@heroiclands/package-build 23.12.3 → 23.12.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/ci/ci-steps.mjs +5 -5
  3. package/content-config.mjs +19 -0
  4. package/docs/api.md +1 -0
  5. package/docs/authoring/frontmatter.md +1 -1
  6. package/docs/authoring/links-and-markup.md +4 -4
  7. package/docs/commands.md +5 -4
  8. package/docs/configuration.md +29 -12
  9. package/docs/guides/book.md +15 -13
  10. package/docs/guides/release-and-deploy.md +6 -9
  11. package/docs/project-setup.md +11 -11
  12. package/docs/reference/format-details.md +28 -16
  13. package/docs/reference/note-types.md +1 -1
  14. package/engine/content-index.mjs +2 -39
  15. package/engine/event-fields.mjs +24 -1
  16. package/engine/event-rows.mjs +37 -2
  17. package/engine/event-views.mjs +58 -14
  18. package/engine/frontmatter-lint.mjs +11 -0
  19. package/engine/generated-sections.mjs +8 -1
  20. package/engine/holdings.mjs +8 -2
  21. package/engine/infobox-render.mjs +16 -5
  22. package/engine/infobox.mjs +23 -2
  23. package/engine/literature-works.mjs +8 -2
  24. package/engine/metadata-index.mjs +3 -0
  25. package/engine/name-sort.mjs +91 -0
  26. package/engine/note-frontmatter.mjs +1 -1
  27. package/engine/note-vocabulary.mjs +1 -1
  28. package/engine/page-lists.mjs +3 -2
  29. package/engine/pdf-build.mjs +1 -0
  30. package/engine/pdf-index.mjs +33 -12
  31. package/engine/pdf-pages.mjs +13 -3
  32. package/engine/pdf-render.mjs +60 -33
  33. package/engine/pdf-toc.mjs +4 -3
  34. package/engine/project-init.mjs +2 -2
  35. package/engine/related-pages.mjs +12 -2
  36. package/engine/site-build.mjs +3 -0
  37. package/engine/sql-tables.mjs +20 -5
  38. package/githooks/pre-push +5 -5
  39. package/package.json +1 -1
  40. package/types/content-config.d.mts +5 -0
  41. package/types/engine/content-index.d.mts +2 -31
  42. package/types/engine/event-fields.d.mts +11 -0
  43. package/types/engine/event-rows.d.mts +4 -1
  44. package/types/engine/event-views.d.mts +7 -3
  45. package/types/engine/generated-sections.d.mts +6 -0
  46. package/types/engine/holdings.d.mts +7 -1
  47. package/types/engine/infobox.d.mts +11 -0
  48. package/types/engine/literature-works.d.mts +5 -0
  49. package/types/engine/name-sort.d.mts +49 -0
  50. package/types/engine/pdf-index.d.mts +7 -3
  51. package/types/engine/pdf-pages.d.mts +2 -1
  52. package/types/engine/pdf-render.d.mts +16 -5
  53. package/types/engine/pdf-toc.d.mts +1 -1
  54. package/types/engine/related-pages.d.mts +5 -0
  55. package/ci/confirm-npm-version.mjs +0 -135
@@ -780,6 +780,7 @@ export async function buildPdf({
780
780
  books: buildable.map((b) => ({
781
781
  id: b.entry.id,
782
782
  title: b.entry.title,
783
+ abbreviation: b.entry.abbreviation,
783
784
  plan: b.selection.plan,
784
785
  })),
785
786
  homes,
@@ -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 "./content-index.mjs";
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, page: string|number}>} [opts.foreign] -
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?.name?.full ?? "").trim();
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 = byLength.find((one) => name.startsWith(one) && name.slice(one.length).trim());
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: { title: String(other.title), page: String(other.page) },
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)) <book-index>]",
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
- " par[#term.name: #emph[#away.title], p. #away.page]",
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
- `page: "${escapeString(term.foreign.page)}")`
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 [
@@ -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)) map.set(key, { slug: entry.slug, title: entry.title, page });
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?.name?.full ?? "").trim(),
263
+ name: String(nameSortOf(entry.record) ?? "").trim(),
255
264
  title: entry.title,
265
+ abbreviation: entry.abbreviation,
256
266
  page,
257
267
  });
258
268
  }
@@ -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
- * Self-contained dictionary rows with the website's 150 CSS-pixel hanging indent.
845
- *
846
- * Each row remains a breakable block; only the bold term is an inline box. The
847
- * negative first-line space lets its label start at the original margin while
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 `#context {
858
- let book-definition(term, body) = {
859
- let indent = 112.5pt
860
- let gap = (0.75em).to-absolute()
861
- let label = text(weight: "bold", term)
862
- let slot = calc.max(indent - gap, measure(label).width)
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
- if (columns > WIDE_TABLE_COLUMNS)
1178
- return `${caption ? "" : "\n#pagebreak(weak: true)\n"}\n#book-wide[\n${labelled}\n]\n\n`;
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
- out.push(renderLink(child.attrGet?.("href") ?? "", inner, ctx));
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, `Name (_Book title_, p. 12)`.
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 does not break, so a number never starts a
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 - The home book's title
1467
- * and the page the entry starts on.
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
- return `${inner} (#emph[${escapeTypst(away.title)}], p.~${away.page})`;
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: 1pt, " +
1709
- "rotate(45deg, rect(width: 3pt, height: 3pt, fill: book-accent)))",
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(size: 8pt, fill: book-faint)\n" +
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.
@@ -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
- * `nameAscii` order — the ASCII fold `buildIndexRecord` already derives, so a
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?.nameAscii ?? a?.name?.full ?? "";
570
- const bn = b?.nameAscii ?? b?.name?.full ?? "";
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 ?? "";
@@ -308,7 +308,7 @@ function projectText(answers) {
308
308
  "Labels are organization-level, defined in `HeroicLands/.github`.",
309
309
  "",
310
310
  ].join("\n"),
311
- ".github/workflows/build.yml": [
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
- ".github/workflows/build.yml",
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");
@@ -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 a.type.localeCompare(b.type, "en") || a.title.localeCompare(b.title, "en");
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) {
@@ -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) {
@@ -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 `state` column:
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 rows = eventRows(
539
- audience === "public" ? records.filter((record) => !isGmNote(record)) : records,
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 GitHub check is something
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
- # `.github/workflows/build.yml`, because a hook holding its own copy of the
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 GitHub: normally one, just before the pull request.
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; GitHub is what enforces the workflow. Said loudly, though: a
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 — GitHub would report the same failure."
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",
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>>): object[];
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;