@heroiclands/package-build 23.12.3 → 23.12.5

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 (55) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/ci/ci-steps.mjs +5 -5
  3. package/content-config.mjs +19 -0
  4. package/docs/api.md +1 -0
  5. package/docs/authoring/frontmatter.md +1 -1
  6. package/docs/authoring/links-and-markup.md +4 -4
  7. package/docs/commands.md +5 -4
  8. package/docs/configuration.md +29 -12
  9. package/docs/guides/book.md +15 -13
  10. package/docs/guides/release-and-deploy.md +6 -9
  11. package/docs/project-setup.md +11 -11
  12. package/docs/reference/format-details.md +28 -16
  13. package/docs/reference/note-types.md +1 -1
  14. package/engine/content-index.mjs +2 -39
  15. package/engine/event-fields.mjs +24 -1
  16. package/engine/event-rows.mjs +37 -2
  17. package/engine/event-views.mjs +58 -14
  18. package/engine/frontmatter-lint.mjs +11 -0
  19. package/engine/generated-sections.mjs +8 -1
  20. package/engine/holdings.mjs +8 -2
  21. package/engine/infobox-render.mjs +16 -5
  22. package/engine/infobox.mjs +23 -2
  23. package/engine/literature-works.mjs +8 -2
  24. package/engine/metadata-index.mjs +3 -0
  25. package/engine/name-sort.mjs +91 -0
  26. package/engine/note-frontmatter.mjs +1 -1
  27. package/engine/note-vocabulary.mjs +1 -1
  28. package/engine/page-lists.mjs +3 -2
  29. package/engine/pdf-build.mjs +1 -0
  30. package/engine/pdf-index.mjs +33 -12
  31. package/engine/pdf-pages.mjs +13 -3
  32. package/engine/pdf-render.mjs +60 -33
  33. package/engine/pdf-toc.mjs +4 -3
  34. package/engine/project-init.mjs +2 -2
  35. package/engine/related-pages.mjs +12 -2
  36. package/engine/site-build.mjs +3 -0
  37. package/engine/sql-tables.mjs +20 -5
  38. package/githooks/pre-push +5 -5
  39. package/package.json +1 -1
  40. package/types/content-config.d.mts +5 -0
  41. package/types/engine/content-index.d.mts +2 -31
  42. package/types/engine/event-fields.d.mts +11 -0
  43. package/types/engine/event-rows.d.mts +4 -1
  44. package/types/engine/event-views.d.mts +7 -3
  45. package/types/engine/generated-sections.d.mts +6 -0
  46. package/types/engine/holdings.d.mts +7 -1
  47. package/types/engine/infobox.d.mts +11 -0
  48. package/types/engine/literature-works.d.mts +5 -0
  49. package/types/engine/name-sort.d.mts +49 -0
  50. package/types/engine/pdf-index.d.mts +7 -3
  51. package/types/engine/pdf-pages.d.mts +2 -1
  52. package/types/engine/pdf-render.d.mts +16 -5
  53. package/types/engine/pdf-toc.d.mts +1 -1
  54. package/types/engine/related-pages.d.mts +5 -0
  55. package/ci/confirm-npm-version.mjs +0 -135
@@ -34,18 +34,22 @@ export function eventViewSections(db: object, records: Iterable<object>, fileOf:
34
34
  *
35
35
  * @type {ReadonlyArray<Readonly<{slug: string, heading: string,
36
36
  * applies: (record: object) => boolean,
37
- * query: (self: string[], shape: CorpusShape) => {fence: string, own?: string}|null,
38
- * owners?: (db: object, selves: string[][], shape: CorpusShape) => Promise<Set<string>>}>>}
37
+ * query: (self: string[], shape: CorpusShape, columns?: {felt?: boolean}) => {fence: string, own?: string}|null,
38
+ * owners?: (db: object, selves: string[][], shape: CorpusShape) => Promise<Set<string>>,
39
+ * felt?: (db: object, selves: string[][], shape: CorpusShape) => Promise<Set<string>>}>>}
39
40
  */
