@heroiclands/package-build 23.1.1 → 23.2.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.md +63 -0
- package/bin/content-commands.mjs +68 -149
- package/bin/package-build.mjs +56 -10
- package/ci/check-banners.mjs +42 -6
- package/ci/confirm-npm-version.mjs +135 -0
- package/ci/content-format-reference.mjs +14 -5
- package/config.mjs +1 -0
- package/container.mjs +185 -4
- package/content-config.mjs +333 -210
- package/deploy.mjs +116 -10
- package/docs/api.md +238 -246
- package/docs/authoring/assets.md +11 -5
- package/docs/authoring/frontmatter.md +1 -1
- package/docs/authoring/links-and-markup.md +87 -0
- package/docs/commands.md +138 -99
- package/docs/configuration.md +277 -125
- package/docs/content-format.md +2 -0
- package/docs/diagnostics.md +48 -13
- package/docs/guides/book.md +341 -21
- package/docs/guides/e2e.md +13 -2
- package/docs/guides/icons.md +14 -4
- package/docs/guides/module-project.md +18 -8
- package/docs/guides/release-and-deploy.md +32 -0
- package/docs/guides/site.md +5 -2
- package/docs/project-setup.md +30 -10
- package/docs/reference/format-details.md +373 -170
- package/docs/reference/note-types.md +128 -109
- package/docs/reference/theme-pages.md +31 -6
- package/e2e.mjs +140 -28
- package/engine/actor-compiler.mjs +22 -31
- package/engine/address-charset.mjs +4 -17
- package/engine/address-fields.mjs +1 -1
- package/engine/address-render.mjs +12 -1
- package/engine/address-values.mjs +6 -0
- package/engine/address.mjs +46 -33
- package/engine/art-fields.mjs +30 -5
- package/engine/art-slots.mjs +10 -1
- package/engine/asset-bindings.mjs +12 -1
- package/engine/base-compiler.mjs +34 -65
- package/engine/being-age.mjs +2 -7
- package/engine/being-measurements.mjs +12 -1
- package/engine/calendar-artifacts.mjs +6 -0
- package/engine/calendar-choice.mjs +12 -1
- package/engine/calendar-format.mjs +12 -1
- package/engine/calendar-human.mjs +12 -1
- package/engine/compile-corpus.mjs +3 -1
- package/engine/content-address.mjs +9 -12
- package/engine/content-alerts.mjs +13 -1
- package/engine/content-blocks.mjs +14 -3
- package/engine/content-embeds.mjs +11 -3
- package/engine/content-figures.mjs +9 -0
- package/engine/content-footnotes.mjs +12 -1
- package/engine/content-format-check.mjs +11 -262
- package/engine/content-format.yaml +5 -2
- package/engine/content-images.mjs +3 -0
- package/engine/content-index.mjs +443 -20
- package/engine/content-links.mjs +29 -35
- package/engine/content-lint.mjs +2 -3
- package/engine/content-poetry.mjs +12 -1
- package/engine/content-slug.mjs +2 -14
- package/engine/content-spans.mjs +13 -1
- package/engine/content-tables.mjs +12 -1
- package/engine/culture-choice.mjs +210 -13
- package/engine/data-keys.mjs +19 -16
- package/engine/date-conversion.mjs +12 -1
- package/engine/declaration-check.mjs +9 -0
- package/engine/derived-fields.mjs +6 -7
- package/engine/diagnostics.mjs +34 -4
- package/engine/docs-checks.mjs +9 -0
- package/engine/document-subtypes.mjs +47 -34
- package/engine/event-views.mjs +139 -14
- package/engine/exported-scene.mjs +92 -28
- package/engine/extension-attributes.mjs +12 -1
- package/engine/field-spec.mjs +11 -102
- package/engine/folder-notes.mjs +0 -64
- package/engine/foreign-catalog.mjs +2 -1
- package/engine/format-generated.mjs +9 -0
- package/engine/foundry-entries.mjs +10 -28
- package/engine/frontmatter-lint.mjs +83 -392
- package/engine/frontmatter.mjs +20 -70
- package/engine/generate.mjs +10 -15
- package/engine/heading-attributes.mjs +12 -1
- package/engine/helpers.mjs +41 -102
- package/engine/holdings.mjs +1 -1
- package/engine/homepage.mjs +3 -10
- package/engine/ids.mjs +1 -167
- package/engine/index-records.mjs +6 -4
- package/engine/index.mjs +0 -3
- package/engine/infobox-render.mjs +15 -3
- package/engine/infobox.mjs +13 -14
- package/engine/item-compiler.mjs +4 -10
- package/engine/item-docs.mjs +3 -5
- package/engine/item-registry.mjs +2 -10
- package/engine/journals.mjs +1 -5
- package/engine/literature-notes.mjs +4 -12
- package/engine/macros.mjs +1 -2
- package/engine/map-graphviz.mjs +12 -1
- package/engine/markdown-expressions.mjs +12 -1
- package/engine/markdownlint-run.mjs +567 -0
- package/engine/metadata-index.mjs +42 -12
- package/engine/note-addresses.mjs +143 -35
- package/engine/note-claims.mjs +14 -28
- package/engine/note-dates.mjs +3 -3
- package/engine/note-frontmatter.mjs +6 -0
- package/engine/note-ids.mjs +8 -14
- package/engine/note-package.mjs +8 -25
- package/engine/note-state.mjs +1 -3
- package/engine/note-vocabulary.mjs +148 -84
- package/engine/number-words.mjs +12 -1
- package/engine/pack-config.mjs +15 -25
- package/engine/pack-router.mjs +10 -16
- package/engine/pathnames.mjs +8 -22
- package/engine/pdf-build.mjs +773 -121
- package/engine/pdf-coverage.mjs +188 -0
- package/engine/pdf-index.mjs +54 -7
- package/engine/pdf-pages.mjs +247 -0
- package/engine/pdf-render.mjs +114 -4
- package/engine/pdf-toc.mjs +29 -32
- package/engine/project-init.mjs +46 -6
- package/engine/prose-config.mjs +1 -1
- package/engine/prose-lint.mjs +52 -88
- package/engine/readability-lint.mjs +9 -0
- package/engine/reckoning-markers.mjs +12 -1
- package/engine/reference-targets.mjs +7 -0
- package/engine/runtime-only-fields.mjs +5 -8
- package/engine/scene-levels.mjs +2 -2
- package/engine/scenes.mjs +1 -1
- package/engine/shortcode-references.mjs +7 -0
- package/engine/site-build.mjs +30 -16
- package/engine/site-index.mjs +10 -16
- package/engine/site-preview.mjs +6 -0
- package/engine/site-root.mjs +8 -1
- package/engine/social-tie-terms.mjs +12 -1
- package/engine/social-ties.mjs +3 -0
- package/engine/sql-tables.mjs +56 -20
- package/engine/standing-terms.mjs +11 -1
- package/engine/standings.mjs +11 -27
- package/engine/system-block.mjs +39 -141
- package/engine/system-vocabulary.mjs +6 -0
- package/engine/walk-files.mjs +69 -0
- package/engine/web-wikilinks.mjs +23 -3
- package/engine/wikilink-syntax.mjs +32 -21
- package/engine/wikilinks.mjs +27 -16
- package/githooks/attribution.mjs +8 -0
- package/githooks/commit-message.mjs +9 -0
- package/hm3/actors.mjs +9 -48
- package/hm3/default-item-art.mjs +9 -9
- package/hm3/item-fields.mjs +2 -9
- package/hm3/template-priority.mjs +6 -7
- package/hugo-theme/data/banner-kinds.yaml +78 -0
- package/hugo-theme/data/banners.yaml +2 -3
- package/hugo-theme/layouts/404.html +3 -3
- package/hugo-theme/layouts/partials/banner-url.html +31 -7
- package/hugo-theme/layouts/partials/hero-banner.html +13 -14
- package/hugo-theme/layouts/partials/related.html +1 -1
- package/hugo-theme/layouts/partials/warn-missing-banner.html +1 -1
- package/hugo-theme/static/css/style.css +1 -1
- package/manifest.mjs +11 -4
- package/markdownlint-config.mjs +1 -1
- package/package.json +6 -3
- package/release.mjs +21 -14
- package/sohl/actors.mjs +16 -27
- package/sohl/default-item-art.mjs +3 -3
- package/sohl/document-subtypes.mjs +1 -16
- package/sohl/infobox.mjs +2 -3
- package/sohl/item-builders.mjs +5 -8
- package/sohl/item-fields.mjs +5 -35
- package/sohl/items.mjs +0 -4
- package/sohl/kb-passes.mjs +3 -3
- package/types/container.d.mts +116 -5
- package/types/content-config.d.mts +83 -35
- package/types/deploy.d.mts +42 -6
- package/types/e2e.d.mts +35 -3
- package/types/engine/address-charset.d.mts +4 -17
- package/types/engine/address.d.mts +17 -8
- package/types/engine/art-fields.d.mts +7 -4
- package/types/engine/art-slots.d.mts +6 -0
- package/types/engine/base-compiler.d.mts +15 -17
- package/types/engine/content-address.d.mts +9 -12
- package/types/engine/content-embeds.d.mts +4 -2
- package/types/engine/content-format-check.d.mts +0 -64
- package/types/engine/content-index.d.mts +82 -3
- package/types/engine/content-links.d.mts +2 -4
- package/types/engine/culture-choice.d.mts +51 -8
- package/types/engine/data-keys.d.mts +2 -2
- package/types/engine/derived-fields.d.mts +1 -2
- package/types/engine/diagnostics.d.mts +27 -4
- package/types/engine/document-subtypes.d.mts +12 -31
- package/types/engine/event-views.d.mts +14 -2
- package/types/engine/exported-scene.d.mts +3 -2
- package/types/engine/field-spec.d.mts +13 -122
- package/types/engine/folder-notes.d.mts +0 -39
- package/types/engine/foreign-catalog.d.mts +2 -1
- package/types/engine/foundry-entries.d.mts +4 -5
- package/types/engine/frontmatter-lint.d.mts +2 -2
- package/types/engine/frontmatter.d.mts +19 -45
- package/types/engine/generate.d.mts +1 -1
- package/types/engine/helpers.d.mts +25 -42
- package/types/engine/homepage.d.mts +3 -10
- package/types/engine/ids.d.mts +0 -114
- package/types/engine/index-records.d.mts +6 -4
- package/types/engine/index.d.mts +0 -1
- package/types/engine/infobox-render.d.mts +7 -1
- package/types/engine/infobox.d.mts +3 -2
- package/types/engine/item-docs.d.mts +1 -1
- package/types/engine/item-registry.d.mts +2 -4
- package/types/engine/literature-notes.d.mts +1 -1
- package/types/engine/markdownlint-run.d.mts +40 -0
- package/types/engine/metadata-index.d.mts +9 -1
- package/types/engine/note-addresses.d.mts +27 -2
- package/types/engine/note-claims.d.mts +3 -5
- package/types/engine/note-ids.d.mts +2 -4
- package/types/engine/note-package.d.mts +1 -1
- package/types/engine/note-state.d.mts +1 -3
- package/types/engine/note-vocabulary.d.mts +28 -21
- package/types/engine/pack-config.d.mts +2 -6
- package/types/engine/pack-router.d.mts +4 -4
- package/types/engine/pathnames.d.mts +3 -3
- package/types/engine/pdf-build.d.mts +98 -12
- package/types/engine/pdf-coverage.d.mts +60 -0
- package/types/engine/pdf-index.d.mts +34 -5
- package/types/engine/pdf-pages.d.mts +143 -0
- package/types/engine/pdf-render.d.mts +45 -1
- package/types/engine/pdf-toc.d.mts +12 -14
- package/types/engine/prose-config.d.mts +1 -1
- package/types/engine/prose-lint.d.mts +11 -10
- package/types/engine/runtime-only-fields.d.mts +2 -2
- package/types/engine/site-build.d.mts +9 -5
- package/types/engine/site-index.d.mts +4 -5
- package/types/engine/site-root.d.mts +1 -1
- package/types/engine/sql-tables.d.mts +31 -2
- package/types/engine/standing-terms.d.mts +2 -1
- package/types/engine/standings.d.mts +7 -9
- package/types/engine/system-block.d.mts +10 -44
- package/types/engine/walk-files.d.mts +15 -0
- package/types/engine/wikilink-syntax.d.mts +18 -19
- package/types/hm3/actors.d.mts +1 -2
- package/types/hm3/item-fields.d.mts +2 -4
- package/types/hm3/template-priority.d.mts +6 -7
- package/types/manifest.d.mts +3 -2
- package/types/release.d.mts +8 -7
- package/types/sohl/actors.d.mts +2 -1
- package/types/sohl/document-subtypes.d.mts +1 -2
- package/types/sohl/item-fields.d.mts +3 -7
- package/engine/retired-fields.mjs +0 -631
- package/types/engine/retired-fields.d.mts +0 -354
|
@@ -0,0 +1,188 @@
|
|
|
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 the declared books cover, as a pure function of their selections.
|
|
16
|
+
*
|
|
17
|
+
* Every book's filters run against one corpus before any book renders, so the
|
|
18
|
+
* questions that span books are answered once, here: which printable notes no
|
|
19
|
+
* book selects, which book is each selected note's home, and how many notes
|
|
20
|
+
* each book prints. Nothing is read or written; the build hands in the records
|
|
21
|
+
* and prints what comes back.
|
|
22
|
+
*
|
|
23
|
+
* - **A candidate** is a note a book can print — not a stub, not a `gm` note —
|
|
24
|
+
* and not named by `pdf.unprinted`. A candidate no book selects is a finding
|
|
25
|
+
* at the severity `pdf.coverage` names, and `off` emits none.
|
|
26
|
+
* - **The home book** is the first book in configuration order that selects the
|
|
27
|
+
* note. A note several books select prints in each; a note selected twice
|
|
28
|
+
* inside one book is still one book.
|
|
29
|
+
*
|
|
30
|
+
* @module engine/pdf-coverage
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { isNoteRecord, isStub } from "./index-records.mjs";
|
|
34
|
+
import { encodeAddresses } from "./address-values.mjs";
|
|
35
|
+
import { isGmNote } from "./note-vocabulary.mjs";
|
|
36
|
+
|
|
37
|
+
/** The message a note in no book carries. */
|
|
38
|
+
export const UNCOVERED_MESSAGE =
|
|
39
|
+
"no book selects this note; add it to a book's query or to `pdf.unprinted`";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether a record is a note a book can print.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} record - An index record.
|
|
45
|
+
* @returns {boolean} False for an asset, a documentation row, a stub and a `gm` note.
|
|
46
|
+
*/
|
|
47
|
+
export function isPrintable(record) {
|
|
48
|
+
return isNoteRecord(record) && !isStub(record) && !isGmNote(record);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The key a note is counted by: its canonical address as text. An index record
|
|
53
|
+
* holds the address as a tuple and a row of the notes database as a string;
|
|
54
|
+
* both give the same key.
|
|
55
|
+
*
|
|
56
|
+
* @param {object} record - A printable record.
|
|
57
|
+
* @returns {string} The key.
|
|
58
|
+
*/
|
|
59
|
+
export function recordKey(record) {
|
|
60
|
+
const canonical = record?.address?.canonical;
|
|
61
|
+
return String(
|
|
62
|
+
canonical == null ?
|
|
63
|
+
(record?.file?.path ?? record?.shortcode ?? "")
|
|
64
|
+
: encodeAddresses(canonical),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Whether a note has no resolved culture.
|
|
70
|
+
*
|
|
71
|
+
* @param {object} record - A record.
|
|
72
|
+
* @returns {boolean} True when `data.culture` is absent, null or empty.
|
|
73
|
+
*/
|
|
74
|
+
function lacksCulture(record) {
|
|
75
|
+
const culture = record?.data?.culture;
|
|
76
|
+
return culture == null || culture === "" || (Array.isArray(culture) && !culture.length);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Compute coverage, homes and the summary.
|
|
81
|
+
*
|
|
82
|
+
* @param {object} input - Options.
|
|
83
|
+
* @param {Array<{id: string, records: object[]}>} input.books - Each book's
|
|
84
|
+
* selected records, in configuration order; a record selected twice appears
|
|
85
|
+
* twice.
|
|
86
|
+
* @param {object[]} input.corpus - Every record in the index.
|
|
87
|
+
* @param {object[]} [input.unprinted] - The records `pdf.unprinted` selects.
|
|
88
|
+
* @param {"warning"|"error"|"off"} [input.mode] - `pdf.coverage`.
|
|
89
|
+
* @param {(record: object) => {file?: string, line?: number, column?: number}} [input.locate]
|
|
90
|
+
* Where a note's `type:` line sits; a field it cannot supply is dropped.
|
|
91
|
+
* @returns {{findings: object[], homes: Map<string, string>, summary: object}}
|
|
92
|
+
* The findings in corpus order, each note's home book id by address, and the
|
|
93
|
+
* summary: per-book counts, totals and every note several books select.
|
|
94
|
+
*/
|
|
95
|
+
export function coverage({ books, corpus, unprinted = [], mode = "warning", locate = () => ({}) }) {
|
|
96
|
+
const shelved = new Set(unprinted.filter(isPrintable).map(recordKey));
|
|
97
|
+
const candidates = corpus.filter(
|
|
98
|
+
(record) => isPrintable(record) && !shelved.has(recordKey(record)),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
/** @type {Map<string, string>} */
|
|
102
|
+
const homes = new Map();
|
|
103
|
+
/** @type {Map<string, {record: object, books: string[]}>} */
|
|
104
|
+
const carriers = new Map();
|
|
105
|
+
const perBook = books.map((book) => {
|
|
106
|
+
const distinct = new Map();
|
|
107
|
+
for (const record of book.records) {
|
|
108
|
+
const key = recordKey(record);
|
|
109
|
+
distinct.set(key, record);
|
|
110
|
+
if (!homes.has(key)) homes.set(key, book.id);
|
|
111
|
+
const carrier = carriers.get(key) ?? { record, books: [] };
|
|
112
|
+
if (!carrier.books.includes(book.id)) carrier.books.push(book.id);
|
|
113
|
+
carriers.set(key, carrier);
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
id: book.id,
|
|
117
|
+
entries: book.records.length,
|
|
118
|
+
notes: distinct.size,
|
|
119
|
+
uncultured: [...distinct.values()].filter(lacksCulture).length,
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const uncovered = candidates.filter((record) => !carriers.has(recordKey(record)));
|
|
124
|
+
const findings =
|
|
125
|
+
mode === "off" ?
|
|
126
|
+
[]
|
|
127
|
+
: uncovered.map((record) => ({
|
|
128
|
+
...locate(record),
|
|
129
|
+
severity: mode,
|
|
130
|
+
message: UNCOVERED_MESSAGE,
|
|
131
|
+
}));
|
|
132
|
+
|
|
133
|
+
const order = new Map(books.map((book, index) => [book.id, index]));
|
|
134
|
+
const multiple = [...carriers.values()]
|
|
135
|
+
.filter((carrier) => carrier.books.length > 1)
|
|
136
|
+
.map((carrier) => ({
|
|
137
|
+
name: String(carrier.record?.name?.full ?? carrier.record?.shortcode ?? ""),
|
|
138
|
+
path: String(carrier.record?.file?.path ?? ""),
|
|
139
|
+
books: carrier.books,
|
|
140
|
+
home: carrier.books[0],
|
|
141
|
+
}))
|
|
142
|
+
.sort(
|
|
143
|
+
(a, b) =>
|
|
144
|
+
order.get(a.home) - order.get(b.home) ||
|
|
145
|
+
(a.path < b.path ? -1
|
|
146
|
+
: a.path > b.path ? 1
|
|
147
|
+
: 0),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
findings,
|
|
152
|
+
homes,
|
|
153
|
+
summary: {
|
|
154
|
+
books: perBook,
|
|
155
|
+
totals: {
|
|
156
|
+
candidates: candidates.length,
|
|
157
|
+
uncovered: uncovered.length,
|
|
158
|
+
unprinted: shelved.size,
|
|
159
|
+
},
|
|
160
|
+
multiple,
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The summary as the lines the command prints.
|
|
167
|
+
*
|
|
168
|
+
* @param {object} summary - From {@link coverage}.
|
|
169
|
+
* @returns {string[]} One line per book, one for the totals, one per note
|
|
170
|
+
* several books select.
|
|
171
|
+
*/
|
|
172
|
+
export function summaryLines(summary) {
|
|
173
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
174
|
+
return [
|
|
175
|
+
...summary.books.map(
|
|
176
|
+
(book) =>
|
|
177
|
+
`${book.id}: ${plural(book.entries, "entry", "entries")}, ` +
|
|
178
|
+
`${plural(book.notes, "note", "notes")}, ${book.uncultured} without a culture`,
|
|
179
|
+
),
|
|
180
|
+
`${plural(summary.totals.candidates, "printable note", "printable notes")}, ` +
|
|
181
|
+
`${summary.totals.uncovered} in no book, ${summary.totals.unprinted} in pdf.unprinted`,
|
|
182
|
+
...summary.multiple.map(
|
|
183
|
+
(note) =>
|
|
184
|
+
`${note.name} (${note.path}) is printed by ${note.books.join(", ")}; ` +
|
|
185
|
+
`home: ${note.home}`,
|
|
186
|
+
),
|
|
187
|
+
];
|
|
188
|
+
}
|
package/engine/pdf-index.mjs
CHANGED
|
@@ -88,11 +88,22 @@ function letterOf(key) {
|
|
|
88
88
|
/**
|
|
89
89
|
* Every term the book's index lists, in index order.
|
|
90
90
|
*
|
|
91
|
+
* A book lists the notes it is home to. An entry `owns` rejects is a reprint of
|
|
92
|
+
* another book's note: it prints there, and its term belongs to its home
|
|
93
|
+
* book's index.
|
|
94
|
+
*
|
|
91
95
|
* @param {object[]} entries - The plan's entries.
|
|
92
|
-
* @
|
|
93
|
-
*
|
|
96
|
+
* @param {object} [opts] - Options.
|
|
97
|
+
* @param {(entry: object) => boolean} [opts.owns] - Whether this book is the
|
|
98
|
+
* entry's home. Every entry when absent.
|
|
99
|
+
* @param {Array<{slug: string, name: string, title: string, page: string|number}>} [opts.foreign] -
|
|
100
|
+
* Notes another book prints, listed as `Name: Book title, p. N` once this
|
|
101
|
+
* book mentions them.
|
|
102
|
+
* @returns {Array<{letter: string, name: string, slug: string, see?: string,
|
|
103
|
+
* foreign?: {title: string, page: string}}>} Terms with `see` set for a
|
|
104
|
+
* cross-reference to the main name it points at.
|
|
94
105
|
*/
|
|
95
|
-
export function indexTerms(entries) {
|
|
106
|
+
export function indexTerms(entries, { owns = () => true, foreign = [] } = {}) {
|
|
96
107
|
const terms = [];
|
|
97
108
|
const info = new Map();
|
|
98
109
|
const nameByCanonical = new Map();
|
|
@@ -103,7 +114,7 @@ export function indexTerms(entries) {
|
|
|
103
114
|
if (canonical && name) nameByCanonical.set(String(canonical), name);
|
|
104
115
|
}
|
|
105
116
|
for (const entry of entries ?? []) {
|
|
106
|
-
if (entry?.kind !== "note") continue;
|
|
117
|
+
if (entry?.kind !== "note" || !owns(entry)) continue;
|
|
107
118
|
const slug = slugOf(entry.record);
|
|
108
119
|
const name = String(entry.record?.name?.full ?? "").trim();
|
|
109
120
|
if (!slug || !name || info.has(slug)) continue;
|
|
@@ -166,6 +177,18 @@ export function indexTerms(entries) {
|
|
|
166
177
|
...(term.main ? {} : { see: mainShown.get(term.slug) }),
|
|
167
178
|
key: sortKey(shown[i]),
|
|
168
179
|
}));
|
|
180
|
+
const listed = new Set(terms.map((term) => term.slug));
|
|
181
|
+
for (const other of foreign) {
|
|
182
|
+
const name = String(other.name ?? "").trim();
|
|
183
|
+
if (!other.slug || !name || listed.has(other.slug)) continue;
|
|
184
|
+
listed.add(other.slug);
|
|
185
|
+
keyed.push({
|
|
186
|
+
name,
|
|
187
|
+
slug: other.slug,
|
|
188
|
+
foreign: { title: String(other.title), page: String(other.page) },
|
|
189
|
+
key: sortKey(name),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
169
192
|
keyed.sort((a, b) => {
|
|
170
193
|
const la = letterOf(a.key);
|
|
171
194
|
const lb = letterOf(b.key);
|
|
@@ -190,9 +213,12 @@ export function indexTerms(entries) {
|
|
|
190
213
|
/**
|
|
191
214
|
* The Typst function definitions the markers and the chapter rely on.
|
|
192
215
|
*
|
|
216
|
+
* @param {object} [opts] - Options.
|
|
217
|
+
* @param {boolean} [opts.seeAlso] - Whether the chapter may list notes another
|
|
218
|
+
* book prints.
|
|
193
219
|
* @returns {string} Typst source.
|
|
194
220
|
*/
|
|
195
|
-
export function indexPreamble() {
|
|
221
|
+
export function indexPreamble({ seeAlso = false } = {}) {
|
|
196
222
|
return [
|
|
197
223
|
// Zero-size and invisible; the label is what the chapter queries.
|
|
198
224
|
"#let book-ix(key, main: false) = [#metadata((key: key, main: main)) <book-index>]",
|
|
@@ -218,10 +244,25 @@ export function indexPreamble() {
|
|
|
218
244
|
" }",
|
|
219
245
|
" let letter = none",
|
|
220
246
|
" for term in terms {",
|
|
247
|
+
...(seeAlso ?
|
|
248
|
+
[
|
|
249
|
+
// A note another book prints is listed once this book mentions it.
|
|
250
|
+
' let away = term.at("foreign", default: none)',
|
|
251
|
+
" if away != none and by.at(term.slug, default: ()).len() == 0 { continue }",
|
|
252
|
+
]
|
|
253
|
+
: []),
|
|
221
254
|
" if term.letter != letter {",
|
|
222
255
|
" letter = term.letter",
|
|
223
256
|
" heading(level: 5, outlined: false, bookmarked: false)[#letter]",
|
|
224
257
|
" }",
|
|
258
|
+
...(seeAlso ?
|
|
259
|
+
[
|
|
260
|
+
" if away != none {",
|
|
261
|
+
" par[#term.name: #emph[#away.title], p. #away.page]",
|
|
262
|
+
" continue",
|
|
263
|
+
" }",
|
|
264
|
+
]
|
|
265
|
+
: []),
|
|
225
266
|
" if term.see != none {",
|
|
226
267
|
" let main = merged(term.slug).filter(p => p.main)",
|
|
227
268
|
" let shown = if main.len() > 0 { link(main.first().loc)[#term.see] } else { term.see }",
|
|
@@ -241,16 +282,22 @@ export function indexPreamble() {
|
|
|
241
282
|
*
|
|
242
283
|
* @param {object[]} entries - The plan's entries.
|
|
243
284
|
* @param {string} title - The book's title, which kicks the chapter.
|
|
285
|
+
* @param {object} [opts] - As {@link indexTerms}.
|
|
244
286
|
* @returns {string} Typst source, or "" when the book has nothing to index.
|
|
245
287
|
*/
|
|
246
|
-
export function indexChapter(entries, title) {
|
|
247
|
-
const terms = indexTerms(entries);
|
|
288
|
+
export function indexChapter(entries, title, opts = {}) {
|
|
289
|
+
const terms = indexTerms(entries, opts);
|
|
248
290
|
if (!terms.length) return "";
|
|
249
291
|
const rows = terms.map(
|
|
250
292
|
(term) =>
|
|
251
293
|
`(letter: "${escapeString(term.letter)}", name: "${escapeString(term.name)}", ` +
|
|
252
294
|
`slug: "${escapeString(term.slug)}", see: ${
|
|
253
295
|
term.see === undefined ? "none" : `"${escapeString(term.see)}"`
|
|
296
|
+
}${
|
|
297
|
+
term.foreign === undefined ?
|
|
298
|
+
""
|
|
299
|
+
: `, foreign: (title: "${escapeString(term.foreign.title)}", ` +
|
|
300
|
+
`page: "${escapeString(term.foreign.page)}")`
|
|
254
301
|
})`,
|
|
255
302
|
);
|
|
256
303
|
return [
|
|
@@ -0,0 +1,247 @@
|
|
|
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
|
+
* Where each book prints each note, and how a book names a note another book
|
|
16
|
+
* prints.
|
|
17
|
+
*
|
|
18
|
+
* A PDF link cannot reach another file, so a reference to a note whose **home
|
|
19
|
+
* book** — the first book in configuration order that selects it — is another
|
|
20
|
+
* book is set as words: `Name (_Book title_, p. N)`. The page is the one the
|
|
21
|
+
* note's entry starts on in its home book. It is read from the laid-out
|
|
22
|
+
* document rather than predicted, so the build renders every book, measures the
|
|
23
|
+
* pages, and renders again with the table — see {@link module:engine/pdf-build}.
|
|
24
|
+
*
|
|
25
|
+
* - {@link homeEntries} says which book and which anchor each homed note has.
|
|
26
|
+
* - {@link readEntryPages} measures one book's `.typ` with `typst eval`, which
|
|
27
|
+
* lays the document out and answers from its introspection. The page a
|
|
28
|
+
* reader sees printed is the page counter at the entry's heading, so the
|
|
29
|
+
* number needs neither the outline nor a `query` that cannot see layout.
|
|
30
|
+
* - {@link pageTable} is the table written as `pages.json`.
|
|
31
|
+
* - {@link elsewhereFor} is the lookup a book renders its cross-book
|
|
32
|
+
* references from.
|
|
33
|
+
*
|
|
34
|
+
* @module engine/pdf-pages
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { spawnSync } from "node:child_process";
|
|
38
|
+
|
|
39
|
+
import { encodeAddresses } from "./address-values.mjs";
|
|
40
|
+
import { recordKey } from "./pdf-coverage.mjs";
|
|
41
|
+
import { labelFor } from "./pdf-render.mjs";
|
|
42
|
+
|
|
43
|
+
/** What a reference prints for a page that has not been measured. */
|
|
44
|
+
export const UNMEASURED_PAGE = "?";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The Typst expression `typst eval` answers: every labelled heading and the
|
|
48
|
+
* page number the book prints for it.
|
|
49
|
+
*/
|
|
50
|
+
const PAGES_EXPRESSION =
|
|
51
|
+
'query(heading).filter(e => e.at("label", default: none) != none)' +
|
|
52
|
+
'.map(e => (str(e.at("label")), counter(page).at(e.location()).first()))';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The arguments that measure a book, as data.
|
|
56
|
+
*
|
|
57
|
+
* Fonts are the compile's, because a face with other metrics lays out other
|
|
58
|
+
* pages.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} typPath - The `.typ` file.
|
|
61
|
+
* @param {string[]} fontArgs - `--ignore-system-fonts --font-path …`, as the
|
|
62
|
+
* compile passes them.
|
|
63
|
+
* @returns {string[]} The arguments, in order.
|
|
64
|
+
*/
|
|
65
|
+
export function evalArgs(typPath, fontArgs) {
|
|
66
|
+
return ["eval", "--in", typPath, ...fontArgs, PAGES_EXPRESSION];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Measure the page each labelled entry of a book starts on.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} typPath - The `.typ` file.
|
|
73
|
+
* @param {string[]} fontArgs - The compile's font arguments.
|
|
74
|
+
* @param {string} [binary] - The Typst binary.
|
|
75
|
+
* @returns {{ok: boolean, message: string, pages: Map<string, number>}} The
|
|
76
|
+
* printed page of every labelled heading, keyed by label.
|
|
77
|
+
*/
|
|
78
|
+
export function readEntryPages(typPath, fontArgs, binary = "typst") {
|
|
79
|
+
const fail = (message) => ({ ok: false, message, pages: new Map() });
|
|
80
|
+
let result;
|
|
81
|
+
try {
|
|
82
|
+
result = spawnSync(binary, evalArgs(typPath, fontArgs), {
|
|
83
|
+
encoding: "utf8",
|
|
84
|
+
maxBuffer: 1 << 28,
|
|
85
|
+
});
|
|
86
|
+
} catch (err) {
|
|
87
|
+
return fail(`could not run \`${binary}\`: ${err.message}`);
|
|
88
|
+
}
|
|
89
|
+
if (result.error) return fail(`could not run \`${binary}\`: ${result.error.message}`);
|
|
90
|
+
if (result.status !== 0) {
|
|
91
|
+
const detail = String(result.stderr || result.stdout || "")
|
|
92
|
+
.trim()
|
|
93
|
+
.split("\n")[0];
|
|
94
|
+
return fail(`\`${binary} eval\` failed: ${detail}`);
|
|
95
|
+
}
|
|
96
|
+
let rows;
|
|
97
|
+
try {
|
|
98
|
+
rows = JSON.parse(result.stdout);
|
|
99
|
+
} catch {
|
|
100
|
+
return fail(`\`${binary} eval\` printed no page table`);
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
ok: true,
|
|
104
|
+
message: "",
|
|
105
|
+
pages: new Map(rows.map(([label, page]) => [String(label), Number(page)])),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Every note that has a home book, with the anchor its entry carries there.
|
|
111
|
+
*
|
|
112
|
+
* **A note a book prints twice has one page**: the first entry, which is the
|
|
113
|
+
* one the book's links reach. A note a book reprints from another book's home
|
|
114
|
+
* keeps its home book's page here, because the home is the first book in
|
|
115
|
+
* configuration order that selects it.
|
|
116
|
+
*
|
|
117
|
+
* @param {object} input - Options.
|
|
118
|
+
* @param {Array<{id: string, title: string, plan: {entries: object[]}}>} input.books -
|
|
119
|
+
* The books that render, in configuration order.
|
|
120
|
+
* @param {Map<string, string>} input.homes - Each note's key → its home book's
|
|
121
|
+
* id, from {@link module:engine/pdf-coverage.coverage}.
|
|
122
|
+
* @returns {Array<{key: string, book: string, title: string, anchor: string,
|
|
123
|
+
* record: object, slug: string, aliases: string[]}>} One per homed note, in book order and
|
|
124
|
+
* then entry order.
|
|
125
|
+
*/
|
|
126
|
+
export function homeEntries({ books, homes }) {
|
|
127
|
+
const found = [];
|
|
128
|
+
const claimed = new Set();
|
|
129
|
+
for (const book of books) {
|
|
130
|
+
for (const entry of book.plan.entries) {
|
|
131
|
+
if (entry.kind !== "note") continue;
|
|
132
|
+
const key = recordKey(entry.record);
|
|
133
|
+
if (claimed.has(key) || homes.get(key) !== book.id) continue;
|
|
134
|
+
claimed.add(key);
|
|
135
|
+
const record = entry.record;
|
|
136
|
+
found.push({
|
|
137
|
+
key,
|
|
138
|
+
book: book.id,
|
|
139
|
+
title: book.title,
|
|
140
|
+
anchor: entry.anchor,
|
|
141
|
+
record,
|
|
142
|
+
slug: String(record?.address?.slug ?? record?.shortcode ?? ""),
|
|
143
|
+
aliases: [record?.address?.canonical, record?.documentation]
|
|
144
|
+
.filter(Boolean)
|
|
145
|
+
.map((address) => encodeAddresses(address)),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return found;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The table written as `pages.json`.
|
|
154
|
+
*
|
|
155
|
+
* @param {ReturnType<typeof homeEntries>} entries - From {@link homeEntries}.
|
|
156
|
+
* @param {Map<string, Map<string, number>>} measured - Book id → label → page.
|
|
157
|
+
* @returns {Record<string, {book: string, page: number}>} Keyed by the note's
|
|
158
|
+
* key, in sorted key order. A note whose entry was not measured is absent.
|
|
159
|
+
*/
|
|
160
|
+
export function pageTable(entries, measured) {
|
|
161
|
+
const table = {};
|
|
162
|
+
const sorted = [...entries].sort((a, b) =>
|
|
163
|
+
a.key < b.key ? -1
|
|
164
|
+
: a.key > b.key ? 1
|
|
165
|
+
: 0,
|
|
166
|
+
);
|
|
167
|
+
for (const entry of sorted) {
|
|
168
|
+
const page = measured.get(entry.book)?.get(labelFor(entry.anchor));
|
|
169
|
+
if (page !== undefined) table[entry.key] = { book: entry.book, page };
|
|
170
|
+
}
|
|
171
|
+
return table;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The notes whose page differs between two tables.
|
|
176
|
+
*
|
|
177
|
+
* @param {Record<string, {book: string, page: number}>} before - A table.
|
|
178
|
+
* @param {Record<string, {book: string, page: number}>} after - A later one.
|
|
179
|
+
* @returns {Array<{key: string, book: string, before: number|undefined,
|
|
180
|
+
* after: number|undefined}>} In sorted key order.
|
|
181
|
+
*/
|
|
182
|
+
export function movedPages(before, after) {
|
|
183
|
+
const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort();
|
|
184
|
+
return keys
|
|
185
|
+
.filter((key) => before[key]?.page !== after[key]?.page)
|
|
186
|
+
.map((key) => ({
|
|
187
|
+
key,
|
|
188
|
+
book: (after[key] ?? before[key]).book,
|
|
189
|
+
before: before[key]?.page,
|
|
190
|
+
after: after[key]?.page,
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The lookup one book renders its cross-book references from.
|
|
196
|
+
*
|
|
197
|
+
* A note the book prints (a reprint included) is never in it: its link stays an
|
|
198
|
+
* internal destination. Keys are the three forms the book's own link map
|
|
199
|
+
* answers to — slug, canonical address and documentation address.
|
|
200
|
+
*
|
|
201
|
+
* `get` counts the references it answers, which is how the build knows whether
|
|
202
|
+
* a measured pass can change anything.
|
|
203
|
+
*
|
|
204
|
+
* @param {object} input - Options.
|
|
205
|
+
* @param {ReturnType<typeof homeEntries>} input.entries - From {@link homeEntries}.
|
|
206
|
+
* @param {string} input.book - The id of the book that renders.
|
|
207
|
+
* @param {Map<string, string>} input.printed - The book's own link map.
|
|
208
|
+
* @param {Record<string, {book: string, page: number}>} [input.table] - The
|
|
209
|
+
* measured pages; every page is {@link UNMEASURED_PAGE} when absent.
|
|
210
|
+
* @param {boolean} [input.seeAlso] - Whether the book's index lists the notes
|
|
211
|
+
* it mentions here (`pdf.indexSeeAlso`). `listed` is empty when not.
|
|
212
|
+
* @returns {{used: number, seeAlso: boolean,
|
|
213
|
+
* listed: Array<{slug: string, name: string, title: string, page: string|number}>,
|
|
214
|
+
* get: (key: string) => ({slug: string, title: string, page: string|number}|undefined)}}
|
|
215
|
+
* The lookup. `listed` is every note another book prints, for the index to
|
|
216
|
+
* show those this book mentions.
|
|
217
|
+
*/
|
|
218
|
+
export function elsewhereFor({ entries, book, printed, table, seeAlso = false }) {
|
|
219
|
+
const map = new Map();
|
|
220
|
+
const listed = [];
|
|
221
|
+
for (const entry of entries) {
|
|
222
|
+
if (entry.book === book) continue;
|
|
223
|
+
const keys = [entry.slug, ...entry.aliases].filter((key) => key && !printed.has(key));
|
|
224
|
+
const page = table?.[entry.key]?.page ?? UNMEASURED_PAGE;
|
|
225
|
+
for (const key of keys) {
|
|
226
|
+
if (!map.has(key)) map.set(key, { slug: entry.slug, title: entry.title, page });
|
|
227
|
+
}
|
|
228
|
+
if (seeAlso && entry.slug && !printed.has(entry.slug)) {
|
|
229
|
+
listed.push({
|
|
230
|
+
slug: entry.slug,
|
|
231
|
+
name: String(entry.record?.name?.full ?? "").trim(),
|
|
232
|
+
title: entry.title,
|
|
233
|
+
page,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
used: 0,
|
|
239
|
+
seeAlso,
|
|
240
|
+
listed,
|
|
241
|
+
get(key) {
|
|
242
|
+
const found = map.get(key);
|
|
243
|
+
if (found) this.used += 1;
|
|
244
|
+
return found;
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|