@heroiclands/package-build 23.12.5 → 23.12.7

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 (95) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/assets/styles/content-base.css +25 -0
  3. package/bin/content-commands.mjs +264 -3
  4. package/bin/package-build.mjs +25 -0
  5. package/content-config.mjs +98 -0
  6. package/docs/api.md +18 -14
  7. package/docs/authoring/dates-and-calendars.md +29 -1
  8. package/docs/authoring/links-and-markup.md +442 -50
  9. package/docs/commands.md +127 -0
  10. package/docs/configuration.md +90 -3
  11. package/docs/content-format.md +3 -1
  12. package/docs/guides/book.md +11 -0
  13. package/docs/guides/module-project.md +17 -1
  14. package/docs/guides/site.md +19 -0
  15. package/docs/reference/format-details.md +434 -23
  16. package/docs/reference/note-types.md +45 -21
  17. package/engine/base-compiler.mjs +2 -0
  18. package/engine/bundles.mjs +7 -2
  19. package/engine/compile-corpus.mjs +3 -1
  20. package/engine/content-charset.mjs +21 -2
  21. package/engine/content-format.yaml +1 -2
  22. package/engine/content-index.mjs +33 -2
  23. package/engine/content-links.mjs +3 -0
  24. package/engine/content-poetry.mjs +22 -4
  25. package/engine/content-scripts.mjs +1219 -0
  26. package/engine/content-session.mjs +2 -0
  27. package/engine/data-keys.mjs +5 -1
  28. package/engine/dictionary-layout.mjs +18 -12
  29. package/engine/dictionary-rows.mjs +11 -2
  30. package/engine/event-rows.mjs +63 -18
  31. package/engine/event-views.mjs +28 -15
  32. package/engine/helpers.mjs +8 -2
  33. package/engine/index-records.mjs +2 -0
  34. package/engine/infobox.mjs +4 -8
  35. package/engine/journals.mjs +14 -3
  36. package/engine/language-data.mjs +137 -1
  37. package/engine/language-tag.mjs +103 -0
  38. package/engine/language-terms.mjs +16 -4
  39. package/engine/map-lexicon.mjs +762 -0
  40. package/engine/markdown-expressions.mjs +72 -17
  41. package/engine/markup-validation.mjs +17 -3
  42. package/engine/names.mjs +1 -1
  43. package/engine/note-calendars.mjs +314 -0
  44. package/engine/note-vocabulary.mjs +124 -16
  45. package/engine/pdf-build.mjs +28 -10
  46. package/engine/pdf-render.mjs +116 -11
  47. package/engine/reckoning-markers.mjs +63 -3
  48. package/engine/run-rows.mjs +119 -0
  49. package/engine/run-words.mjs +266 -0
  50. package/engine/scenes.mjs +1 -0
  51. package/engine/script-font-delivery.mjs +321 -0
  52. package/engine/script-fonts.mjs +172 -0
  53. package/engine/site-build.mjs +10 -3
  54. package/engine/site-config.mjs +5 -0
  55. package/engine/site-root.mjs +19 -5
  56. package/engine/sql-tables.mjs +120 -4
  57. package/engine/text-maps.mjs +607 -0
  58. package/engine/verb-pattern.mjs +99 -0
  59. package/engine/word-pattern.mjs +27 -0
  60. package/engine/word-rows.mjs +8 -2
  61. package/engine/word-terms.mjs +44 -0
  62. package/hugo-theme/layouts/_default/baseof.html +3 -0
  63. package/hugo-theme/static/css/style.css +10 -0
  64. package/manifest.mjs +9 -1
  65. package/package.json +1 -1
  66. package/types/content-config.d.mts +38 -0
  67. package/types/engine/compile-corpus.d.mts +2 -1
  68. package/types/engine/content-index.d.mts +17 -3
  69. package/types/engine/content-poetry.d.mts +7 -0
  70. package/types/engine/content-scripts.d.mts +226 -0
  71. package/types/engine/data-keys.d.mts +2 -2
  72. package/types/engine/dictionary-rows.d.mts +15 -0
  73. package/types/engine/event-rows.d.mts +16 -1
  74. package/types/engine/event-views.d.mts +2 -1
  75. package/types/engine/helpers.d.mts +5 -1
  76. package/types/engine/journals.d.mts +7 -2
  77. package/types/engine/language-data.d.mts +2 -0
  78. package/types/engine/language-tag.d.mts +8 -0
  79. package/types/engine/language-terms.d.mts +2 -1
  80. package/types/engine/map-lexicon.d.mts +274 -0
  81. package/types/engine/markup-validation.d.mts +7 -2
  82. package/types/engine/note-calendars.d.mts +60 -0
  83. package/types/engine/pdf-build.d.mts +26 -3
  84. package/types/engine/pdf-render.d.mts +17 -2
  85. package/types/engine/reckoning-markers.d.mts +32 -1
  86. package/types/engine/run-rows.d.mts +89 -0
  87. package/types/engine/run-words.d.mts +86 -0
  88. package/types/engine/script-font-delivery.d.mts +134 -0
  89. package/types/engine/script-fonts.d.mts +168 -0
  90. package/types/engine/site-build.d.mts +2 -1
  91. package/types/engine/site-root.d.mts +11 -3
  92. package/types/engine/text-maps.d.mts +175 -0
  93. package/types/engine/verb-pattern.d.mts +65 -0
  94. package/types/engine/word-pattern.d.mts +13 -0
  95. package/types/engine/word-terms.d.mts +6 -0