40
41
  export const EVENT_VIEWS: ReadonlyArray<Readonly<{
41
42
  slug: string;
42
43
  heading: string;
43
44
  applies: (record: object) => boolean;
44
- query: (self: string[], shape: CorpusShape) => {
45
+ query: (self: string[], shape: CorpusShape, columns?: {
46
+ felt?: boolean;
47
+ }) => {
45
48
  fence: string;
46
49
  own?: string;
47
50
  } | null;
48
51
  owners?: (db: object, selves: string[][], shape: CorpusShape) => Promise<Set<string>>;
52
+ felt?: (db: object, selves: string[][], shape: CorpusShape) => Promise<Set<string>>;
49
53
  }>>;
50
54
  export type CorpusShape = {
51
55
  /**
@@ -110,6 +110,12 @@ export function splitGenerated(markdown: string): {
110
110
  *
111
111
  * @module
112
112
  */
113
+ /**
114
+ * The anchor of the Chronology section, which the book sets across the page.
115
+ *
116
+ * @type {string}
117
+ */
118
+ export const CHRONOLOGY_SLUG: string;
113
119
  /**
114
120
  * Every generated section, in the order it follows the author's text: where a
115
121
  * place sits and who governs it, then what happened, then what was written
@@ -1,10 +1,11 @@
1
- /** @typedef {{title:string,url?:string,address:string,type:string,subType?:string}} HoldingsEntry */
1
+ /** @typedef {{title:string,sort?:string,url?:string,address:string,type:string,subType?:string}} HoldingsEntry */
2
2
  /**
3
3
  * @typedef {object} HoldingsNode
4
4
  * @property {string} shortcode
5
5
  * @property {"place"|"affiliation"} type
6
6
  * @property {string} [subType]
7
7
  * @property {string} title
8
+ * @property {string} [sort] The note's `name.sort`; the list files by it in place of `title`.
8
9
  * @property {string} [url]
9
10
  * @property {string} [package] Owning content package.
10
11
  * @property {string} [system] Own document system.
@@ -60,6 +61,7 @@ export function holdingsPages(nodes: Iterable<HoldingsNode | null>, { government
60
61
  export function checkGovernment(note: object): object[];
61
62
  export type HoldingsEntry = {
62
63
  title: string;
64
+ sort?: string;
63
65
  url?: string;
64
66
  address: string;
65
67
  type: string;
@@ -70,6 +72,10 @@ export type HoldingsNode = {
70
72
  type: "place" | "affiliation";
71
73
  subType?: string | undefined;
72
74
  title: string;
75
+ /**
76
+ * The note's `name.sort`; the list files by it in place of `title`.
77
+ */
78
+ sort?: string | undefined;
73
79
  url?: string | undefined;
74
80
  /**
75
81
  * Owning content package.
@@ -94,6 +94,17 @@ export function hasRenderableValue(kind: string, value: unknown): boolean;
94
94
  * @returns {string} One of {@link INFOBOX_VALUE_KINDS}.
95
95
  */
96
96
  export function valueKindOf(field: object, value: unknown): string;
97
+ /**
98
+ * A number as a reader reads it in a profile: under a million in full with
99
+ * thousands separators (`850`, `12,500`), and from a million up in words with
100
+ * at most one decimal (`11 million`, `1.2 million`, `3 billion`). Every figure
101
+ * the setting gives at that scale is an estimate, and the words say so.
102
+ * Decimals below a million keep the digits they were written with.
103
+ *
104
+ * @param {number} value - The number.
105
+ * @returns {string} The text the row shows.
106
+ */
107
+ export function formatQuantity(value: number): string;
97
108
  /**
98
109
  * One row, with its unit on it.
99
110
  *
@@ -38,6 +38,7 @@ export function worksPages(nodes: Iterable<WorksNode | null>, { types }: {
38
38
  }>;
39
39
  export type WorkEntry = {
40
40
  title: string;
41
+ sort?: string;
41
42
  url?: string;
42
43
  address: string;
43
44
  form?: string;
@@ -48,6 +49,10 @@ export type WorksNode = {
48
49
  */
49
50
  key: string;
50
51
  title: string;
52
+ /**
53
+ * The note's `name.sort`; the list files by it in place of `title`.
54
+ */
55
+ sort?: string | undefined;
51
56
  /**
52
57
  * Absent for a note that publishes no page.
53
58
  */
@@ -0,0 +1,49 @@
1
+ /**
2
+ * A note's display name reduced to printable 7-bit ASCII.
3
+ *
4
+ * Content names carry the setting's orthography — `Kûrbúl Helm`, `Hârn`,
5
+ * `Kèthîra` — and nobody types them. A reader searching the index, or an editor
6
+ * completing a wikilink, needs a form that matches what a keyboard produces, so
7
+ * the record states one rather than leaving every consumer to invent it (and to
8
+ * invent a *different* one, which is how two searches over the same data come
9
+ * to disagree).
10
+ *
11
+ * **Transliterated, not stripped.** `unidecode` — the same table
12
+ * {@link slugify} already runs, so an ASCII name and a slug can never disagree
13
+ * about a character — carries a letter across rather than deleting it:
14
+ * diacritics fold (`â`→`a`, `è`→`e`), ligatures expand (`æ`→`ae`, `Œ`→`OE`,
15
+ * `ß`→`ss`), the runic letters spell out (`þ`→`th`, `Þ`→`Th`, `ð`→`d`), and
16
+ * even a vulgar fraction becomes readable (`¾`→`3/4`). Deleting them instead
17
+ * would collapse `Kûrbúl` to `Krbl`, which is worse than the original.
18
+ *
19
+ * Anything still outside printable ASCII after that becomes a space, and runs
20
+ * of whitespace collapse — a space rather than nothing, so a character that
21
+ * transliterates away cannot silently weld two words together.
22
+ *
23
+ * The value is emitted even when it equals the name, so a consumer matching on
24
+ * it never has to branch on whether the name happened to be ASCII already.
25
+ *
26
+ * @param {unknown} name - The note's `name.full`.
27
+ * @returns {string|null} The ASCII form, or `null` when there is no name, or
28
+ * nothing printable survives.
29
+ */
30
+ export function asciiName(name: unknown): string | null;
31
+ /**
32
+ * The name a note files under: its `name.sort` when it declares one, otherwise
33
+ * its `name.full`.
34
+ *
35
+ * @param {unknown} record - An index record or a note's frontmatter.
36
+ * @returns {string|undefined} The unfolded key, or `undefined` for a record
37
+ * with no name.
38
+ */
39
+ export function nameSortOf(record: unknown): string | undefined;
40
+ /**
41
+ * The key a name-ordered list compares notes on: {@link nameSortOf} folded to
42
+ * printable ASCII by {@link asciiName}, so an accented sort key files beside
43
+ * its base letters. Case is left for the caller, which decides whether its
44
+ * comparison ignores it.
45
+ *
46
+ * @param {unknown} record - An index record or a note's frontmatter.
47
+ * @returns {string} The key, or "" for a record with no name.
48
+ */
49
+ export function nameSortKey(record: unknown): string;
@@ -20,11 +20,13 @@ export function indexMarker(slug: string, main?: boolean): string;
20
20
  * @param {string[]} [opts.heads] - Leading words (`pdf.indexHeads`) matched
21
21
  * literally against the start of a name; a name that opens with one is also
22
22
  * listed as `<rest>, <head>`, the head without its trailing space or colon. None when absent.
23
- * @param {Array<{slug: string, name: string, title: string, page: string|number}>} [opts.foreign] -
23
+ * @param {Array<{slug: string, name: string, title: string, abbreviation?: string,
24
+ * page: string|number}>} [opts.foreign] -
24
25
  * Notes another book prints, listed as `Name: Book title, p. N` once this
25
- * book mentions them.
26
+ * book mentions them, or `Name: Aelw. p12` when the home book has an
27
+ * abbreviation.
26
28
  * @returns {Array<{letter: string, name: string, slug: string, see?: string,
27
- * foreign?: {title: string, page: string}}>} Terms with `see` set for a
29
+ * foreign?: {title: string, abbreviation?: string, page: string}}>} Terms with `see` set for a
28
30
  * cross-reference to the main name it points at.
