@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.
- package/CHANGELOG.md +28 -0
- package/ci/ci-steps.mjs +5 -5
- package/content-config.mjs +19 -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/commands.md +5 -4
- package/docs/configuration.md +29 -12
- package/docs/guides/book.md +15 -13
- package/docs/guides/release-and-deploy.md +6 -9
- package/docs/project-setup.md +11 -11
- 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-build.mjs +1 -0
- package/engine/pdf-index.mjs +33 -12
- package/engine/pdf-pages.mjs +13 -3
- package/engine/pdf-render.mjs +60 -33
- package/engine/pdf-toc.mjs +4 -3
- package/engine/project-init.mjs +2 -2
- package/engine/related-pages.mjs +12 -2
- package/engine/site-build.mjs +3 -0
- package/engine/sql-tables.mjs +20 -5
- package/githooks/pre-push +5 -5
- package/package.json +1 -1
- package/types/content-config.d.mts +5 -0
- 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-index.d.mts +7 -3
- package/types/engine/pdf-pages.d.mts +2 -1
- package/types/engine/pdf-render.d.mts +16 -5
- package/types/engine/pdf-toc.d.mts +1 -1
- package/types/engine/related-pages.d.mts +5 -0
- package/ci/confirm-npm-version.mjs +0 -135
package/engine/pdf-build.mjs
CHANGED
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.
|
|
@@ -102,11 +105,13 @@ function letterOf(key) {
|
|
|
102
105
|
* @param {string[]} [opts.heads] - Leading words (`pdf.indexHeads`) matched
|
|
103
106
|
* literally against the start of a name; a name that opens with one is also
|
|
104
107
|
* listed as `<rest>, <head>`, the head without its trailing space or colon. None when absent.
|
|
105
|
-
* @param {Array<{slug: string, name: string, title: string,
|
|
108
|
+
* @param {Array<{slug: string, name: string, title: string, abbreviation?: string,
|
|
109
|
+
* page: string|number}>} [opts.foreign] -
|
|
106
110
|
* Notes another book prints, listed as `Name: Book title, p. N` once this
|
|
107
|
-
* book mentions them.
|
|
111
|
+
* book mentions them, or `Name: Aelw. p12` when the home book has an
|
|
112
|
+
* abbreviation.
|
|
108
113
|
* @returns {Array<{letter: string, name: string, slug: string, see?: string,
|
|
109
|
-
* foreign?: {title: string, page: string}}>} Terms with `see` set for a
|
|
114
|
+
* foreign?: {title: string, abbreviation?: string, page: string}}>} Terms with `see` set for a
|
|
110
115
|
* cross-reference to the main name it points at.
|
|
111
116
|
*/
|
|
112
117
|
export function indexTerms(entries, { owns = () => true, foreign = [], heads = [] } = {}) {
|
|
@@ -123,8 +128,9 @@ export function indexTerms(entries, { owns = () => true, foreign = [], heads = [
|
|
|
123
128
|
for (const entry of entries ?? []) {
|
|
124
129
|
if (entry?.kind !== "note" || !owns(entry)) continue;
|
|
125
130
|
const slug = slugOf(entry.record);
|
|
126
|
-
const name = String(entry.record
|
|
131
|
+
const name = String(nameSortOf(entry.record) ?? "").trim();
|
|
127
132
|
if (!slug || !name || info.has(slug)) continue;
|
|
133
|
+
const declared = Boolean(String(entry.record?.name?.sort ?? "").trim());
|
|
128
134
|
const parents = Array.isArray(entry.record?.data?.parents) ? entry.record.data.parents : [];
|
|
129
135
|
const parent = parents
|
|
130
136
|
.map((one) =>
|
|
@@ -137,7 +143,10 @@ export function indexTerms(entries, { owns = () => true, foreign = [], heads = [
|
|
|
137
143
|
});
|
|
138
144
|
terms.push({ name, slug, main: true });
|
|
139
145
|
const taken = new Set([sortKey(name)]);
|
|
140
|
-
const head =
|
|
146
|
+
const head =
|
|
147
|
+
declared ? undefined : (
|
|
148
|
+
byLength.find((one) => name.startsWith(one) && name.slice(one.length).trim())
|
|
149
|
+
);
|
|
141
150
|
if (head) {
|
|
142
151
|
const inverted = `${name.slice(head.length).trim()}, ${head.replace(/[\s:]+$/, "")}`;
|
|
143
152
|
taken.add(sortKey(inverted));
|
|
@@ -198,7 +207,11 @@ export function indexTerms(entries, { owns = () => true, foreign = [], heads = [
|
|
|
198
207
|
keyed.push({
|
|
199
208
|
name,
|
|
200
209
|
slug: other.slug,
|
|
201
|
-
foreign: {
|
|
210
|
+
foreign: {
|
|
211
|
+
title: String(other.title),
|
|
212
|
+
...(other.abbreviation ? { abbreviation: String(other.abbreviation) } : {}),
|
|
213
|
+
page: String(other.page),
|
|
214
|
+
},
|
|
202
215
|
key: sortKey(name),
|
|
203
216
|
});
|
|
204
217
|
}
|
|
@@ -234,7 +247,7 @@ export function indexTerms(entries, { owns = () => true, foreign = [], heads = [
|
|
|
234
247
|
export function indexPreamble({ seeAlso = false } = {}) {
|
|
235
248
|
return [
|
|
236
249
|
// Zero-size and invisible; the label is what the chapter queries.
|
|
237
|
-
"#let book-ix(key, main: false) = [#metadata((key: key, main: main))
|
|
250
|
+
"#let book-ix(key, main: false) = [#metadata((key: key, main: main))<book-index>]",
|
|
238
251
|
"#let book-index(terms) = context {",
|
|
239
252
|
" set par(justify: false, first-line-indent: 0em, hanging-indent: 1em, spacing: 0.45em)",
|
|
240
253
|
" set text(size: 8.8pt)",
|
|
@@ -271,7 +284,12 @@ export function indexPreamble({ seeAlso = false } = {}) {
|
|
|
271
284
|
...(seeAlso ?
|
|
272
285
|
[
|
|
273
286
|
" if away != none {",
|
|
274
|
-
|
|
287
|
+
' let short = away.at("abbreviation", default: none)',
|
|
288
|
+
" if short != none {",
|
|
289
|
+
" par[#term.name: #short~p#away.page]",
|
|
290
|
+
" } else {",
|
|
291
|
+
" par[#term.name: #emph[#away.title], p. #away.page]",
|
|
292
|
+
" }",
|
|
275
293
|
" continue",
|
|
276
294
|
" }",
|
|
277
295
|
]
|
|
@@ -309,8 +327,11 @@ export function indexChapter(entries, title, opts = {}) {
|
|
|
309
327
|
}${
|
|
310
328
|
term.foreign === undefined ?
|
|
311
329
|
""
|
|
312
|
-
: `, foreign: (title: "${escapeString(term.foreign.title)}",
|
|
313
|
-
|
|
330
|
+
: `, foreign: (title: "${escapeString(term.foreign.title)}", ${
|
|
331
|
+
term.foreign.abbreviation === undefined ?
|
|
332
|
+
""
|
|
333
|
+
: `abbreviation: "${escapeString(term.foreign.abbreviation)}", `
|
|
334
|
+
}` + `page: "${escapeString(term.foreign.page)}")`
|
|
314
335
|
})`,
|
|
315
336
|
);
|
|
316
337
|
return [
|
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 = "?";
|
|
@@ -142,7 +143,7 @@ export function readEntryPages(typPath, fontArgs, binary = "typst") {
|
|
|
142
143
|
* The books that render, in configuration order.
|
|
143
144
|
* @param {Map<string, string>} input.homes - Each note's key → its home book's
|
|
144
145
|
* id, from {@link module:engine/pdf-coverage.coverage}.
|
|
145
|
-
* @returns {Array<{key: string, book: string, title: string, anchor: string,
|
|
146
|
+
* @returns {Array<{key: string, book: string, title: string, abbreviation?: string, anchor: string,
|
|
146
147
|
* record: object, slug: string, aliases: string[]}>} One per homed note, in book order and
|
|
147
148
|
* then entry order.
|
|
148
149
|
*/
|
|
@@ -160,6 +161,7 @@ export function homeEntries({ books, homes }) {
|
|
|
160
161
|
key,
|
|
161
162
|
book: book.id,
|
|
162
163
|
title: book.title,
|
|
164
|
+
abbreviation: book.abbreviation,
|
|
163
165
|
anchor: entry.anchor,
|
|
164
166
|
record,
|
|
165
167
|
slug: String(record?.address?.slug ?? record?.shortcode ?? ""),
|
|
@@ -246,13 +248,21 @@ export function elsewhereFor({ entries, book, printed, table, seeAlso = false })
|
|
|
246
248
|
const keys = [entry.slug, ...entry.aliases].filter((key) => key && !printed.has(key));
|
|
247
249
|
const page = table?.[entry.key]?.page ?? UNMEASURED_PAGE;
|
|
248
250
|
for (const key of keys) {
|
|
249
|
-
if (!map.has(key))
|
|
251
|
+
if (!map.has(key)) {
|
|
252
|
+
map.set(key, {
|
|
253
|
+
slug: entry.slug,
|
|
254
|
+
title: entry.title,
|
|
255
|
+
abbreviation: entry.abbreviation,
|
|
256
|
+
page,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
250
259
|
}
|
|
251
260
|
if (seeAlso && entry.slug && !printed.has(entry.slug)) {
|
|
252
261
|
listed.push({
|
|
253
262
|
slug: entry.slug,
|
|
254
|
-
name: String(entry.record
|
|
263
|
+
name: String(nameSortOf(entry.record) ?? "").trim(),
|
|
255
264
|
title: entry.title,
|
|
265
|
+
abbreviation: entry.abbreviation,
|
|
256
266
|
page,
|
|
257
267
|
});
|
|
258
268
|
}
|
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
|
|
@@ -841,12 +844,10 @@ function renderBlock(tokens, i, out, ctx) {
|
|
|
841
844
|
}
|
|
842
845
|
|
|
843
846
|
/**
|
|
844
|
-
*
|
|
845
|
-
*
|
|
846
|
-
*
|
|
847
|
-
*
|
|
848
|
-
* subsequent paragraphs, lists and blocks retain the definition's inset. A long
|
|
849
|
-
* term enlarges its slot without changing the continuation margin.
|
|
847
|
+
* Definition rows set as a book sets them: the term in bold, run in at the
|
|
848
|
+
* margin, and the definition following on the same line, with its continuation
|
|
849
|
+
* lines and any further paragraphs, lists and blocks indented 1em. A long term
|
|
850
|
+
* takes no more room than a short one. Rows are spaced as paragraphs are.
|
|
850
851
|
*
|
|
851
852
|
* Scope the helper to this list so exported Markdown fragments also work without
|
|
852
853
|
* a book preamble, including nested definition lists.
|
|
@@ -854,18 +855,12 @@ function renderBlock(tokens, i, out, ctx) {
|
|
|
854
855
|
* @returns {string} Typst markup.
|
|
855
856
|
*/
|
|
856
857
|
function renderDefinitionList(entries) {
|
|
857
|
-
return `#
|
|
858
|
-
let book-definition(term, body) =
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
block(width: 100%, breakable: true, inset: (left: indent), above: 0pt, below: 0.5em)[
|
|
864
|
-
#set par(first-line-indent: 0pt, hanging-indent: 0pt)
|
|
865
|
-
#set text(weight: "regular")
|
|
866
|
-
#h(-indent)#box(width: slot, label)#h(gap)#body
|
|
867
|
-
]
|
|
868
|
-
}
|
|
858
|
+
return `#{
|
|
859
|
+
let book-definition(term, body) = block(width: 100%, breakable: true, inset: (left: 1em))[
|
|
860
|
+
#set par(first-line-indent: 0pt, hanging-indent: 0pt)
|
|
861
|
+
#set text(weight: "regular")
|
|
862
|
+
#h(-1em)#text(weight: "bold", term)#h(0.5em)#body
|
|
863
|
+
]
|
|
869
864
|
${entries.join("\n ")}
|
|
870
865
|
}`;
|
|
871
866
|
}
|
|
@@ -1174,8 +1169,14 @@ function renderTable(tokens, ctx) {
|
|
|
1174
1169
|
// Wide content is given an explicit span rather than left to overflow the
|
|
1175
1170
|
// measure: past three columns a table is set across the page, and
|
|
1176
1171
|
// `book-wide` decides between a float and pages of its own by measuring it.
|
|
1177
|
-
|
|
1178
|
-
|
|
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`;
|
|
1179
1180
|
return `\n${labelled}\n\n`;
|
|
1180
1181
|
}
|
|
1181
1182
|
|
|
@@ -1303,7 +1304,11 @@ function renderInline(token, ctx) {
|
|
|
1303
1304
|
case "link_open": {
|
|
1304
1305
|
const close = childMatching(children, i, "link_open", "link_close");
|
|
1305
1306
|
const inner = renderInline({ children: children.slice(i + 1, close) }, ctx);
|
|
1306
|
-
|
|
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 }));
|
|
1307
1312
|
i = close;
|
|
1308
1313
|
break;
|
|
1309
1314
|
}
|
|
@@ -1419,9 +1424,11 @@ function pageOfLabel(label) {
|
|
|
1419
1424
|
* @param {string} href - The resolved URL.
|
|
1420
1425
|
* @param {string} inner - The already-rendered link text.
|
|
1421
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}.
|
|
1422
1429
|
* @returns {string} Typst markup.
|
|
1423
1430
|
*/
|
|
1424
|
-
function renderLink(href, inner, ctx) {
|
|
1431
|
+
function renderLink(href, inner, ctx, { bracketed = false } = {}) {
|
|
1425
1432
|
const url = String(href ?? "");
|
|
1426
1433
|
const fragment = /#([^?]*)/.exec(url)?.[1] ?? "";
|
|
1427
1434
|
if (url.startsWith("#") && fragment)
|
|
@@ -1449,7 +1456,7 @@ function renderLink(href, inner, ctx) {
|
|
|
1449
1456
|
// A mention in authored prose is what lists the note, under
|
|
1450
1457
|
// `pdf.indexSeeAlso`.
|
|
1451
1458
|
const marker = ctx.indexMentions && ctx.elsewhere.seeAlso ? indexMarker(away.slug) : "";
|
|
1452
|
-
return `${crossBookReference(inner, away)}${marker}`;
|
|
1459
|
+
return `${crossBookReference(inner, away, { bracketed })}${marker}`;
|
|
1453
1460
|
}
|
|
1454
1461
|
if (!url || ctx.generated) return inner;
|
|
1455
1462
|
return `#link("${escapeTypstString(absolutePageUrl(url, ctx.url))}")[${inner}]`;
|
|
@@ -1457,18 +1464,30 @@ function renderLink(href, inner, ctx) {
|
|
|
1457
1464
|
|
|
1458
1465
|
/**
|
|
1459
1466
|
* A reference to a note another book prints, as Typst: the words the link
|
|
1460
|
-
* carried, then the book and the page
|
|
1467
|
+
* carried, then the book and the page. A book with an abbreviation reads
|
|
1468
|
+
* `Name (Aelw. p12)`; one without reads `Name (_Book title_, p. 12)`.
|
|
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.
|
|
1461
1473
|
*
|
|
1462
|
-
* The space before the page number
|
|
1463
|
-
* line of its own.
|
|
1474
|
+
* The space before the page number, or between the abbreviation and the page,
|
|
1475
|
+
* does not break, so a number never starts a line of its own.
|
|
1464
1476
|
*
|
|
1465
1477
|
* @param {string} inner - The already-rendered link text.
|
|
1466
|
-
* @param {{title: string, page: string|number}} away -
|
|
1467
|
-
*
|
|
1478
|
+
* @param {{title: string, abbreviation?: string, page: string|number}} away -
|
|
1479
|
+
* The home book's title, its abbreviation when it declares one, and the page
|
|
1480
|
+
* the entry starts on.
|
|
1481
|
+
* @param {{bracketed?: boolean}} [opts] - `bracketed` when the text closes a
|
|
1482
|
+
* bracket right after the link.
|
|
1468
1483
|
* @returns {string} Typst markup.
|
|
1469
1484
|
*/
|
|
1470
|
-
export function crossBookReference(inner, away) {
|
|
1471
|
-
|
|
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})`;
|
|
1472
1491
|
}
|
|
1473
1492
|
|
|
1474
1493
|
/**
|
|
@@ -1705,10 +1724,12 @@ export function bookTypstPreamble() {
|
|
|
1705
1724
|
" ]\n" +
|
|
1706
1725
|
" ])\n" +
|
|
1707
1726
|
"]",
|
|
1708
|
-
"#let book-ornament = box(baseline:
|
|
1709
|
-
"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.
|
|
1710
1731
|
"#let book-footer(name) = context {\n" +
|
|
1711
|
-
" set text(
|
|
1732
|
+
" set text(fill: book-faint)\n" +
|
|
1712
1733
|
" grid(columns: (1fr, auto, 1fr), align(left)[#name], align(center)[#book-ornament],\n" +
|
|
1713
1734
|
" align(right)[#counter(page).display()])\n" +
|
|
1714
1735
|
"}",
|
|
@@ -2033,6 +2054,12 @@ export function renderBook({
|
|
|
2033
2054
|
// becomes wider than `1.`.
|
|
2034
2055
|
out.push("#show list: set par(first-line-indent: 0em)");
|
|
2035
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)");
|
|
2036
2063
|
// The mono face is a separate claim from the book face: a fenced block is
|
|
2037
2064
|
// the one place the corpus is allowed box-drawing characters, and the
|
|
2038
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/project-init.mjs
CHANGED
|
@@ -308,7 +308,7 @@ function projectText(answers) {
|
|
|
308
308
|
"Labels are organization-level, defined in `HeroicLands/.github`.",
|
|
309
309
|
"",
|
|
310
310
|
].join("\n"),
|
|
311
|
-
".
|
|
311
|
+
".gitea/workflows/build.yml": [
|
|
312
312
|
"# SPDX-License-Identifier: GPL-3.0-or-later",
|
|
313
313
|
"name: Build and Test",
|
|
314
314
|
"on:",
|
|
@@ -424,7 +424,7 @@ export function checkProject(rootDir) {
|
|
|
424
424
|
"LICENSE.md",
|
|
425
425
|
".changeset/config.json",
|
|
426
426
|
".github/ISSUE_REPORTING.md",
|
|
427
|
-
".
|
|
427
|
+
".gitea/workflows/build.yml",
|
|
428
428
|
];
|
|
429
429
|
for (const file of required) {
|
|
430
430
|
if (!fs.existsSync(path.join(root, file))) add(file, "required project file is missing");
|
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) {
|
package/engine/sql-tables.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import { expandContentTables } from "./content-tables.mjs";
|
|
|
32
32
|
import { NOTE_VOCABULARY, isGmNote } from "./note-vocabulary.mjs";
|
|
33
33
|
import { encodeAddresses, flattenPublishedAddresses } from "./address-values.mjs";
|
|
34
34
|
import { EVENT_COLUMNS, EVENT_SORT_COLUMNS, eventRows } from "./event-rows.mjs";
|
|
35
|
+
import { reckoningContext } from "./reckoning-markers.mjs";
|
|
35
36
|
import { NAME_COLUMNS, namesRows } from "./name-rows.mjs";
|
|
36
37
|
import {
|
|
37
38
|
GLOSSARY_COLUMNS,
|
|
@@ -66,6 +67,7 @@ import { parseMarkdownFile } from "./helpers.mjs";
|
|
|
66
67
|
import { sqlQueriesInMarkdown } from "./markdown-expressions.mjs";
|
|
67
68
|
// The record accessors only — see `engine/index-records.mjs`.
|
|
68
69
|
import { authoredFrontmatter, isNoteRecord, noteFile, sortKeysDeep } from "./index-records.mjs";
|
|
70
|
+
import { nameSortOf } from "./name-sort.mjs";
|
|
69
71
|
|
|
70
72
|
/** Rendered in a cell whose value is absent. */
|
|
71
73
|
const EMPTY_CELL = "—";
|
|
@@ -438,11 +440,25 @@ export async function openNotesDatabase(
|
|
|
438
440
|
*/
|
|
439
441
|
const WITH_STUBS_SCHEMA = "__with_stubs";
|
|
440
442
|
|
|
443
|
+
/**
|
|
444
|
+
* The record with its `nameSort` stated: the declared `name.sort`, otherwise
|
|
445
|
+
* `name.full`. A record with no name has none.
|
|
446
|
+
*
|
|
447
|
+
* @param {object} record - An index record.
|
|
448
|
+
* @returns {object} The record, with `nameSort` where it has a name.
|
|
449
|
+
*/
|
|
450
|
+
function withNameSort(record) {
|
|
451
|
+
const key = nameSortOf(record);
|
|
452
|
+
return key === undefined ? record : { ...record, nameSort: key };
|
|
453
|
+
}
|
|
454
|
+
|
|
441
455
|
/**
|
|
442
456
|
* Create the two relations an index is read through, and the ladder one of them
|
|
443
457
|
* derives.
|
|
444
458
|
*
|
|
445
|
-
* **`entries` is every row**, stubs included, plus a derived `
|
|
459
|
+
* **`entries` is every row**, stubs included, plus a derived `nameSort` column
|
|
460
|
+
* (the note's `name.sort`, otherwise its `name.full`) and a derived `state`
|
|
461
|
+
* column:
|
|
446
462
|
*
|
|
447
463
|
* ```text
|
|
448
464
|
* address IS NULL → stub
|
|
@@ -484,7 +500,7 @@ async function createRelations(connection, file, records, prefix = "", audience
|
|
|
484
500
|
records
|
|
485
501
|
.map((record) =>
|
|
486
502
|
JSON.stringify({
|
|
487
|
-
record: sortKeysDeep(record),
|
|
503
|
+
record: sortKeysDeep(withNameSort(record)),
|
|
488
504
|
visible: audience !== "public" || !isGmNote(record),
|
|
489
505
|
}),
|
|
490
506
|
)
|
|
@@ -535,9 +551,8 @@ async function createRelations(connection, file, records, prefix = "", audience
|
|
|
535
551
|
* @returns {Promise<void>}
|
|
536
552
|
*/
|
|
537
553
|
async function createEventRelation(connection, file, records, prefix, audience) {
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
);
|
|
554
|
+
const visible = audience === "public" ? records.filter((record) => !isGmNote(record)) : records;
|
|
555
|
+
const rows = eventRows(visible, reckoningContext({ notes: records }));
|
|
541
556
|
if (!rows.length) {
|
|
542
557
|
const columns = EVENT_COLUMNS.map((column) => {
|
|
543
558
|
const type = EVENT_SORT_COLUMNS.includes(column) ? "DOUBLE" : "VARCHAR";
|
package/githooks/pre-push
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env sh
|
|
2
2
|
#
|
|
3
3
|
# Before pushing, run exactly what the Build & Test workflow runs — in a
|
|
4
|
-
# container, over a clean export of HEAD — so a red
|
|
4
|
+
# container, over a clean export of HEAD — so a red CI check is something
|
|
5
5
|
# you chose rather than something you discover.
|
|
6
6
|
#
|
|
7
7
|
# The steps are not listed here. `ci/ci-steps.mjs` reads them out of
|
|
8
|
-
# `.
|
|
8
|
+
# `.gitea/workflows/build.yml`, because a hook holding its own copy of the
|
|
9
9
|
# command list is a second statement of one thing, and the copy is the one that
|
|
10
10
|
# goes stale — silently, since running four of five steps still exits 0.
|
|
11
11
|
#
|
|
12
12
|
# **On push rather than at `gh pr create`**, because a push is where this
|
|
13
|
-
# repository's work reaches
|
|
13
|
+
# repository's work reaches the forge: normally one, just before the pull request.
|
|
14
14
|
# Nothing to remember, no wrapper command, and no entry in package.json — the
|
|
15
15
|
# hook is the whole mechanism.
|
|
16
16
|
#
|
|
@@ -92,7 +92,7 @@ status=$?
|
|
|
92
92
|
# 2 means the check could not run — no Docker — which is not the same as a
|
|
93
93
|
# failure and must not stop the push. Whoever is pushing may not be the
|
|
94
94
|
# maintainer, may be on a machine without Docker, and is entitled to open a
|
|
95
|
-
# pull request;
|
|
95
|
+
# pull request; CI is what enforces the workflow. Said loudly, though: a
|
|
96
96
|
# skipped check that reads like a passed one is how a guard loses its meaning.
|
|
97
97
|
# 127 is "could not execute it at all" — no node on PATH, package not
|
|
98
98
|
# installed. Infrastructure, not a verdict, so it is handled the same way.
|
|
@@ -104,6 +104,6 @@ fi
|
|
|
104
104
|
|
|
105
105
|
if [ "$status" -ne 0 ]; then
|
|
106
106
|
echo ""
|
|
107
|
-
echo "pre-push: refusing to push —
|
|
107
|
+
echo "pre-push: refusing to push — CI would report the same failure."
|
|
108
108
|
exit 1
|
|
109
109
|
fi
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "23.12.
|
|
3
|
+
"version": "23.12.5",
|
|
4
4
|
"description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -159,6 +159,11 @@ export type ResolvedPdfBook = {
|
|
|
159
159
|
*/
|
|
160
160
|
id: string;
|
|
161
161
|
title: string;
|
|
162
|
+
/**
|
|
163
|
+
* Names the book in another book's
|
|
164
|
+
* references; `""` when none is declared.
|
|
165
|
+
*/
|
|
166
|
+
abbreviation: string;
|
|
162
167
|
/**
|
|
163
168
|
* `""` when none is declared.
|
|
164
169
|
*/
|
|
@@ -31,36 +31,6 @@ export function noteAddress(frontmatter: Record<string, any>, contentPackage: st
|
|
|
31
31
|
slug: string;
|
|
32
32
|
canonical: string;
|
|
33
33
|
} | null;
|
|
34
|
-
/**
|
|
35
|
-
* A note's display name reduced to printable 7-bit ASCII.
|
|
36
|
-
*
|
|
37
|
-
* Content names carry the setting's orthography — `Kûrbúl Helm`, `Hârn`,
|
|
38
|
-
* `Kèthîra` — and nobody types them. A reader searching the index, or an editor
|
|
39
|
-
* completing a wikilink, needs a form that matches what a keyboard produces, so
|
|
40
|
-
* the record states one rather than leaving every consumer to invent it (and to
|
|
41
|
-
* invent a *different* one, which is how two searches over the same data come
|
|
42
|
-
* to disagree).
|
|
43
|
-
*
|
|
44
|
-
* **Transliterated, not stripped.** `unidecode` — the same table
|
|
45
|
-
* {@link slugify} already runs, so an ASCII name and a slug can never disagree
|
|
46
|
-
* about a character — carries a letter across rather than deleting it:
|
|
47
|
-
* diacritics fold (`â`→`a`, `è`→`e`), ligatures expand (`æ`→`ae`, `Œ`→`OE`,
|
|
48
|
-
* `ß`→`ss`), the runic letters spell out (`þ`→`th`, `Þ`→`Th`, `ð`→`d`), and
|
|
49
|
-
* even a vulgar fraction becomes readable (`¾`→`3/4`). Deleting them instead
|
|
50
|
-
* would collapse `Kûrbúl` to `Krbl`, which is worse than the original.
|
|
51
|
-
*
|
|
52
|
-
* Anything still outside printable ASCII after that becomes a space, and runs
|
|
53
|
-
* of whitespace collapse — a space rather than nothing, so a character that
|
|
54
|
-
* transliterates away cannot silently weld two words together.
|
|
55
|
-
*
|
|
56
|
-
* The value is emitted even when it equals the name, so a consumer matching on
|
|
57
|
-
* it never has to branch on whether the name happened to be ASCII already.
|
|
58
|
-
*
|
|
59
|
-
* @param {unknown} name - The note's `name.full`.
|
|
60
|
-
* @returns {string|null} The ASCII form, or `null` when there is no name, or
|
|
61
|
-
* nothing printable survives.
|
|
62
|
-
*/
|
|
63
|
-
export function asciiName(name: unknown): string | null;
|
|
64
34
|
/**
|
|
65
35
|
* A note's `name.aliases` reduced to printable 7-bit ASCII, in order.
|
|
66
36
|
*
|
|
@@ -449,6 +419,7 @@ export type PreparedContentIndex = {
|
|
|
449
419
|
*/
|
|
450
420
|
syntax?: import("./content-session.mjs").ContentSyntax | undefined;
|
|
451
421
|
};
|
|
422
|
+
import { asciiName } from "./name-sort.mjs";
|
|
452
423
|
import { collectAnchors } from "./anchors.mjs";
|
|
453
424
|
import { authoredFrontmatter } from "./index-records.mjs";
|
|
454
425
|
import { DERIVED_KEYS } from "./index-records.mjs";
|
|
@@ -458,4 +429,4 @@ import { isStub } from "./index-records.mjs";
|
|
|
458
429
|
import { noteFile } from "./index-records.mjs";
|
|
459
430
|
import { recordPath } from "./index-records.mjs";
|
|
460
431
|
import { sortKeysDeep } from "./index-records.mjs";
|
|
461
|
-
export { collectAnchors, authoredFrontmatter, DERIVED_KEYS, isAssetRecord, isNoteRecord, isStub, noteFile, recordPath, sortKeysDeep };
|
|
432
|
+
export { asciiName, collectAnchors, authoredFrontmatter, DERIVED_KEYS, isAssetRecord, isNoteRecord, isStub, noteFile, recordPath, sortKeysDeep };
|
|
@@ -46,6 +46,17 @@ export function eventNoteIndex(records: Iterable<Record<string, any>>, { content
|
|
|
46
46
|
* @returns {string} The printed date.
|
|
47
47
|
*/
|
|
48
48
|
export function printEventDate(written: unknown, date: object | null, dates?: object): string;
|
|
49
|
+
/**
|
|
50
|
+
* A date as `dateformat` prints it in a calendar, where one is named and
|
|
51
|
+
* prints it; otherwise as {@link printEventDate} prints it.
|
|
52
|
+
*
|
|
53
|
+
* @param {unknown} written - The date as the event writes it.
|
|
54
|
+
* @param {object|null} date - The resolved date.
|
|
55
|
+
* @param {object} [dates] - The reckoning context.
|
|
56
|
+
* @param {string} [calendar] - The calendar's Address or shortcode.
|
|
57
|
+
* @returns {string} The printed date; the authored text when nothing resolves.
|
|
58
|
+
*/
|
|
59
|
+
export function printEventDateIn(written: unknown, date: object | null, dates?: object, calendar?: string): string;
|
|
49
60
|
/**
|
|
50
61
|
* One field of one event, as the text a reference prints, or why it cannot.
|
|
51
62
|
*
|
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @param {Iterable<Record<string, any>>} records - Index records, every
|
|
5
5
|
* Address a string, already narrowed to the audience being published to.
|
|
6
|
+
* @param {object} [dates] - The reckoning context the dates are printed in.
|
|
6
7
|
* @returns {object[]} One row per event entry, in record then entry order.
|
|
7
8
|
*/
|
|
8
|
-
export function eventRows(records: Iterable<Record<string, any
|
|
9
|
+
export function eventRows(records: Iterable<Record<string, any>>, dates?: object): object[];
|
|
9
10
|
/** The sort keys a row carries, after the event's own keys. */
|
|
10
11
|
export const EVENT_SORT_COLUMNS: readonly string[];
|
|
12
|
+
/** The printed dates a row carries, after the sort keys. */
|
|
13
|
+
export const EVENT_TEXT_COLUMNS: readonly string[];
|
|
11
14
|
/** Every column of the `events` relation, in order. */
|
|
12
15
|
export const EVENT_COLUMNS: any;
|