@heroiclands/package-build 23.12.4 → 23.12.6
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 +28 -0
- package/content-config.mjs +26 -0
- package/docs/api.md +9 -5
- package/docs/authoring/dates-and-calendars.md +29 -1
- package/docs/authoring/frontmatter.md +1 -1
- package/docs/authoring/links-and-markup.md +7 -7
- package/docs/configuration.md +30 -4
- package/docs/guides/book.md +12 -12
- package/docs/guides/module-project.md +1 -1
- package/docs/reference/format-details.md +81 -24
- package/docs/reference/note-types.md +10 -11
- package/engine/content-format.yaml +0 -2
- package/engine/content-index.mjs +23 -41
- package/engine/content-links.mjs +3 -0
- package/engine/event-fields.mjs +24 -1
- package/engine/event-rows.mjs +82 -2
- package/engine/event-views.mjs +74 -17
- package/engine/frontmatter-lint.mjs +11 -0
- package/engine/generated-sections.mjs +8 -1
- package/engine/holdings.mjs +8 -2
- package/engine/index-records.mjs +1 -0
- package/engine/infobox-render.mjs +16 -5
- package/engine/infobox.mjs +27 -10
- package/engine/literature-works.mjs +8 -2
- package/engine/markdown-expressions.mjs +72 -17
- package/engine/metadata-index.mjs +3 -0
- package/engine/name-sort.mjs +91 -0
- package/engine/note-calendars.mjs +314 -0
- package/engine/note-frontmatter.mjs +1 -1
- package/engine/note-vocabulary.mjs +12 -17
- 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/reckoning-markers.mjs +63 -3
- package/engine/related-pages.mjs +12 -2
- package/engine/site-build.mjs +3 -0
- package/engine/sql-tables.mjs +28 -7
- package/package.json +1 -1
- package/types/content-config.d.mts +12 -0
- package/types/engine/content-index.d.mts +9 -32
- package/types/engine/event-fields.d.mts +11 -0
- package/types/engine/event-rows.d.mts +19 -1
- package/types/engine/event-views.d.mts +8 -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/note-calendars.d.mts +60 -0
- package/types/engine/pdf-render.d.mts +8 -0
- package/types/engine/pdf-toc.d.mts +1 -1
- package/types/engine/reckoning-markers.d.mts +32 -1
- package/types/engine/related-pages.d.mts +5 -0
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" ?
|
package/engine/index-records.mjs
CHANGED
|
@@ -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
|
@@ -128,6 +128,7 @@ import {
|
|
|
128
128
|
standingLead,
|
|
129
129
|
} from "./standings.mjs";
|
|
130
130
|
import { AddressEntries } from "./address-values.mjs";
|
|
131
|
+
import { noteCalendar } from "./note-calendars.mjs";
|
|
131
132
|
|
|
132
133
|
/**
|
|
133
134
|
* How a section arranges what it holds.
|
|
@@ -593,6 +594,27 @@ export function valueKindOf(field, value) {
|
|
|
593
594
|
return Array.isArray(value) ? "list" : "text";
|
|
594
595
|
}
|
|
595
596
|
|
|
597
|
+
/**
|
|
598
|
+
* A number as a reader reads it in a profile: under a million in full with
|
|
599
|
+
* thousands separators (`850`, `12,500`), and from a million up in words with
|
|
600
|
+
* at most one decimal (`11 million`, `1.2 million`, `3 billion`). Every figure
|
|
601
|
+
* the setting gives at that scale is an estimate, and the words say so.
|
|
602
|
+
* Decimals below a million keep the digits they were written with.
|
|
603
|
+
*
|
|
604
|
+
* @param {number} value - The number.
|
|
605
|
+
* @returns {string} The text the row shows.
|
|
606
|
+
*/
|
|
607
|
+
export function formatQuantity(value) {
|
|
608
|
+
const n = Number(value);
|
|
609
|
+
if (!Number.isFinite(n)) return String(value);
|
|
610
|
+
const size = Math.abs(n);
|
|
611
|
+
if (size < 1e6) return n.toLocaleString("en-US", { maximumFractionDigits: 20 });
|
|
612
|
+
// A figure that rounds up to a thousand million is a billion.
|
|
613
|
+
const [scale, word] = size < 999.95e6 ? [1e6, "million"] : [1e9, "billion"];
|
|
614
|
+
const scaled = Math.round((n / scale) * 10) / 10;
|
|
615
|
+
return `${scaled.toLocaleString("en-US", { maximumFractionDigits: 1 })} ${word}`;
|
|
616
|
+
}
|
|
617
|
+
|
|
596
618
|
/**
|
|
597
619
|
* Build one row's value, resolving whatever the kind says is a reference.
|
|
598
620
|
*
|
|
@@ -616,7 +638,7 @@ function rowValue(kind, raw, resolve, hint, verbatim = false) {
|
|
|
616
638
|
if (kind === "list") {
|
|
617
639
|
return (Array.isArray(raw) ? raw : [raw]).filter(hasValue).map((v) => shown(v));
|
|
618
640
|
}
|
|
619
|
-
if (kind === "number") return raw;
|
|
641
|
+
if (kind === "number") return formatQuantity(raw);
|
|
620
642
|
return shown(raw);
|
|
621
643
|
}
|
|
622
644
|
|
|
@@ -904,7 +926,7 @@ function declaredKeyRows(fields, raw) {
|
|
|
904
926
|
(inner.kind === "number" || inner.kind === "integer") &&
|
|
905
927
|
Number.isFinite(Number(value))
|
|
906
928
|
) {
|
|
907
|
-
rows.push({ label, kind: "number", value: Number(value) });
|
|
929
|
+
rows.push({ label, kind: "number", value: formatQuantity(Number(value)) });
|
|
908
930
|
} else {
|
|
909
931
|
rows.push({ label, kind: "text", value: shown(value) });
|
|
910
932
|
}
|
|
@@ -1045,15 +1067,10 @@ function noteBox(
|
|
|
1045
1067
|
: parsed?.qualifier ? dates.eras?.get(parsed.qualifier)
|
|
1046
1068
|
: null;
|
|
1047
1069
|
let printable = null;
|
|
1048
|
-
|
|
1070
|
+
const calendar = noteCalendar(fm, dates);
|
|
1071
|
+
if (parsed?.known && calendar) {
|
|
1049
1072
|
try {
|
|
1050
|
-
printable = formatDateInCalendar(
|
|
1051
|
-
parsed,
|
|
1052
|
-
isAddressTuple(data.calendar) ?
|
|
1053
|
-
renderAddress(data.calendar)
|
|
1054
|
-
: data.calendar,
|
|
1055
|
-
dates,
|
|
1056
|
-
);
|
|
1073
|
+
printable = formatDateInCalendar(parsed, calendar, dates);
|
|
1057
1074
|
} catch {
|
|
1058
1075
|
// The calendar reference is checked at the authored field.
|
|
1059
1076
|
}
|
|
@@ -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
|
}
|
|
@@ -15,11 +15,12 @@ import Handlebars from "handlebars";
|
|
|
15
15
|
|
|
16
16
|
import { isAddressTuple, renderAddress } from "./address.mjs";
|
|
17
17
|
import { matchAllOutsideCode, replaceOutsideCode } from "./code-fences.mjs";
|
|
18
|
-
import { formatDateInCalendar, parseNoteDate } from "./note-dates.mjs";
|
|
18
|
+
import { calendarEras, formatDateInCalendar, parseNoteDate } from "./note-dates.mjs";
|
|
19
19
|
import { numberWords, numberDigits } from "./number-words.mjs";
|
|
20
20
|
import { slugify } from "./content-slug.mjs";
|
|
21
21
|
import { WIKILINK, authoredLabel, isSamePage, parseWikilink } from "./wikilink-syntax.mjs";
|
|
22
|
-
import { EVENT_REFERENCE_FIELDS, eventFieldText } from "./event-fields.mjs";
|
|
22
|
+
import { EVENT_REFERENCE_FIELDS, eventFieldText, printEventDate } from "./event-fields.mjs";
|
|
23
|
+
import { noteCalendar } from "./note-calendars.mjs";
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* An inline expression, as an author writes one.
|
|
@@ -33,6 +34,48 @@ import { EVENT_REFERENCE_FIELDS, eventFieldText } from "./event-fields.mjs";
|
|
|
33
34
|
*/
|
|
34
35
|
export const EXPRESSION = /(?<![\\{])\{\{(?![{<%])([^{}\n]+)\}\}(?!\})/g;
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Which of `dateformat`'s arguments is which. Three are a calendar, a date and
|
|
39
|
+
* a format. Two are a calendar and a date, unless the first is absent — a
|
|
40
|
+
* `data.calendar` the note leaves unset is no calendar — or is a date that
|
|
41
|
+
* names no calendar, when they are a date and a format. One is a date.
|
|
42
|
+
*
|
|
43
|
+
* @param {unknown[]} given - The arguments, without Handlebars' options.
|
|
44
|
+
* @param {object} [dates] - The reckoning context.
|
|
45
|
+
* @returns {{calendar?: unknown, authored: unknown, format?: unknown}} The roles.
|
|
46
|
+
*/
|
|
47
|
+
function dateformatArguments(given, dates) {
|
|
48
|
+
if (given.length >= 3) {
|
|
49
|
+
const [calendar, authored, format] = given;
|
|
50
|
+
return { calendar: calendar ?? undefined, authored, format };
|
|
51
|
+
}
|
|
52
|
+
if (given.length < 2) return { authored: given[0] };
|
|
53
|
+
const [first, second] = given;
|
|
54
|
+
if (first === undefined || first === null) return { authored: second };
|
|
55
|
+
if (typeof first === "number") return { authored: first, format: second };
|
|
56
|
+
if (typeof first === "string" && !namesCalendar(first, dates)) {
|
|
57
|
+
const parsed = parseNoteDate(first, { ...dates, allowUnknown: false });
|
|
58
|
+
if (parsed.date && !parsed.findings.some((item) => item.severity === "error"))
|
|
59
|
+
return { authored: first, format: second };
|
|
60
|
+
}
|
|
61
|
+
return { calendar: first, authored: second };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether a string names a calendar or one of its eras.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} value - The string.
|
|
68
|
+
* @param {object} [dates] - The reckoning context.
|
|
69
|
+
* @returns {boolean} Whether it resolves to one.
|
|
70
|
+
*/
|
|
71
|
+
function namesCalendar(value, dates) {
|
|
72
|
+
try {
|
|
73
|
+
return calendarEras(value, dates).length > 0;
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
36
79
|
/**
|
|
37
80
|
* Every helper an expression may call, with the parameters it takes.
|
|
38
81
|
*
|
|
@@ -135,30 +178,42 @@ const HELPERS = Object.freeze([
|
|
|
135
178
|
}),
|
|
136
179
|
Object.freeze({
|
|
137
180
|
name: "dateformat",
|
|
138
|
-
params: 'calendar date ["format"]',
|
|
181
|
+
params: '[calendar] date ["format"]',
|
|
139
182
|
summary:
|
|
140
|
-
"A date written in a calendar. The calendar is an Address or its shortcode
|
|
141
|
-
"is the
|
|
183
|
+
"A date written in a calendar. The calendar is an Address or its shortcode; left " +
|
|
184
|
+
"out, it is the note's own calendar — its `data.calendar`, else its culture's, " +
|
|
185
|
+
"else for a place its parents', else the configured `defaultCalendar` — and a " +
|
|
186
|
+
"note with none prints the date as written. The date is the value a note " +
|
|
187
|
+
"carries, and the optional last argument names an output pattern.",
|
|
142
188
|
make:
|
|
143
|
-
({ dates }) =>
|
|
144
|
-
(
|
|
189
|
+
({ dates, fm }) =>
|
|
190
|
+
(...args) => {
|
|
191
|
+
const { calendar, authored, format } = dateformatArguments(
|
|
192
|
+
args.slice(0, -1),
|
|
193
|
+
dates,
|
|
194
|
+
);
|
|
145
195
|
if (
|
|
146
|
-
!(
|
|
147
|
-
|
|
196
|
+
!(
|
|
197
|
+
calendar === undefined ||
|
|
198
|
+
typeof calendar === "string" ||
|
|
199
|
+
isAddressTuple(calendar)
|
|
200
|
+
) ||
|
|
201
|
+
!["string", "number"].includes(typeof authored) ||
|
|
202
|
+
!(format === undefined || typeof format === "string")
|
|
148
203
|
)
|
|
149
|
-
throw new TypeError("dateformat needs a
|
|
204
|
+
throw new TypeError("dateformat needs a date, and takes a calendar Address");
|
|
150
205
|
const parsed = parseNoteDate(authored, { ...dates, allowUnknown: false });
|
|
151
206
|
const error = parsed.findings.find((item) => item.severity === "error");
|
|
152
207
|
if (error || !parsed.date)
|
|
153
208
|
throw new RangeError(error?.message ?? "date does not resolve");
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
isAddressTuple(calendar) ? renderAddress(calendar)
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
);
|
|
209
|
+
const reference =
|
|
210
|
+
calendar === undefined ? noteCalendar(fm, dates)
|
|
211
|
+
: isAddressTuple(calendar) ? renderAddress(calendar)
|
|
212
|
+
: calendar;
|
|
213
|
+
if (reference === undefined) return printEventDate(authored, parsed.date, dates);
|
|
214
|
+
const printable = formatDateInCalendar(parsed.date, reference, dates, format);
|
|
160
215
|
if (!printable)
|
|
161
|
-
throw new RangeError(`date cannot be printed in calendar ${
|
|
216
|
+
throw new RangeError(`date cannot be printed in calendar ${reference}`);
|
|
162
217
|
return printable.prose ?? printable.text;
|
|
163
218
|
},
|
|
164
219
|
}),
|
|
@@ -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
|
+
}
|