29
31
  */
30
32
  export function indexTerms(entries: object[], { owns, foreign, heads }?: {
@@ -34,6 +36,7 @@ export function indexTerms(entries: object[], { owns, foreign, heads }?: {
34
36
  slug: string;
35
37
  name: string;
36
38
  title: string;
39
+ abbreviation?: string;
37
40
  page: string | number;
38
41
  }[] | undefined;
39
42
  }): Array<{
@@ -43,6 +46,7 @@ export function indexTerms(entries: object[], { owns, foreign, heads }?: {
43
46
  see?: string;
44
47
  foreign?: {
45
48
  title: string;
49
+ abbreviation?: string;
46
50
  page: string;
47
51
  };
48
52
  }>;
@@ -57,7 +57,7 @@ export function readEntryPages(typPath: string, fontArgs: string[], binary?: str
57
57
  * The books that render, in configuration order.
58
58
  * @param {Map<string, string>} input.homes - Each note's key → its home book's
59
59
  * id, from {@link module:engine/pdf-coverage.coverage}.
60
- * @returns {Array<{key: string, book: string, title: string, anchor: string,
60
+ * @returns {Array<{key: string, book: string, title: string, abbreviation?: string, anchor: string,
61
61
  * record: object, slug: string, aliases: string[]}>} One per homed note, in book order and
62
62
  * then entry order.
63
63
  */
@@ -74,6 +74,7 @@ export function homeEntries({ books, homes }: {
74
74
  key: string;
75
75
  book: string;
76
76
  title: string;
77
+ abbreviation?: string;
77
78
  anchor: string;
78
79
  record: object;
79
80
  slug: string;
@@ -166,19 +166,30 @@ export function markdownToTypst(markdown: string, opts?: {
166
166
  export function absolutePageUrl(url: string, site?: string): string;
167
167
  /**
168
168
  * A reference to a note another book prints, as Typst: the words the link
169
- * carried, then the book and the page, `Name (_Book title_, p. 12)`.
169
+ * carried, then the book and the page. A book with an abbreviation reads
170
+ * `Name (Aelw. p12)`; one without reads `Name (_Book title_, p. 12)`.
170
171
  *
171
- * The space before the page number does not break, so a number never starts a
172
- * line of its own.
172
+ * A link the text already closes a bracket on, as in `(see Name)`, takes the
173
+ * reference inside that bracket after a comma, `(see Name, Aelw. p12)`, so the
174
+ * brackets never nest.
175
+ *
176
+ * The space before the page number, or between the abbreviation and the page,
177
+ * does not break, so a number never starts a line of its own.
173
178
  *
174
179
  * @param {string} inner - The already-rendered link text.
175
- * @param {{title: string, page: string|number}} away - The home book's title
176
- * and the page the entry starts on.
180
+ * @param {{title: string, abbreviation?: string, page: string|number}} away -
181
+ * The home book's title, its abbreviation when it declares one, and the page
182
+ * the entry starts on.
183
+ * @param {{bracketed?: boolean}} [opts] - `bracketed` when the text closes a
184
+ * bracket right after the link.
177
185
  * @returns {string} Typst markup.
178
186
  */
179
187
  export function crossBookReference(inner: string, away: {
180
188
  title: string;
189
+ abbreviation?: string;
181
190
  page: string | number;
191
+ }, { bracketed }?: {
192
+ bracketed?: boolean;
182
193
  }): string;
183
194
  /**
184
195
  * The Typst definitions the book's page furniture is drawn with.
@@ -72,7 +72,7 @@ export function runTreeFilters(nodes: object[], db: {
72
72
  * without a renderer or a PDF. Only how the result *looks* needs eyes.
73
73
  *
74
74
  * **Notes are sorted, prose is not.** A section's entries come out in
75
- * `nameAscii` order — the ASCII fold `buildIndexRecord` already derives, so a
75
+ * name-sort order (`name.sort`, else `name.full`, folded to ASCII), so a
76
76
  * circumflex sorts with its letter instead of after `Z` as a raw codepoint
77
77
  * comparison would put it. Prose keeps the position it was written in, because
78
78
  * its place in the sequence is the author's statement.
@@ -59,6 +59,11 @@ export type RelatedEntry = {
59
59
  * The note's `type`, which the theme groups by.
60
60
  */
61
61
  type: string;
62
+ /**
63
+ * The note's `name.sort`. It orders the list in place
64
+ * of `title` and is not published.
65
+ */
66
+ sort?: string | undefined;
62
67
  };
63
68
  /**
64
69
  * The `related` block one page publishes.
@@ -1,135 +0,0 @@
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
- * Asserts that npm serves an exact package version.
16
- *
17
- * `npm publish` returns before the registry serves the new version, and a
18
- * version that is accepted but never served is indistinguishable from success
19
- * by the publish alone. This polls `npm view <name>@<version> version` until
20
- * it prints the version, then exits 0. When the time budget runs out it exits 1
21
- * with an `::error::` annotation naming the package, the version and the last
22
- * registry answer.
23
- *
24
- * Usage: `node ci/confirm-npm-version.mjs <name> <version> [--interval <s>]
25
- * [--timeout <s>]`. The defaults are 15 seconds between queries and 600 seconds
26
- * overall.
27
- */
28
-
29
- import { spawnSync } from "node:child_process";
30
- import { fileURLToPath } from "node:url";
31
-
32
- /** Seconds between registry queries. */
33
- export const DEFAULT_INTERVAL_SECONDS = 15;
34
- /** Seconds the registry has to serve the version. */
35
- export const DEFAULT_TIMEOUT_SECONDS = 600;
36
-
37
- /**
38
- * Ask the registry which version `name@version` resolves to.
39
- *
40
- * @param {string} spec - `<name>@<version>`.
41
- * @returns {string} The version npm printed, or its error text when the query
42
- * fails (an unpublished version answers E404).
43
- */
44
- export function queryNpm(spec) {
45
- const result = spawnSync("npm", ["view", spec, "version"], { encoding: "utf8" });
46
- if (result.error) return result.error.message;
47
- const out = (result.stdout ?? "").trim();
48
- if (result.status === 0) return out;
49
- const err = (result.stderr ?? "").trim().replace(/\s*\n\s*/g, " | ");
50
- return err || out || `npm exited ${result.status}`;
51
- }
52
-
53
- /**
54
- * Poll until the registry serves `version` of `name`.
55
- *
56
- * @param {object} options
57
- * @param {string} options.name - Package name.
58
- * @param {string} options.version - Exact version expected.
59
- * @param {(spec: string) => string} [options.query] - Registry query; returns
60
- * the registry's answer for `<name>@<version>`.
61
- * @param {(ms: number) => Promise<void>} [options.sleep] - Waits between queries.
62
- * @param {number} [options.intervalSeconds] - Seconds between queries.
63
- * @param {number} [options.timeoutSeconds] - Seconds before giving up.
64
- * @param {(line: string) => void} [options.log] - Progress output.
65
- * @returns {Promise<{ok: true, attempts: number} | {ok: false, attempts: number, last: string}>}
66
- */
67
- export async function confirmNpmVersion({
68
- name,
69
- version,
70
- query = queryNpm,
71
- sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
72
- intervalSeconds = DEFAULT_INTERVAL_SECONDS,
73
- timeoutSeconds = DEFAULT_TIMEOUT_SECONDS,
74
- log = () => {},
75
- }) {
76
- const spec = `${name}@${version}`;
77
- const attemptLimit = Math.floor(timeoutSeconds / intervalSeconds) + 1;
78
- let last = "";
79
- for (let attempts = 1; attempts <= attemptLimit; attempts++) {
80
- last = query(spec);
81
- if (last === version) {
82
- log(`${spec} is served by npm.`);
83
- return { ok: true, attempts };
84
- }
85
- log(`${spec} is not served yet (${last || "empty answer"}).`);
86
- if (attempts < attemptLimit) await sleep(intervalSeconds * 1000);
87
- }
88
- return { ok: false, attempts: attemptLimit, last };
89
- }
90
-
91
- function parseArguments(argv) {
92
- const positional = [];
93
- const options = {};
94
- for (let i = 0; i < argv.length; i++) {
95
- const arg = argv[i];
96
- if (arg === "--interval" || arg === "--timeout") {
97
- const value = Number(argv[++i]);
98
- if (!Number.isFinite(value) || value <= 0) {
99
- throw new Error(`${arg} takes a positive number of seconds.`);
100
- }
101
- options[arg === "--interval" ? "intervalSeconds" : "timeoutSeconds"] = value;
102
- } else if (arg.startsWith("--")) {
103
- throw new Error(`Unknown option ${arg}.`);
104
- } else {
105
- positional.push(arg);
106
- }
107
- }
108
- if (positional.length !== 2) {
109
- throw new Error(
110
- "Usage: confirm-npm-version.mjs <name> <version> [--interval <s>] [--timeout <s>]",
111
- );
112
- }
113
- return { name: positional[0], version: positional[1], ...options };
114
- }
115
-
116
- async function main(argv) {
117
- let options;
118
- try {
119
- options = parseArguments(argv);
120
- } catch (error) {
121
- console.error(`::error::${error.message}`);
122
- return 2;
123
- }
124
- const outcome = await confirmNpmVersion({ ...options, log: (line) => console.log(line) });
125
- if (outcome.ok) return 0;
126
- const timeout = options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
127
- console.error(
128
- `::error::npm does not serve ${options.name}@${options.version} after ${timeout}s and ${outcome.attempts} queries. Last registry answer: ${outcome.last || "(empty)"}`,
129
- );
130
- return 1;
131
- }
132
-
133
- if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
134
- process.exitCode = await main(process.argv.slice(2));
135
- }