@@ -81,6 +81,7 @@ import { fileURLToPath } from "node:url";
81
81
  import YAML from "yaml";
82
82
 
83
83
  import { CONFIG_FILENAMES, loadPackConfig, packConfigPath } from "./pack-config.mjs";
84
+ import { runContext } from "./content-scripts.mjs";
84
85
  import { routerFor } from "./pack-router.mjs";
85
86
  import { publishesContentPages } from "../content-config.mjs";
86
87
  import { indexRecordsFor } from "./content-index.mjs";
@@ -160,6 +161,7 @@ import { resolveIconGlyphs } from "./pdf-fonts.mjs";
160
161
  import { drawMapsFrom } from "./derived-sections.mjs";
161
162
  import { generatedDrawing, markGenerated, splitGenerated } from "./generated-sections.mjs";
162
163
  import { resolveAssetReplacement } from "./asset-replacement.mjs";
164
+ import { typstScriptFontPath } from "./script-font-delivery.mjs";
163
165
 
164
166
  /**
165
167
  * The file on disk an authored image pathname names, or `null`.
@@ -832,7 +834,7 @@ export async function buildPdf({
832
834
  * message: string}} The table, or null with why it could not be read.
833
835
  */
834
836
  const measureAll = (written) => {
835
- const fontArgs = typstFontArgs(resolved.pdf);
837
+ const fontArgs = typstFontArgs(resolved.pdf, resolved);
836
838
  const measured = new Map();
837
839
  for (const { entry } of toRender) {
838
840
  const result = readPages(
@@ -906,7 +908,7 @@ export async function buildPdf({
906
908
  if (!wanted) continue;
907
909
  const result = pass.written.get(entry.id);
908
910
  imageReports.push(...(result.imageReports ?? []));
909
- const ok = compile ? compileBook(result, resolved.pdf, findings) : true;
911
+ const ok = compile ? compileBook(result, resolved.pdf, findings, resolved) : true;
910
912
  if (!ok) built = false;
911
913
  books.push({
912
914
  id: entry.id,
@@ -1616,6 +1618,7 @@ async function buildBook(book, shared) {
1616
1618
  url: site,
1617
1619
  elsewhere,
1618
1620
  findings,
1621
+ runs: runContext(resolved, records),
1619
1622
  file: page.file,
1620
1623
  bodyLine: page.bodyLine,
1621
1624
  prepared: true,
@@ -1706,6 +1709,7 @@ async function buildBook(book, shared) {
1706
1709
  elsewhere,
1707
1710
  indexMentions: true,
1708
1711
  findings,
1712
+ runs: runContext(resolved, records),
1709
1713
  file,
1710
1714
  }),
1711
1715
  );
@@ -1737,6 +1741,7 @@ async function buildBook(book, shared) {
1737
1741
  elsewhere,
1738
1742
  indexMentions: true,
1739
1743
  findings,
1744
+ runs: runContext(resolved, records),
1740
1745
  file,
1741
1746
  });
1742
1747
  } catch {
@@ -1910,6 +1915,7 @@ async function buildBook(book, shared) {
1910
1915
  copyright: shared.copyright,
1911
1916
  license: shared.license,
1912
1917
  index,
1918
+ runs: runContext(resolved, records),
1913
1919
  });
1914
1920
  // Last, over the whole document: a reference can only be checked once every
1915
1921
  // declaration is in one string, and Typst treats a dangling one as fatal.
@@ -1932,10 +1938,11 @@ async function buildBook(book, shared) {
1932
1938
  * @param {{typ: string, pdf: string}} written - From {@link buildBook}.
1933
1939
  * @param {object} pdf - The resolved `pdf:` block.
1934
1940
  * @param {object[]} findings - Collected here rather than thrown.
1941
+ * @param {object} config - The resolved configuration.
1935
1942
  * @returns {boolean} Whether the PDF was written.
1936
1943
  */
1937
- function compileBook(written, pdf, findings) {
1938
- const compiled = compileTypst(written.typ, written.pdf, pdf);
1944
+ function compileBook(written, pdf, findings, config) {
1945
+ const compiled = compileTypst(written.typ, written.pdf, pdf, config);
1939
1946
  findings.push(...compiled.findings);
1940
1947
  if (!compiled.ok) {
1941
1948
  findings.push({ file: written.typ, severity: "error", message: compiled.message });
@@ -1979,10 +1986,13 @@ export const BOOK_FONTS_PATH = path.join(
1979
1986
  * @param {string} typPath - The `.typ` file.
1980
1987
  * @param {string} pdfPath - Where the PDF goes.
1981
1988
  * @param {object} [pdf] - The resolved `pdf:` block.
1989
+ * @param {{rootDir?: string, paths?: {content?: string}}} [config] - The
1990
+ * resolved configuration; the installed faces are searched when a note sets
1991
+ * text in one.
1982
1992
  * @returns {string[]} The arguments, in order.
1983
1993
  */
1984
- export function typstArgs(typPath, pdfPath, pdf = {}) {
1985
- return ["compile", ...typstFontArgs(pdf), typPath, pdfPath];
1994
+ export function typstArgs(typPath, pdfPath, pdf = {}, config) {
1995
+ return ["compile", ...typstFontArgs(pdf, config), typPath, pdfPath];
1986
1996
  }
1987
1997
 
1988
1998
  /**
@@ -1990,14 +2000,20 @@ export function typstArgs(typPath, pdfPath, pdf = {}) {
1990
2000
  * the page measurement so both lay the book out in the same metrics.
1991
2001
  *
1992
2002
  * @param {object} [pdf] - The resolved `pdf:` block.
2003
+ * @param {{rootDir?: string, paths?: {content?: string}}} [config] - The
2004
+ * resolved configuration; the installed faces are searched when a note sets
2005
+ * text in one.
1993
2006
  * @returns {string[]} The arguments, in order.
1994
2007
  */
1995
- export function typstFontArgs(pdf = {}) {
2008
+ export function typstFontArgs(pdf = {}, config) {
1996
2009
  // One flag, the paths joined by the platform's separator: the compiler
1997
2010
  // takes a list, and the shipped faces come first so a consumer's own
1998
- // directory extends the set rather than standing in for it.
2011
+ // directory and the installed script fonts extend the set rather than
2012
+ // standing in for it.
1999
2013
  const fontPaths = [BOOK_FONTS_PATH];
2000
2014
  if (pdf.fonts?.path) fontPaths.push(pdf.fonts.path);
2015
+ const scriptFonts = config && typstScriptFontPath(config);
2016
+ if (scriptFonts) fontPaths.push(scriptFonts);
2001
2017
  return ["--ignore-system-fonts", "--font-path", fontPaths.join(path.delimiter)];
2002
2018
  }
2003
2019
 
@@ -2044,12 +2060,14 @@ export function typstWarnings(output) {
2044
2060
  * @param {string} typPath - The `.typ` file.
2045
2061
  * @param {string} pdfPath - Where the PDF goes.
2046
2062
  * @param {object} pdf - The resolved `pdf:` block.
2063
+ * @param {{rootDir?: string, paths?: {content?: string}}} [config] - The
2064
+ * resolved configuration, for the installed faces.
2047
2065
  * @returns {{ok: boolean, message: string, findings: object[]}} What happened,
2048
2066
  * and what the compiler warned about on the way.
2049
2067
  */
2050
- export function compileTypst(typPath, pdfPath, pdf = {}) {
2068
+ export function compileTypst(typPath, pdfPath, pdf = {}, config) {
2051
2069
  const binary = pdf.binary || "typst";
2052
- const args = typstArgs(typPath, pdfPath, pdf);
2070
+ const args = typstArgs(typPath, pdfPath, pdf, config);
2053
2071
  let result;
2054
2072
  try {
2055
2073
  result = spawnSync(binary, args, { encoding: "utf8" });
@@ -91,6 +91,7 @@ import { indexChapter, indexMarker, indexPreamble } from "./pdf-index.mjs";
91
91
  import { slugify } from "./content-slug.mjs";
92
92
  import { alertMarkdownPlugin, scanAlerts, ALERT_TYPES } from "./content-alerts.mjs";
93
93
  import { spanMarkdownPlugin, scanSpans } from "./content-spans.mjs";
94
+ import { GENERIC_FONTS, renderRuns, scanRuns } from "./content-scripts.mjs";
94
95
  import { scanFigures } from "./content-figures.mjs";
95
96
  import { BLOCK_CONTAINERS, scanBlocks, BLOCK_NAMES } from "./content-blocks.mjs";
96
97
  import { inlineVerse, poetryStanzas, scanPoetry } from "./content-poetry.mjs";
@@ -370,7 +371,11 @@ function reportUnrenderable(source, definitions, opts, original) {
370
371
  );
371
372
  }
372
373
  }
373
- for (const error of [...scanSpans(source).errors, ...markupAnchorFindings(source)])
374
+ for (const error of [
375
+ ...scanSpans(source).errors,
376
+ ...scanRuns(source, opts.runs).errors,
377
+ ...markupAnchorFindings(source),
378
+ ])
374
379
  report(linePosition(error.line, error.column, opts), "error", error.message);
375
380
  for (const error of scanFigures(source).errors)
376
381
  report(linePosition(error.line, error.column, opts), "error", error.message);
@@ -462,6 +467,9 @@ function reportUnrenderable(source, definitions, opts, original) {
462
467
  * Typst and is told nothing.
463
468
  * @param {string} [opts.file] - The file the markdown came from, named in every
464
469
  * finding.
470
+ * @param {import("./content-scripts.mjs").RunContext} [opts.runs] - The
471
+ * package's languages, faces and maps, which text marked with `lang=`,
472
+ * `font=` and `map=` is checked against and set by.
465
473
  * @param {number} [opts.bodyLine] - The 1-based file line the body starts on, so
466
474
  * a finding's line is the file's rather than the body's.
467
475
  * @param {number} [opts.bodyColumn] - The 1-based column the body starts at.
@@ -497,7 +505,7 @@ export function markdownToTypst(markdown, opts = {}) {
497
505
  } = opts;
498
506
  const given = opts.nested ? String(markdown ?? "") : stripHtmlComments(markdown);
499
507
  const separated = opts.footnoteDefinitions === undefined ? separateFootnotes(given) : null;
500
- const source = separated?.markdown ?? given;
508
+ const authored = separated?.markdown ?? given;
501
509
  const definitions = opts.footnoteDefinitions ?? separated?.definitions ?? "";
502
510
  const footnoteState = opts.footnoteState ?? { seen: new Set() };
503
511
  const sharedOptions = {
@@ -508,7 +516,13 @@ export function markdownToTypst(markdown, opts = {}) {
508
516
  };
509
517
  // Read once, over the body as it arrived: every position a finding carries
510
518
  // is an offset into *this* string, and the renderer below walks slices of it.
511
- if (!opts.nested) reportUnrenderable(source, definitions, opts, given);
519
+ if (!opts.nested) reportUnrenderable(authored, definitions, opts, given);
520
+ // Text marked with a language, a font or a map is rewritten once, ahead of
521
+ // every other pass, line for line; the nested calls below read the result.
522
+ const source =
523
+ opts.nested ? authored : (
524
+ renderRuns(authored, opts.runs, undefined, { target: "book" }).markdown
525
+ );
512
526
  // One map for the whole body, not one per block: a heading inside a
513
527
  // blockquote or a list item shares the entry's anchor namespace with every
514
528
  // other heading in the same body, because `sectionLabel` scopes by entry
@@ -584,7 +598,9 @@ export function markdownToTypst(markdown, opts = {}) {
584
598
  insideAdmonition: false,
585
599
  });
586
600
  const anchor = block.id ? ` <${sectionLabel(anchorPrefix, block.id)}>` : "";
587
- output.push(`\n#block(width: 100%)[${content}]${anchor}\n`);
601
+ output.push(
602
+ `\n#block(width: 100%)[${scriptWrapped(content, block.classes, block.attributes)}]${anchor}\n`,
603
+ );
588
604
  cursor = block.end + 1;
589
605
  continue;
590
606
  }
@@ -771,7 +787,9 @@ function renderBlock(tokens, i, out, ctx) {
771
787
  `${token.markup}${token.info}\n${token.content}${token.markup}`,
772
788
  ).blocks[0];
773
789
  const anchor = poem?.id ? ` <${sectionLabel(ctx.anchorPrefix, poem.id)}>` : "";
774
- out.push(`\n#block(width: 100%)[${content}]${anchor}\n`);
790
+ out.push(
791
+ `\n#block(width: 100%)[${scriptWrapped(content, poem?.classes, poem?.attributes)}]${anchor}\n`,
792
+ );
775
793
  } else out.push(rawBlock(token.content, token.info?.trim() || ""));
776
794
  return 1;
777
795
  }
@@ -1215,6 +1233,43 @@ function rawBlock(content, info) {
1215
1233
  return `\n${ticks}${lang}\n${text}\n${ticks}\n\n`;
1216
1234
  }
1217
1235
 
1236
+ /**
1237
+ * The language code Typst's `text(lang:)` takes for a BCP 47 tag.
1238
+ *
1239
+ * Typst takes an ISO 639 code and applies the hyphenation of that language, so
1240
+ * a real language passes its primary subtag and an invented one (`art-x-…`),
1241
+ * which has no patterns, leaves the book's language alone.
1242
+ *
1243
+ * @param {string|null|undefined} tag - A BCP 47 tag.
1244
+ * @returns {string|undefined} The code, or `undefined` for none.
1245
+ */
1246
+ function typstLanguage(tag) {
1247
+ const primary = /^([a-z]{2,3})(?:-|$)/i.exec(tag ?? "")?.[1]?.toLowerCase();
1248
+ return primary && primary !== "art" ? primary : undefined;
1249
+ }
1250
+
1251
+ /** The classes that set a run in a font: a face's run carries `script`. */
1252
+ const FONT_CLASSES = Object.freeze(["script", ...Object.values(GENERIC_FONTS)]);
1253
+
1254
+ /**
1255
+ * Wrap a block's Typst in its font, its language and its direction.
1256
+ *
1257
+ * @param {string} content - The block's Typst.
1258
+ * @param {string[]|undefined} classes - The classes the block carries.
1259
+ * @param {Record<string, string>|undefined} attributes - Its `lang` and `dir`.
1260
+ * @returns {string} The content, unchanged when the block is marked with none.
1261
+ */
1262
+ function scriptWrapped(content, classes = [], attributes = {}) {
1263
+ const lang = typstLanguage(attributes.lang);
1264
+ const direction = attributes.dir === "rtl" ? "#set text(dir: rtl)\n" : "";
1265
+ if (!classes.some((name) => FONT_CLASSES.includes(name)) && !lang && !direction) return content;
1266
+ const list = classes.map((name) => `"${escapeTypstString(name)}"`);
1267
+ const arguments_ =
1268
+ `classes: (${list.join(", ")}${list.length === 1 ? "," : ""})` +
1269
+ (lang ? `, lang: "${lang}"` : "");
1270
+ return `${direction}#book-script-text(${arguments_})[${content}]`;
1271
+ }
1272
+
1218
1273
  /**
1219
1274
  * Render an inline token's children.
1220
1275
  *
@@ -1248,9 +1303,15 @@ function renderInline(token, ctx) {
1248
1303
  const values = classes
1249
1304
  .map((value) => '"' + escapeTypstString(value) + '"')
1250
1305
  .join(", ");
1306
+ const lang = typstLanguage(child.attrGet?.("lang"));
1251
1307
  out.push(
1252
- classes.length ?
1253
- "#book-span(classes: (" + values + (classes.length === 1 ? "," : "") + "))["
1308
+ classes.length || lang ?
1309
+ "#book-span(classes: (" +
1310
+ values +
1311
+ (classes.length === 1 ? "," : "") +
1312
+ ")" +
1313
+ (lang ? `, lang: "${lang}"` : "") +
1314
+ ")["
1254
1315
  : "#box[",
1255
1316
  );
1256
1317
  break;
@@ -1632,6 +1693,30 @@ function renderIcon(token, ctx) {
1632
1693
  */
1633
1694
  const WIDE_TABLE_COLUMNS = 3;
1634
1695
 
1696
+ /**
1697
+ * The `book-script-fonts` dictionary: each installed face's class, and each
1698
+ * generic family's, to its font family and OpenType features.
1699
+ *
1700
+ * @param {import("./content-scripts.mjs").RunContext} runs - The installed faces.
1701
+ * @param {{serif?: string, sans?: string, mono?: string}} families - The book's
1702
+ * own serif, sans and mono, which `font=serif`, `sans` and `mono` set.
1703
+ * @returns {string} Typst markup.
1704
+ */
1705
+ function scriptFontsTypst(runs, families) {
1706
+ const entry = (name, family, features = []) =>
1707
+ `"${escapeTypstString(name)}": (family: "${escapeTypstString(family)}", ` +
1708
+ `features: (${features.map((feature) => `"${escapeTypstString(feature)}"`).join(", ")}` +
1709
+ `${features.length === 1 ? "," : ""}))`;
1710
+ const faces = [];
1711
+ for (const face of runs.faces?.values() ?? [])
1712
+ if (face.class && face.family) faces.push(entry(face.class, face.family, face.features));
1713
+ for (const [generic, name] of Object.entries(GENERIC_FONTS)) {
1714
+ const family = families[/** @type {"serif"|"sans"|"mono"} */ (generic)];
1715
+ if (family) faces.push(entry(name, family));
1716
+ }
1717
+ return `#let book-script-fonts = (${faces.length ? faces.join(", ") : ":"})`;
1718
+ }
1719
+
1635
1720
  /**
1636
1721
  * The Typst definitions the book's page furniture is drawn with.
1637
1722
  *
@@ -1669,20 +1754,37 @@ const WIDE_TABLE_COLUMNS = 3;
1669
1754
  * carries are not the same set, and a missing glyph on 2,000 feet is a
1670
1755
  * tofu box on every page of the book.
1671
1756
  *
1757
+ * @param {import("./content-scripts.mjs").RunContext} [runs] - The installed
1758
+ * faces, each of whose classes sets its family.
1759
+ * @param {{serif?: string, sans?: string, mono?: string}} [families] - The
1760
+ * book's own serif, sans and mono, which `font=serif`, `sans` and `mono` set.
1672
1761
  * @returns {string} Typst markup.
1673
1762
  */
1674
- export function bookTypstPreamble() {
1763
+ export function bookTypstPreamble(runs = {}, families = {}) {
1675
1764
  return [
1676
1765
  "#let book-margin = 1.9cm",
1677
1766
  "#let book-page-width = 8.5in",
1678
1767
  "#let book-page-height = 11in",
1679
1768
  "#let book-text-width = book-page-width - 2 * book-margin",
1680
1769
  "#let book-text-height = book-page-height - 2 * book-margin",
1770
+ // Each face's class, and each generic family's, sets its font family
1771
+ // and OpenType features.
1772
+ scriptFontsTypst(runs, families),
1773
+ "#let book-script-text(body, classes: (), lang: none) = {\n" +
1774
+ " let styled = body\n" +
1775
+ " for name in classes {\n" +
1776
+ " if name in book-script-fonts {\n" +
1777
+ " let face = book-script-fonts.at(name)\n" +
1778
+ " styled = text(font: face.family, features: face.features, styled)\n" +
1779
+ " }\n" +
1780
+ " }\n" +
1781
+ " if lang != none { text(lang: lang, styled) } else { styled }\n" +
1782
+ "}",
1681
1783
  // Span classes reach the book intact; name styling sets an absolute
1682
1784
  // weight so it remains bold inside an ordinary strong phrase.
1683
- "#let book-span(body, classes: ()) = {\n" +
1785
+ "#let book-span(body, classes: (), lang: none) = {\n" +
1684
1786
  ' let styled = if classes.contains("name") { text(weight: "bold", body) } else { body }\n' +
1685
- " box(styled)\n" +
1787
+ " box(book-script-text(styled, classes: classes, lang: lang))\n" +
1686
1788
  "}",
1687
1789
  // Use the file's natural measure for auto. Every requested width is
1688
1790
  // bounded by the available measure and by the height of a page.
@@ -2001,6 +2103,8 @@ function authorLine({ name, role }) {
2001
2103
  * @param {object} [opts.index] - As {@link module:engine/pdf-index.indexTerms}:
2002
2104
  * `owns` limits the index to the notes this book is home to, and `foreign`
2003
2105
  * (present only under `pdf.indexSeeAlso`) lists notes another book prints.
2106
+ * @param {import("./content-scripts.mjs").RunContext} [opts.runs] - The
2107
+ * installed faces, which the preamble maps to font families.
2004
2108
  * @returns {string} A complete `.typ` document.
2005
2109
  */
2006
2110
  export function renderBook({
@@ -2019,6 +2123,7 @@ export function renderBook({
2019
2123
  copyright = "",
2020
2124
  license = "",
2021
2125
  index = {},
2126
+ runs = {},
2022
2127
  } = {}) {
2023
2128
  // The two halves of one superfamily, chosen together: matched metrics are
2024
2129
  // most of why the sans can carry every heading over a serif body without
@@ -2030,7 +2135,7 @@ export function renderBook({
2030
2135
  const mono = fonts.mono || "DejaVu Sans Mono";
2031
2136
  const out = [];
2032
2137
 
2033
- out.push(bookTypstPreamble());
2138
+ out.push(bookTypstPreamble(runs, { serif, sans, mono }));
2034
2139
  out.push(indexPreamble({ seeAlso: index.foreign !== undefined }));
2035
2140
  out.push("");
2036
2141
  out.push(`#set document(title: "${escapeTypstString(title)}")`);
@@ -209,13 +209,69 @@ export function resolveReckoningMarkers(index, daysPerYear) {
209
209
  return { eras, markers, findings };
210
210
  }
211
211
 
212
+ /**
213
+ * The key a note's resolved calendar is filed under: its type and shortcode.
214
+ *
215
+ * @param {object|undefined} fm - A note's frontmatter or record.
216
+ * @returns {string|undefined} The key.
217
+ */
218
+ export function noteCalendarKey(fm) {
219
+ return fm?.type && fm?.shortcode ? `${fm.type}\u0000${fm.shortcode}` : undefined;
220
+ }
221
+
222
+ /**
223
+ * A record's `resolvedCalendar` as its canonical Address string: an Address
224
+ * tuple, as a record in memory carries it, or the string an encoded record
225
+ * carries.
226
+ *
227
+ * @param {unknown} value - The value.
228
+ * @returns {string|undefined} The Address, or `undefined` for none.
229
+ */
230
+ export function calendarAddress(value) {
231
+ if (typeof value === "string") return value || undefined;
232
+ return value && typeof value === "object" && "shortcode" in value ?
233
+ renderAddress(/** @type {any} */ (value))
234
+ : undefined;
235
+ }
236
+
237
+ /**
238
+ * Each note's resolved calendar, by {@link noteCalendarKey}, from the
239
+ * `resolvedCalendar` the content index writes on a record.
240
+ *
241
+ * @param {Iterable<object>|undefined} notes - Index records, or
242
+ * `{ fm, resolvedCalendar }` entries.
243
+ * @returns {Map<string, string>} Note key to the calendar's canonical Address.
244
+ */
245
+ export function noteCalendarMap(notes) {
246
+ const out = new Map();
247
+ for (const note of notes ?? []) {
248
+ const calendar = calendarAddress(note?.resolvedCalendar);
249
+ if (!calendar) continue;
250
+ const key = noteCalendarKey(note?.fm ?? note?.frontmatter ?? note);
251
+ if (key && !out.has(key)) out.set(key, calendar);
252
+ }
253
+ return out;
254
+ }
255
+
212
256
  /** One resolved date context per corpus index. */
213
257
  const contexts = new WeakMap();
214
258
 
215
- /** Read the world's year length and resolve every calendar era in one corpus. */
259
+ /**
260
+ * Read the world's year length and resolve every calendar era in one corpus.
261
+ *
262
+ * The context also carries `noteCalendars`, each note's resolved calendar by
263
+ * note, read from the `resolvedCalendar` the content index writes on a record;
264
+ * {@link module:engine/note-calendars.noteCalendar} reads it.
265
+ */
216
266
  export function reckoningContext(index) {
217
267
  if (!index || typeof index !== "object")
218
- return { eras: new Map(), markers: new Map(), daysPerYear: undefined, findings: [] };
268
+ return {
269
+ eras: new Map(),
270
+ markers: new Map(),
271
+ daysPerYear: undefined,
272
+ findings: [],
273
+ noteCalendars: new Map(),
274
+ };
219
275
  const cached = contexts.get(index);
220
276
  if (cached) return cached;
221
277
  const world = (index.notes ?? []).find((note) => {
@@ -224,7 +280,11 @@ export function reckoningContext(index) {
224
280
  });
225
281
  const fm = world?.fm ?? world?.frontmatter ?? world;
226
282
  const daysPerYear = fm?.data?.year?.days;
227
- const context = { ...resolveReckoningMarkers(index, daysPerYear), daysPerYear };
283
+ const context = {
284
+ ...resolveReckoningMarkers(index, daysPerYear),
285
+ daysPerYear,
286
+ noteCalendars: noteCalendarMap(index.notes),
287
+ };
228
288
  contexts.set(index, context);
229
289
  return context;
230
290
  }
@@ -0,0 +1,119 @@
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
+ * Spans, divs and poetry fences marked with `lang=`, `font=` or `map=`, projected
16
+ * from own-note index records into the SQL `runs` relation. Every value is a
17
+ * column value and none is a key, so two notes writing one tag in different
18
+ * case are two rows. Documentation records and assets contribute no rows.
19
+ *
20
+ * @module
21
+ */
22
+
23
+ import { canonicalKey, readCanonicalKey } from "./address.mjs";
24
+ import { encodeAddresses } from "./address-values.mjs";
25
+ import { isNoteRecord } from "./index-records.mjs";
26
+ import { scanRuns } from "./content-scripts.mjs";
27
+
28
+ /** Every column of the `runs` relation, in order. */
29
+ export const RUN_COLUMNS = Object.freeze([
30
+ "owner",
31
+ "kind",
32
+ "lang",
33
+ "tag",
34
+ "font",
35
+ "map",
36
+ "line",
37
+ "column",
38
+ ]);
39
+
40
+ /**
41
+ * One run as an index record carries it.
42
+ *
43
+ * @typedef {object} RecordRun
44
+ * @property {"span"|"block"|"poetry"} kind - The construct.
45
+ * @property {string|null} lang - The language it is set in, as written: its
46
+ * own `lang=`, else the one the div or fence around it states.
47
+ * @property {string|null} tag - The BCP 47 tag it resolves to, or null.
48
+ * @property {string|null} font - Its font, as written and inherited the same way.
49
+ * @property {string|null} map - Its map, as written and inherited the same way.
50
+ * @property {number} line - 1-based file line of its opening.
51
+ * @property {number} column - 1-based UTF-16 file column of its opening.
52
+ */
53
+
54
+ /**
55
+ * @typedef {RecordRun & {owner: string|null}} RunRow
56
+ */
57
+
58
+ /**
59
+ * A note body's runs, at file positions, as an index record carries them.
60
+ *
61
+ * @param {string} body - The note's body.
62
+ * @param {object} options - Options.
63
+ * @param {import("./content-scripts.mjs").RunContext} [options.context] - The
64
+ * configured languages, which give a declared name its tag.
65
+ * @param {number} [options.bodyLine] - The 1-based file line the body starts on.
66
+ * @param {number} [options.bodyColumn] - The 1-based column of its first character.
67
+ * @param {import("./content-session.mjs").ContentSyntax} [options.syntax] -
68
+ * Session scanner projections.
69
+ * @returns {RecordRun[]} One entry per run, in source order.
70
+ */
71
+ export function recordRuns(body, { context, bodyLine = 1, bodyColumn = 1, syntax } = {}) {
72
+ return scanRuns(body, context, syntax).runs.map((run) => ({
73
+ kind: run.kind,
74
+ lang: run.lang ?? null,
75
+ tag: run.tag ?? null,
76
+ font: run.font ?? null,
77
+ map: run.map ?? null,
78
+ line: bodyLine + run.line - 1,
79
+ column: run.column + (run.line === 1 ? bodyColumn - 1 : 0),
80
+ }));
81
+ }
82
+
83
+ /**
84
+ * The containing page's NOTE identity.
85
+ *
86
+ * @param {Record<string, any>} record - An own-note index record.
87
+ * @returns {string|null} The page Address, or null for an unaddressable record.
88
+ */
89
+ export function ownerOf(record) {
90
+ const parts = readCanonicalKey(encodeAddresses(record.address?.canonical));
91
+ return parts ? canonicalKey(parts.package, "note", parts.type, parts.shortcode) : null;
92
+ }
93
+
94
+ /**
95
+ * Project recorded runs without reading sources or changing records.
96
+ *
97
+ * @param {Iterable<Record<string, any>>} records - Index records, already
98
+ * narrowed to the audience.
99
+ * @returns {RunRow[]} One row per run, in record then source order.
100
+ */
101
+ export function runsRows(records) {
102
+ const rows = [];
103
+ for (const record of records) {
104
+ if (!record || !isNoteRecord(record) || !Array.isArray(record.runs)) continue;
105
+ const owner = ownerOf(record);
106
+ for (const run of record.runs)
107
+ rows.push({
108
+ owner,
109
+ kind: run.kind,
110
+ lang: run.lang ?? null,
111
+ tag: run.tag ?? null,
112
+ font: run.font ?? null,
113
+ map: run.map ?? null,
114
+ line: run.line,
115
+ column: run.column,
116
+ });
117
+ }
118
+ return rows;
119
+ }