@heroiclands/package-build 23.12.4 → 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.
- package/CHANGELOG.md +18 -0
- package/docs/api.md +1 -0
- package/docs/authoring/frontmatter.md +1 -1
- package/docs/authoring/links-and-markup.md +4 -4
- package/docs/configuration.md +3 -1
- package/docs/guides/book.md +12 -12
- package/docs/reference/format-details.md +28 -16
- package/docs/reference/note-types.md +1 -1
- package/engine/content-index.mjs +2 -39
- package/engine/event-fields.mjs +24 -1
- package/engine/event-rows.mjs +37 -2
- package/engine/event-views.mjs +58 -14
- package/engine/frontmatter-lint.mjs +11 -0
- package/engine/generated-sections.mjs +8 -1
- package/engine/holdings.mjs +8 -2
- package/engine/infobox-render.mjs +16 -5
- package/engine/infobox.mjs +23 -2
- package/engine/literature-works.mjs +8 -2
- package/engine/metadata-index.mjs +3 -0
- package/engine/name-sort.mjs +91 -0
- package/engine/note-frontmatter.mjs +1 -1
- package/engine/note-vocabulary.mjs +1 -1
- package/engine/page-lists.mjs +3 -2
- package/engine/pdf-index.mjs +11 -4
- package/engine/pdf-pages.mjs +2 -1
- package/engine/pdf-render.mjs +43 -13
- package/engine/pdf-toc.mjs +4 -3
- package/engine/related-pages.mjs +12 -2
- package/engine/site-build.mjs +3 -0
- package/engine/sql-tables.mjs +20 -5
- package/package.json +1 -1
- package/types/engine/content-index.d.mts +2 -31
- package/types/engine/event-fields.d.mts +11 -0
- package/types/engine/event-rows.d.mts +4 -1
- package/types/engine/event-views.d.mts +7 -3
- package/types/engine/generated-sections.d.mts +6 -0
- package/types/engine/holdings.d.mts +7 -1
- package/types/engine/infobox.d.mts +11 -0
- package/types/engine/literature-works.d.mts +5 -0
- package/types/engine/name-sort.d.mts +49 -0
- package/types/engine/pdf-render.d.mts +8 -0
- package/types/engine/pdf-toc.d.mts +1 -1
- package/types/engine/related-pages.d.mts +5 -0
|
@@ -41,6 +41,13 @@
|
|
|
41
41
|
* @module
|
|
42
42
|
*/
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* The anchor of the Chronology section, which the book sets across the page.
|
|
46
|
+
*
|
|
47
|
+
* @type {string}
|
|
48
|
+
*/
|
|
49
|
+
export const CHRONOLOGY_SLUG = "chronology";
|
|
50
|
+
|
|
44
51
|
/**
|
|
45
52
|
* Every generated section, in the order it follows the author's text: where a
|
|
46
53
|
* place sits and who governs it, then what happened, then what was written
|
|
@@ -53,7 +60,7 @@ export const GENERATED_SECTIONS = Object.freeze(
|
|
|
53
60
|
{ slug: "within", heading: "Within" },
|
|
54
61
|
{ slug: "governedby", heading: "Governed by" },
|
|
55
62
|
{ slug: "governedplaces", heading: "Governed places" },
|
|
56
|
-
{ slug:
|
|
63
|
+
{ slug: CHRONOLOGY_SLUG, heading: "Chronology" },
|
|
57
64
|
{ slug: "events", heading: "Events" },
|
|
58
65
|
{ slug: "accounts", heading: "Accounts" },
|
|
59
66
|
{ slug: "followed", heading: "What followed" },
|
package/engine/holdings.mjs
CHANGED
|
@@ -24,13 +24,14 @@ import { isAddressTuple, ownDocumentSystem, parseAddress, renderAddress } from "
|
|
|
24
24
|
import { positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
25
25
|
import { readCanonicalKey } from "./content-address.mjs";
|
|
26
26
|
|
|
27
|
-
/** @typedef {{title:string,url?:string,address:string,type:string,subType?:string}} HoldingsEntry */
|
|
27
|
+
/** @typedef {{title:string,sort?:string,url?:string,address:string,type:string,subType?:string}} HoldingsEntry */
|
|
28
28
|
/**
|
|
29
29
|
* @typedef {object} HoldingsNode
|
|
30
30
|
* @property {string} shortcode
|
|
31
31
|
* @property {"place"|"affiliation"} type
|
|
32
32
|
* @property {string} [subType]
|
|
33
33
|
* @property {string} title
|
|
34
|
+
* @property {string} [sort] The note's `name.sort`; the list files by it in place of `title`.
|
|
34
35
|
* @property {string} [url]
|
|
35
36
|
* @property {string} [package] Owning content package.
|
|
36
37
|
* @property {string} [system] Own document system.
|
|
@@ -108,6 +109,7 @@ function listed(node) {
|
|
|
108
109
|
function entryOf(node) {
|
|
109
110
|
return {
|
|
110
111
|
title: node.title,
|
|
112
|
+
...(node.sort ? { sort: node.sort } : {}),
|
|
111
113
|
...(listed(node) ? { url: node.url } : {}),
|
|
112
114
|
address: ownKey(node),
|
|
113
115
|
type: node.type,
|
|
@@ -118,7 +120,7 @@ function entryOf(node) {
|
|
|
118
120
|
function bySubTypeThenTitle(a, b) {
|
|
119
121
|
return (
|
|
120
122
|
(a.subType ?? "").localeCompare(b.subType ?? "", "en") ||
|
|
121
|
-
a.title.localeCompare(b.title, "en")
|
|
123
|
+
(a.sort ?? a.title).localeCompare(b.sort ?? b.title, "en")
|
|
122
124
|
);
|
|
123
125
|
}
|
|
124
126
|
|
|
@@ -141,6 +143,7 @@ export function holdingsNode(
|
|
|
141
143
|
type,
|
|
142
144
|
subType: fm.subType == null ? undefined : String(fm.subType),
|
|
143
145
|
title,
|
|
146
|
+
...(typeof fm.name?.sort === "string" && fm.name.sort ? { sort: fm.name.sort } : {}),
|
|
144
147
|
url,
|
|
145
148
|
...(pkg ? { package: pkg } : {}),
|
|
146
149
|
...(system ? { system } : {}),
|
|
@@ -173,6 +176,9 @@ export function foreignHoldingsNodes(foreignIndex) {
|
|
|
173
176
|
type,
|
|
174
177
|
subType: entry.subType == null ? undefined : String(entry.subType),
|
|
175
178
|
title: String(entry.name ?? tuple.shortcode),
|
|
179
|
+
...(typeof entry.nameSort === "string" && entry.nameSort ?
|
|
180
|
+
{ sort: entry.nameSort }
|
|
181
|
+
: {}),
|
|
176
182
|
url: entry.url,
|
|
177
183
|
parents:
|
|
178
184
|
type === "place" ?
|
|
@@ -296,9 +296,14 @@ function entryToTypst(entry, link) {
|
|
|
296
296
|
}
|
|
297
297
|
|
|
298
298
|
/**
|
|
299
|
-
* One section as Typst
|
|
299
|
+
* One section as Typst.
|
|
300
300
|
*
|
|
301
|
-
*
|
|
301
|
+
* A stat grid is whole and unbreakable. A section of rows or run-in lists may
|
|
302
|
+
* break between its rows, so a section taller than the space left on a page
|
|
303
|
+
* continues on the next one instead of running past the page's foot; each row
|
|
304
|
+
* stays whole, and the section's label and `lead` stay with its first row.
|
|
305
|
+
*
|
|
306
|
+
* `lead` rides **inside** the section rather than above it. A panel breaks
|
|
302
307
|
* between its sections, and a title sitting outside the first one is a break
|
|
303
308
|
* the panel is allowed to take — which leaves the heading and a rule stranded
|
|
304
309
|
* at the foot of a page with nothing under them.
|
|
@@ -309,8 +314,9 @@ function entryToTypst(entry, link) {
|
|
|
309
314
|
* @returns {string} Typst markup.
|
|
310
315
|
*/
|
|
311
316
|
function sectionToTypst(section, link, lead = "") {
|
|
312
|
-
const
|
|
313
|
-
if (section.label)
|
|
317
|
+
const head = lead ? [lead] : [];
|
|
318
|
+
if (section.label) head.push(`#infobox-head[${escapeTypst(section.label)}]`);
|
|
319
|
+
const body = [];
|
|
314
320
|
|
|
315
321
|
if (section.layout === "rows") {
|
|
316
322
|
for (const row of section.rows ?? []) {
|
|
@@ -346,7 +352,12 @@ function sectionToTypst(section, link, lead = "") {
|
|
|
346
352
|
}
|
|
347
353
|
}
|
|
348
354
|
|
|
349
|
-
|
|
355
|
+
if (section.layout === "grid" || body.length < 2)
|
|
356
|
+
return `#block(breakable: false)[\n${[...head, ...body].join("\n")}\n]`;
|
|
357
|
+
const [first, ...rest] = body;
|
|
358
|
+
const kept = `#block(breakable: false)[\n${[...head, first].join("\n")}\n]`;
|
|
359
|
+
const rows = rest.map((row) => `#block(breakable: false)[${row}]`);
|
|
360
|
+
return `#block(breakable: true)[\n${[kept, ...rows].join("\n")}\n]`;
|
|
350
361
|
}
|
|
351
362
|
|
|
352
363
|
/**
|
package/engine/infobox.mjs
CHANGED
|
@@ -593,6 +593,27 @@ export function valueKindOf(field, value) {
|
|
|
593
593
|
return Array.isArray(value) ? "list" : "text";
|
|
594
594
|
}
|
|
595
595
|
|
|
596
|
+
/**
|
|
597
|
+
* A number as a reader reads it in a profile: under a million in full with
|
|
598
|
+
* thousands separators (`850`, `12,500`), and from a million up in words with
|
|
599
|
+
* at most one decimal (`11 million`, `1.2 million`, `3 billion`). Every figure
|
|
600
|
+
* the setting gives at that scale is an estimate, and the words say so.
|
|
601
|
+
* Decimals below a million keep the digits they were written with.
|
|
602
|
+
*
|
|
603
|
+
* @param {number} value - The number.
|
|
604
|
+
* @returns {string} The text the row shows.
|
|
605
|
+
*/
|
|
606
|
+
export function formatQuantity(value) {
|
|
607
|
+
const n = Number(value);
|
|
608
|
+
if (!Number.isFinite(n)) return String(value);
|
|
609
|
+
const size = Math.abs(n);
|
|
610
|
+
if (size < 1e6) return n.toLocaleString("en-US", { maximumFractionDigits: 20 });
|
|
611
|
+
// A figure that rounds up to a thousand million is a billion.
|
|
612
|
+
const [scale, word] = size < 999.95e6 ? [1e6, "million"] : [1e9, "billion"];
|
|
613
|
+
const scaled = Math.round((n / scale) * 10) / 10;
|
|
614
|
+
return `${scaled.toLocaleString("en-US", { maximumFractionDigits: 1 })} ${word}`;
|
|
615
|
+
}
|
|
616
|
+
|
|
596
617
|
/**
|
|
597
618
|
* Build one row's value, resolving whatever the kind says is a reference.
|
|
598
619
|
*
|
|
@@ -616,7 +637,7 @@ function rowValue(kind, raw, resolve, hint, verbatim = false) {
|
|
|
616
637
|
if (kind === "list") {
|
|
617
638
|
return (Array.isArray(raw) ? raw : [raw]).filter(hasValue).map((v) => shown(v));
|
|
618
639
|
}
|
|
619
|
-
if (kind === "number") return raw;
|
|
640
|
+
if (kind === "number") return formatQuantity(raw);
|
|
620
641
|
return shown(raw);
|
|
621
642
|
}
|
|
622
643
|
|
|
@@ -904,7 +925,7 @@ function declaredKeyRows(fields, raw) {
|
|
|
904
925
|
(inner.kind === "number" || inner.kind === "integer") &&
|
|
905
926
|
Number.isFinite(Number(value))
|
|
906
927
|
) {
|
|
907
|
-
rows.push({ label, kind: "number", value: Number(value) });
|
|
928
|
+
rows.push({ label, kind: "number", value: formatQuantity(Number(value)) });
|
|
908
929
|
} else {
|
|
909
930
|
rows.push({ label, kind: "text", value: shown(value) });
|
|
910
931
|
}
|
|
@@ -31,11 +31,12 @@ import { AddressLink } from "./address-values.mjs";
|
|
|
31
31
|
import { readCanonicalKey } from "./content-address.mjs";
|
|
32
32
|
import { LITERATURE_SUBTYPE } from "./literature-notes.mjs";
|
|
33
33
|
|
|
34
|
-
/** @typedef {{title: string, url?: string, address: string, form?: string}} WorkEntry */
|
|
34
|
+
/** @typedef {{title: string, sort?: string, url?: string, address: string, form?: string}} WorkEntry */
|
|
35
35
|
/**
|
|
36
36
|
* @typedef {object} WorksNode
|
|
37
37
|
* @property {string} key Own Address, normalized to the `note` system.
|
|
38
38
|
* @property {string} title
|
|
39
|
+
* @property {string} [sort] The note's `name.sort`; the list files by it in place of `title`.
|
|
39
40
|
* @property {string} [url] Absent for a note that publishes no page.
|
|
40
41
|
* @property {string} [package] Owning content package.
|
|
41
42
|
* @property {string} [form] A work's `data.form`.
|
|
@@ -79,6 +80,7 @@ export function worksNode(fm, { title, url, package: pkg }) {
|
|
|
79
80
|
return {
|
|
80
81
|
key: renderAddress({ package: pkg ?? "local", system: "note", type, shortcode }),
|
|
81
82
|
title,
|
|
83
|
+
...(typeof fm.name?.sort === "string" && fm.name.sort ? { sort: fm.name.sort } : {}),
|
|
82
84
|
...(url ? { url } : {}),
|
|
83
85
|
...(pkg ? { package: pkg } : {}),
|
|
84
86
|
...(work && typeof data.form === "string" && data.form ? { form: data.form } : {}),
|
|
@@ -107,6 +109,9 @@ export function foreignWorksNodes(foreignIndex) {
|
|
|
107
109
|
out.push({
|
|
108
110
|
key: renderAddress({ ...tuple, system: "note" }),
|
|
109
111
|
title: String(entry.name ?? tuple.shortcode),
|
|
112
|
+
...(typeof entry.nameSort === "string" && entry.nameSort ?
|
|
113
|
+
{ sort: entry.nameSort }
|
|
114
|
+
: {}),
|
|
110
115
|
...(entry.url ? { url: entry.url } : {}),
|
|
111
116
|
package: tuple.package,
|
|
112
117
|
...(typeof entry.form === "string" && entry.form ? { form: entry.form } : {}),
|
|
@@ -149,6 +154,7 @@ export function worksPages(nodes, { types }) {
|
|
|
149
154
|
if (!block) lists.set(target.url, (block = { works: [] }));
|
|
150
155
|
block.works.push({
|
|
151
156
|
title: work.title,
|
|
157
|
+
...(work.sort ? { sort: work.sort } : {}),
|
|
152
158
|
...(work.url ? { url: work.url } : {}),
|
|
153
159
|
address: work.key,
|
|
154
160
|
...(work.form ? { form: work.form } : {}),
|
|
@@ -156,6 +162,6 @@ export function worksPages(nodes, { types }) {
|
|
|
156
162
|
}
|
|
157
163
|
}
|
|
158
164
|
for (const block of lists.values())
|
|
159
|
-
block.works.sort((a, b) => a.title.localeCompare(b.title, "en"));
|
|
165
|
+
block.works.sort((a, b) => (a.sort ?? a.title).localeCompare(b.sort ?? b.title, "en"));
|
|
160
166
|
return lists;
|
|
161
167
|
}
|
|
@@ -445,6 +445,9 @@ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE)
|
|
|
445
445
|
const foundry = record.foundry?.[parts.system];
|
|
446
446
|
index.set(key, {
|
|
447
447
|
name: record.name?.full ?? record.name,
|
|
448
|
+
...(typeof record.name?.sort === "string" && record.name.sort ?
|
|
449
|
+
{ nameSort: record.name.sort }
|
|
450
|
+
: {}),
|
|
448
451
|
aliases: record.name?.aliases,
|
|
449
452
|
// Absent where the package publishes no page for it. A consumer
|
|
450
453
|
// must tolerate that rather than invent an href, exactly as it
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
* The key a name-ordered list files a note under, and the ASCII fold ordering
|
|
16
|
+
* compares it on. A leaf module, so every list that orders notes by name can
|
|
17
|
+
* read the key without importing the content index.
|
|
18
|
+
*
|
|
19
|
+
* @module
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import unidecode from "unidecode";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A note's display name reduced to printable 7-bit ASCII.
|
|
26
|
+
*
|
|
27
|
+
* Content names carry the setting's orthography — `Kûrbúl Helm`, `Hârn`,
|
|
28
|
+
* `Kèthîra` — and nobody types them. A reader searching the index, or an editor
|
|
29
|
+
* completing a wikilink, needs a form that matches what a keyboard produces, so
|
|
30
|
+
* the record states one rather than leaving every consumer to invent it (and to
|
|
31
|
+
* invent a *different* one, which is how two searches over the same data come
|
|
32
|
+
* to disagree).
|
|
33
|
+
*
|
|
34
|
+
* **Transliterated, not stripped.** `unidecode` — the same table
|
|
35
|
+
* {@link slugify} already runs, so an ASCII name and a slug can never disagree
|
|
36
|
+
* about a character — carries a letter across rather than deleting it:
|
|
37
|
+
* diacritics fold (`â`→`a`, `è`→`e`), ligatures expand (`æ`→`ae`, `Œ`→`OE`,
|
|
38
|
+
* `ß`→`ss`), the runic letters spell out (`þ`→`th`, `Þ`→`Th`, `ð`→`d`), and
|
|
39
|
+
* even a vulgar fraction becomes readable (`¾`→`3/4`). Deleting them instead
|
|
40
|
+
* would collapse `Kûrbúl` to `Krbl`, which is worse than the original.
|
|
41
|
+
*
|
|
42
|
+
* Anything still outside printable ASCII after that becomes a space, and runs
|
|
43
|
+
* of whitespace collapse — a space rather than nothing, so a character that
|
|
44
|
+
* transliterates away cannot silently weld two words together.
|
|
45
|
+
*
|
|
46
|
+
* The value is emitted even when it equals the name, so a consumer matching on
|
|
47
|
+
* it never has to branch on whether the name happened to be ASCII already.
|
|
48
|
+
*
|
|
49
|
+
* @param {unknown} name - The note's `name.full`.
|
|
50
|
+
* @returns {string|null} The ASCII form, or `null` when there is no name, or
|
|
51
|
+
* nothing printable survives.
|
|
52
|
+
*/
|
|
53
|
+
export function asciiName(name) {
|
|
54
|
+
if (typeof name !== "string") return null;
|
|
55
|
+
const folded = unidecode(name)
|
|
56
|
+
.replace(/[^\x20-\x7E]/g, " ")
|
|
57
|
+
.replace(/\s+/g, " ")
|
|
58
|
+
.trim();
|
|
59
|
+
return folded === "" ? null : folded;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The name a note files under: its `name.sort` when it declares one, otherwise
|
|
64
|
+
* its `name.full`.
|
|
65
|
+
*
|
|
66
|
+
* @param {unknown} record - An index record or a note's frontmatter.
|
|
67
|
+
* @returns {string|undefined} The unfolded key, or `undefined` for a record
|
|
68
|
+
* with no name.
|
|
69
|
+
*/
|
|
70
|
+
export function nameSortOf(record) {
|
|
71
|
+
const name = /** @type {any} */ (record)?.name;
|
|
72
|
+
for (const candidate of [name?.sort, name?.full]) {
|
|
73
|
+
if (typeof candidate === "string" && candidate.trim()) return candidate;
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The key a name-ordered list compares notes on: {@link nameSortOf} folded to
|
|
80
|
+
* printable ASCII by {@link asciiName}, so an accented sort key files beside
|
|
81
|
+
* its base letters. Case is left for the caller, which decides whether its
|
|
82
|
+
* comparison ignores it.
|
|
83
|
+
*
|
|
84
|
+
* @param {unknown} record - An index record or a note's frontmatter.
|
|
85
|
+
* @returns {string} The key, or "" for a record with no name.
|
|
86
|
+
*/
|
|
87
|
+
export function nameSortKey(record) {
|
|
88
|
+
const key = nameSortOf(record);
|
|
89
|
+
if (key === undefined) return "";
|
|
90
|
+
return asciiName(key) ?? key;
|
|
91
|
+
}
|
|
@@ -22,7 +22,7 @@ export {
|
|
|
22
22
|
} from "./note-vocabulary.mjs";
|
|
23
23
|
|
|
24
24
|
/** The name properties every note accepts, in display order. */
|
|
25
|
-
export const NOTE_NAME_KEYS = Object.freeze(["full", "aliases"]);
|
|
25
|
+
export const NOTE_NAME_KEYS = Object.freeze(["full", "aliases", "sort"]);
|
|
26
26
|
|
|
27
27
|
/** Additional name components accepted by an individual character. */
|
|
28
28
|
export const CHARACTER_NAME_KEYS = Object.freeze(["given", "clan"]);
|
|
@@ -939,7 +939,7 @@ export const NOTE_TOP_LEVEL_FIELDS = Object.freeze([
|
|
|
939
939
|
}),
|
|
940
940
|
Object.freeze({
|
|
941
941
|
name: "name",
|
|
942
|
-
describe: "The display names — a required `full`, and any `aliases`.",
|
|
942
|
+
describe: "The display names — a required `full`, and any `aliases` and `sort`.",
|
|
943
943
|
}),
|
|
944
944
|
Object.freeze({
|
|
945
945
|
name: "type",
|
package/engine/page-lists.mjs
CHANGED
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
* @module
|
|
42
42
|
*/
|
|
43
43
|
|
|
44
|
+
import { nameSortKey } from "./name-sort.mjs";
|
|
44
45
|
import { FENCE_LINE, parseHeaderArgs } from "./code-fences.mjs";
|
|
45
46
|
import { booleanAttribute } from "./extension-attributes.mjs";
|
|
46
47
|
import { isNoteRecord } from "./index-records.mjs";
|
|
@@ -195,8 +196,8 @@ export function findPageListBlocks(markdown) {
|
|
|
195
196
|
return blocks;
|
|
196
197
|
}
|
|
197
198
|
|
|
198
|
-
/** A page's sort key
|
|
199
|
-
const nameKey = (record) =>
|
|
199
|
+
/** A page's sort key: its `name.sort` or `name.full`, folded so a circumflex sorts with its letter. */
|
|
200
|
+
const nameKey = (record) => nameSortKey(record).toLowerCase();
|
|
200
201
|
|
|
201
202
|
/** The address a page is addressed by, as a stable tiebreak. */
|
|
202
203
|
const slugKey = (record) => String(record?.address?.slug ?? "");
|
package/engine/pdf-index.mjs
CHANGED
|
@@ -33,12 +33,15 @@
|
|
|
33
33
|
*
|
|
34
34
|
* Terms sort on the ASCII fold the content index derives for names
|
|
35
35
|
* ({@link module:engine/content-index.asciiName}), ignoring case, so an
|
|
36
|
-
* accented name files under its base letter.
|
|
36
|
+
* accented name files under its base letter. A note's own entry is its
|
|
37
|
+
* `name.sort` when it declares one (`Black Flame, The`, `Hilvan, Doran`) and
|
|
38
|
+
* its `name.full` otherwise; it prints, files and takes its letter heading
|
|
39
|
+
* from that one form.
|
|
37
40
|
*
|
|
38
41
|
* @module
|
|
39
42
|
*/
|
|
40
43
|
|
|
41
|
-
import { asciiName } from "./
|
|
44
|
+
import { asciiName, nameSortOf } from "./name-sort.mjs";
|
|
42
45
|
|
|
43
46
|
/**
|
|
44
47
|
* The marker an entry heading or an authored mention carries.
|
|
@@ -125,8 +128,9 @@ export function indexTerms(entries, { owns = () => true, foreign = [], heads = [
|
|
|
125
128
|
for (const entry of entries ?? []) {
|
|
126
129
|
if (entry?.kind !== "note" || !owns(entry)) continue;
|
|
127
130
|
const slug = slugOf(entry.record);
|
|
128
|
-
const name = String(entry.record
|
|
131
|
+
const name = String(nameSortOf(entry.record) ?? "").trim();
|
|
129
132
|
if (!slug || !name || info.has(slug)) continue;
|
|
133
|
+
const declared = Boolean(String(entry.record?.name?.sort ?? "").trim());
|
|
130
134
|
const parents = Array.isArray(entry.record?.data?.parents) ? entry.record.data.parents : [];
|
|
131
135
|
const parent = parents
|
|
132
136
|
.map((one) =>
|
|
@@ -139,7 +143,10 @@ export function indexTerms(entries, { owns = () => true, foreign = [], heads = [
|
|
|
139
143
|
});
|
|
140
144
|
terms.push({ name, slug, main: true });
|
|
141
145
|
const taken = new Set([sortKey(name)]);
|
|
142
|
-
const head =
|
|
146
|
+
const head =
|
|
147
|
+
declared ? undefined : (
|
|
148
|
+
byLength.find((one) => name.startsWith(one) && name.slice(one.length).trim())
|
|
149
|
+
);
|
|
143
150
|
if (head) {
|
|
144
151
|
const inverted = `${name.slice(head.length).trim()}, ${head.replace(/[\s:]+$/, "")}`;
|
|
145
152
|
taken.add(sortKey(inverted));
|
package/engine/pdf-pages.mjs
CHANGED
|
@@ -39,6 +39,7 @@ import { spawnSync } from "node:child_process";
|
|
|
39
39
|
import { encodeAddresses } from "./address-values.mjs";
|
|
40
40
|
import { recordKey } from "./pdf-coverage.mjs";
|
|
41
41
|
import { labelFor } from "./pdf-render.mjs";
|
|
42
|
+
import { nameSortOf } from "./name-sort.mjs";
|
|
42
43
|
|
|
43
44
|
/** What a reference prints for a page that has not been measured. */
|
|
44
45
|
export const UNMEASURED_PAGE = "?";
|
|
@@ -259,7 +260,7 @@ export function elsewhereFor({ entries, book, printed, table, seeAlso = false })
|
|
|
259
260
|
if (seeAlso && entry.slug && !printed.has(entry.slug)) {
|
|
260
261
|
listed.push({
|
|
261
262
|
slug: entry.slug,
|
|
262
|
-
name: String(entry.record
|
|
263
|
+
name: String(nameSortOf(entry.record) ?? "").trim(),
|
|
263
264
|
title: entry.title,
|
|
264
265
|
abbreviation: entry.abbreviation,
|
|
265
266
|
page,
|
package/engine/pdf-render.mjs
CHANGED
|
@@ -75,6 +75,7 @@ import {
|
|
|
75
75
|
imagePlugin,
|
|
76
76
|
} from "./content-images.mjs";
|
|
77
77
|
import { bookImageWidthIn } from "./pdf-images.mjs";
|
|
78
|
+
import { CHRONOLOGY_SLUG } from "./generated-sections.mjs";
|
|
78
79
|
|
|
79
80
|
/** Requested print width for every named image size. */
|
|
80
81
|
export const BOOK_IMAGE_WIDTHS = Object.freeze({
|
|
@@ -738,6 +739,8 @@ function renderBlock(tokens, i, out, ctx) {
|
|
|
738
739
|
// the same words later in the same entry.
|
|
739
740
|
const base = anchor || slugify(plainHeadingText(inline)) || "heading";
|
|
740
741
|
const unique = anchorFor(base, ctx.seen);
|
|
742
|
+
// The section a table falls in decides whether it spans the page.
|
|
743
|
+
ctx.section = base;
|
|
741
744
|
const label = ` <${sectionLabel(ctx.anchorPrefix, unique)}>`;
|
|
742
745
|
// A body heading is never printed and never bookmarked — it is
|
|
743
746
|
// structure a reader reaches only by following a link, not a
|
|
@@ -1166,8 +1169,14 @@ function renderTable(tokens, ctx) {
|
|
|
1166
1169
|
// Wide content is given an explicit span rather than left to overflow the
|
|
1167
1170
|
// measure: past three columns a table is set across the page, and
|
|
1168
1171
|
// `book-wide` decides between a float and pages of its own by measuring it.
|
|
1169
|
-
|
|
1170
|
-
|
|
1172
|
+
// A table can ask for the span, whatever its columns: its caption takes
|
|
1173
|
+
// `.full-width`, and a Chronology section is always set that way. A wide
|
|
1174
|
+
// table floats to where it fits and the text runs on past it, so a heading
|
|
1175
|
+
// is never left alone above an empty page; its heading, caption and any
|
|
1176
|
+
// reference to it are what lead a reader there.
|
|
1177
|
+
const asked =
|
|
1178
|
+
Boolean(caption?.classes?.includes("full-width")) || ctx.section === CHRONOLOGY_SLUG;
|
|
1179
|
+
if (asked || columns > WIDE_TABLE_COLUMNS) return `\n#book-wide[\n${labelled}\n]\n\n`;
|
|
1171
1180
|
return `\n${labelled}\n\n`;
|
|
1172
1181
|
}
|
|
1173
1182
|
|
|
@@ -1295,7 +1304,11 @@ function renderInline(token, ctx) {
|
|
|
1295
1304
|
case "link_open": {
|
|
1296
1305
|
const close = childMatching(children, i, "link_open", "link_close");
|
|
1297
1306
|
const inner = renderInline({ children: children.slice(i + 1, close) }, ctx);
|
|
1298
|
-
|
|
1307
|
+
// A link the text closes a bracket on takes its reference
|
|
1308
|
+
// inside that bracket.
|
|
1309
|
+
const after = children[close + 1];
|
|
1310
|
+
const bracketed = after?.type === "text" && after.content.startsWith(")");
|
|
1311
|
+
out.push(renderLink(child.attrGet?.("href") ?? "", inner, ctx, { bracketed }));
|
|
1299
1312
|
i = close;
|
|
1300
1313
|
break;
|
|
1301
1314
|
}
|
|
@@ -1411,9 +1424,11 @@ function pageOfLabel(label) {
|
|
|
1411
1424
|
* @param {string} href - The resolved URL.
|
|
1412
1425
|
* @param {string} inner - The already-rendered link text.
|
|
1413
1426
|
* @param {object} ctx - Render context.
|
|
1427
|
+
* @param {{bracketed?: boolean}} [opts] - `bracketed` when the text closes a
|
|
1428
|
+
* bracket right after the link; see {@link crossBookReference}.
|
|
1414
1429
|
* @returns {string} Typst markup.
|
|
1415
1430
|
*/
|
|
1416
|
-
function renderLink(href, inner, ctx) {
|
|
1431
|
+
function renderLink(href, inner, ctx, { bracketed = false } = {}) {
|
|
1417
1432
|
const url = String(href ?? "");
|
|
1418
1433
|
const fragment = /#([^?]*)/.exec(url)?.[1] ?? "";
|
|
1419
1434
|
if (url.startsWith("#") && fragment)
|
|
@@ -1441,7 +1456,7 @@ function renderLink(href, inner, ctx) {
|
|
|
1441
1456
|
// A mention in authored prose is what lists the note, under
|
|
1442
1457
|
// `pdf.indexSeeAlso`.
|
|
1443
1458
|
const marker = ctx.indexMentions && ctx.elsewhere.seeAlso ? indexMarker(away.slug) : "";
|
|
1444
|
-
return `${crossBookReference(inner, away)}${marker}`;
|
|
1459
|
+
return `${crossBookReference(inner, away, { bracketed })}${marker}`;
|
|
1445
1460
|
}
|
|
1446
1461
|
if (!url || ctx.generated) return inner;
|
|
1447
1462
|
return `#link("${escapeTypstString(absolutePageUrl(url, ctx.url))}")[${inner}]`;
|
|
@@ -1452,6 +1467,10 @@ function renderLink(href, inner, ctx) {
|
|
|
1452
1467
|
* carried, then the book and the page. A book with an abbreviation reads
|
|
1453
1468
|
* `Name (Aelw. p12)`; one without reads `Name (_Book title_, p. 12)`.
|
|
1454
1469
|
*
|
|
1470
|
+
* A link the text already closes a bracket on, as in `(see Name)`, takes the
|
|
1471
|
+
* reference inside that bracket after a comma, `(see Name, Aelw. p12)`, so the
|
|
1472
|
+
* brackets never nest.
|
|
1473
|
+
*
|
|
1455
1474
|
* The space before the page number, or between the abbreviation and the page,
|
|
1456
1475
|
* does not break, so a number never starts a line of its own.
|
|
1457
1476
|
*
|
|
@@ -1459,13 +1478,16 @@ function renderLink(href, inner, ctx) {
|
|
|
1459
1478
|
* @param {{title: string, abbreviation?: string, page: string|number}} away -
|
|
1460
1479
|
* The home book's title, its abbreviation when it declares one, and the page
|
|
1461
1480
|
* the entry starts on.
|
|
1481
|
+
* @param {{bracketed?: boolean}} [opts] - `bracketed` when the text closes a
|
|
1482
|
+
* bracket right after the link.
|
|
1462
1483
|
* @returns {string} Typst markup.
|
|
1463
1484
|
*/
|
|
1464
|
-
export function crossBookReference(inner, away) {
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1485
|
+
export function crossBookReference(inner, away, { bracketed = false } = {}) {
|
|
1486
|
+
const where =
|
|
1487
|
+
away.abbreviation ?
|
|
1488
|
+
`${escapeTypst(away.abbreviation)}~p${away.page}`
|
|
1489
|
+
: `#emph[${escapeTypst(away.title)}], p.~${away.page}`;
|
|
1490
|
+
return bracketed ? `${inner}, ${where}` : `${inner} (${where})`;
|
|
1469
1491
|
}
|
|
1470
1492
|
|
|
1471
1493
|
/**
|
|
@@ -1702,10 +1724,12 @@ export function bookTypstPreamble() {
|
|
|
1702
1724
|
" ]\n" +
|
|
1703
1725
|
" ])\n" +
|
|
1704
1726
|
"]",
|
|
1705
|
-
"#let book-ornament = box(baseline:
|
|
1706
|
-
"rotate(45deg, rect(width:
|
|
1727
|
+
"#let book-ornament = box(baseline: 1.2pt, " +
|
|
1728
|
+
"rotate(45deg, rect(width: 3.6pt, height: 3.6pt, fill: book-accent)))",
|
|
1729
|
+
// The foot is set at the body's size, in the faint ink, so the page
|
|
1730
|
+
// number and the section name read without being taken for text.
|
|
1707
1731
|
"#let book-footer(name) = context {\n" +
|
|
1708
|
-
" set text(
|
|
1732
|
+
" set text(fill: book-faint)\n" +
|
|
1709
1733
|
" grid(columns: (1fr, auto, 1fr), align(left)[#name], align(center)[#book-ornament],\n" +
|
|
1710
1734
|
" align(right)[#counter(page).display()])\n" +
|
|
1711
1735
|
"}",
|
|
@@ -2030,6 +2054,12 @@ export function renderBook({
|
|
|
2030
2054
|
// becomes wider than `1.`.
|
|
2031
2055
|
out.push("#show list: set par(first-line-indent: 0em)");
|
|
2032
2056
|
out.push("#show enum: set par(first-line-indent: 0em)");
|
|
2057
|
+
// Items sit three-quarters of a line apart, so a list of several-line
|
|
2058
|
+
// items reads as separate entries. The face's own bullet is small at book
|
|
2059
|
+
// size, so it is enlarged and lifted to the middle of the first line; a
|
|
2060
|
+
// nested level takes a dash.
|
|
2061
|
+
out.push("#set list(spacing: 0.75em, marker: (text(size: 1.3em, baseline: -0.08em)[•], [–]))");
|
|
2062
|
+
out.push("#set enum(spacing: 0.75em)");
|
|
2033
2063
|
// The mono face is a separate claim from the book face: a fenced block is
|
|
2034
2064
|
// the one place the corpus is allowed box-drawing characters, and the
|
|
2035
2065
|
// serif that sets the prose is not the font that carries them.
|
package/engine/pdf-toc.mjs
CHANGED
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
* @module
|
|
50
50
|
*/
|
|
51
51
|
|
|
52
|
+
import { nameSortKey } from "./name-sort.mjs";
|
|
52
53
|
import { encodeAddresses } from "./address-values.mjs";
|
|
53
54
|
import { slugify } from "./content-slug.mjs";
|
|
54
55
|
import { positionOfYamlPath } from "./diagnostics.mjs";
|
|
@@ -416,7 +417,7 @@ export async function runTreeFilters(nodes, db, { keep = () => true, text } = {}
|
|
|
416
417
|
* without a renderer or a PDF. Only how the result *looks* needs eyes.
|
|
417
418
|
*
|
|
418
419
|
* **Notes are sorted, prose is not.** A section's entries come out in
|
|
419
|
-
*
|
|
420
|
+
* name-sort order (`name.sort`, else `name.full`, folded to ASCII), so a
|
|
420
421
|
* circumflex sorts with its letter instead of after `Z` as a raw codepoint
|
|
421
422
|
* comparison would put it. Prose keeps the position it was written in, because
|
|
422
423
|
* its place in the sequence is the author's statement.
|
|
@@ -566,8 +567,8 @@ function emitSection(node, nodes, ctx) {
|
|
|
566
567
|
*/
|
|
567
568
|
function sortRecords(records) {
|
|
568
569
|
return [...records].sort((a, b) => {
|
|
569
|
-
const an = a
|
|
570
|
-
const bn = b
|
|
570
|
+
const an = nameSortKey(a);
|
|
571
|
+
const bn = nameSortKey(b);
|
|
571
572
|
if (an !== bn) return an < bn ? -1 : 1;
|
|
572
573
|
const as = a?.address?.slug ?? "";
|
|
573
574
|
const bs = b?.address?.slug ?? "";
|
package/engine/related-pages.mjs
CHANGED
|
@@ -49,6 +49,8 @@ import { homepageAddresses } from "./homepage.mjs";
|
|
|
49
49
|
* site-root-relative form the page states for itself.
|
|
50
50
|
* @property {string} [gloss] Why the author recommends this page.
|
|
51
51
|
* @property {string} type The note's `type`, which the theme groups by.
|
|
52
|
+
* @property {string} [sort] The note's `name.sort`. It orders the list in place
|
|
53
|
+
* of `title` and is not published.
|
|
52
54
|
*/
|
|
53
55
|
|
|
54
56
|
/**
|
|
@@ -73,7 +75,10 @@ import { homepageAddresses } from "./homepage.mjs";
|
|
|
73
75
|
* @returns {number}
|
|
74
76
|
*/
|
|
75
77
|
function byTypeThenTitle(a, b) {
|
|
76
|
-
return
|
|
78
|
+
return (
|
|
79
|
+
a.type.localeCompare(b.type, "en") ||
|
|
80
|
+
(a.sort ?? a.title).localeCompare(b.sort ?? b.title, "en")
|
|
81
|
+
);
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
/**
|
|
@@ -118,7 +123,12 @@ export function relatedPages(edges, entries, deliberate = new Map()) {
|
|
|
118
123
|
const list = (urls) =>
|
|
119
124
|
[...urls]
|
|
120
125
|
.map((url) => /** @type {RelatedEntry} */ (entries.get(url)))
|
|
121
|
-
.sort(byTypeThenTitle)
|
|
126
|
+
.sort(byTypeThenTitle)
|
|
127
|
+
.map((entry) => {
|
|
128
|
+
if (entry.sort === undefined) return entry;
|
|
129
|
+
const { sort, ...published } = entry;
|
|
130
|
+
return published;
|
|
131
|
+
});
|
|
122
132
|
|
|
123
133
|
const out = new Map();
|
|
124
134
|
for (const [url, { backlinks, mentions }] of graph) {
|
package/engine/site-build.mjs
CHANGED
|
@@ -1069,6 +1069,9 @@ export function renderPages(pages, options) {
|
|
|
1069
1069
|
title: pageTitle(page),
|
|
1070
1070
|
url: page.url,
|
|
1071
1071
|
type: String(page.fm.type),
|
|
1072
|
+
...(typeof page.fm.name?.sort === "string" && page.fm.name.sort ?
|
|
1073
|
+
{ sort: page.fm.name.sort }
|
|
1074
|
+
: {}),
|
|
1072
1075
|
});
|
|
1073
1076
|
}
|
|
1074
1077
|
for (const home of homepages) {
|