@heroiclands/package-build 23.1.0 → 23.2.0

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 (248) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/bin/content-commands.mjs +68 -149
  3. package/bin/package-build.mjs +56 -10
  4. package/ci/check-banners.mjs +42 -6
  5. package/ci/confirm-npm-version.mjs +135 -0
  6. package/ci/content-format-reference.mjs +14 -5
  7. package/config.mjs +1 -0
  8. package/container.mjs +185 -4
  9. package/content-config.mjs +333 -210
  10. package/deploy.mjs +116 -10
  11. package/docs/api.md +238 -246
  12. package/docs/authoring/assets.md +11 -5
  13. package/docs/authoring/frontmatter.md +1 -1
  14. package/docs/authoring/links-and-markup.md +87 -0
  15. package/docs/commands.md +138 -99
  16. package/docs/configuration.md +277 -125
  17. package/docs/content-format.md +2 -0
  18. package/docs/diagnostics.md +48 -13
  19. package/docs/guides/book.md +341 -21
  20. package/docs/guides/e2e.md +13 -2
  21. package/docs/guides/icons.md +14 -4
  22. package/docs/guides/module-project.md +18 -8
  23. package/docs/guides/release-and-deploy.md +54 -6
  24. package/docs/guides/site.md +5 -2
  25. package/docs/project-setup.md +30 -10
  26. package/docs/reference/format-details.md +373 -170
  27. package/docs/reference/note-types.md +128 -109
  28. package/docs/reference/theme-pages.md +31 -6
  29. package/e2e.mjs +140 -28
  30. package/engine/actor-compiler.mjs +22 -31
  31. package/engine/address-charset.mjs +4 -17
  32. package/engine/address-fields.mjs +1 -1
  33. package/engine/address-render.mjs +12 -1
  34. package/engine/address-values.mjs +6 -0
  35. package/engine/address.mjs +46 -33
  36. package/engine/art-fields.mjs +30 -5
  37. package/engine/art-slots.mjs +10 -1
  38. package/engine/asset-bindings.mjs +12 -1
  39. package/engine/base-compiler.mjs +34 -65
  40. package/engine/being-age.mjs +2 -7
  41. package/engine/being-measurements.mjs +12 -1
  42. package/engine/calendar-artifacts.mjs +6 -0
  43. package/engine/calendar-choice.mjs +12 -1
  44. package/engine/calendar-format.mjs +12 -1
  45. package/engine/calendar-human.mjs +12 -1
  46. package/engine/compile-corpus.mjs +3 -1
  47. package/engine/content-address.mjs +9 -12
  48. package/engine/content-alerts.mjs +13 -1
  49. package/engine/content-blocks.mjs +14 -3
  50. package/engine/content-embeds.mjs +11 -3
  51. package/engine/content-figures.mjs +9 -0
  52. package/engine/content-footnotes.mjs +12 -1
  53. package/engine/content-format-check.mjs +11 -262
  54. package/engine/content-format.yaml +5 -2
  55. package/engine/content-images.mjs +3 -0
  56. package/engine/content-index.mjs +443 -20
  57. package/engine/content-links.mjs +29 -35
  58. package/engine/content-lint.mjs +2 -3
  59. package/engine/content-poetry.mjs +12 -1
  60. package/engine/content-slug.mjs +2 -14
  61. package/engine/content-spans.mjs +13 -1
  62. package/engine/content-tables.mjs +12 -1
  63. package/engine/culture-choice.mjs +210 -13
  64. package/engine/data-keys.mjs +19 -16
  65. package/engine/date-conversion.mjs +12 -1
  66. package/engine/declaration-check.mjs +9 -0
  67. package/engine/derived-fields.mjs +6 -7
  68. package/engine/diagnostics.mjs +34 -4
  69. package/engine/docs-checks.mjs +9 -0
  70. package/engine/document-subtypes.mjs +47 -34
  71. package/engine/event-views.mjs +139 -14
  72. package/engine/exported-scene.mjs +92 -28
  73. package/engine/extension-attributes.mjs +12 -1
  74. package/engine/field-spec.mjs +11 -102
  75. package/engine/folder-notes.mjs +0 -64
  76. package/engine/foreign-catalog.mjs +2 -1
  77. package/engine/format-generated.mjs +9 -0
  78. package/engine/foundry-entries.mjs +10 -28
  79. package/engine/frontmatter-lint.mjs +83 -392
  80. package/engine/frontmatter.mjs +20 -70
  81. package/engine/generate.mjs +10 -15
  82. package/engine/heading-attributes.mjs +12 -1
  83. package/engine/helpers.mjs +41 -102
  84. package/engine/holdings.mjs +1 -1
  85. package/engine/homepage.mjs +3 -10
  86. package/engine/ids.mjs +1 -167
  87. package/engine/index-records.mjs +6 -4
  88. package/engine/index.mjs +0 -3
  89. package/engine/infobox-render.mjs +15 -3
  90. package/engine/infobox.mjs +13 -14
  91. package/engine/item-compiler.mjs +4 -10
  92. package/engine/item-docs.mjs +3 -5
  93. package/engine/item-registry.mjs +2 -10
  94. package/engine/journals.mjs +1 -5
  95. package/engine/literature-notes.mjs +4 -12
  96. package/engine/macros.mjs +1 -2
  97. package/engine/map-graphviz.mjs +12 -1
  98. package/engine/markdown-expressions.mjs +12 -1
  99. package/engine/markdownlint-run.mjs +567 -0
  100. package/engine/metadata-index.mjs +42 -12
  101. package/engine/note-addresses.mjs +143 -35
  102. package/engine/note-claims.mjs +14 -28
  103. package/engine/note-dates.mjs +3 -3
  104. package/engine/note-frontmatter.mjs +6 -0
  105. package/engine/note-ids.mjs +8 -14
  106. package/engine/note-package.mjs +8 -25
  107. package/engine/note-state.mjs +1 -3
  108. package/engine/note-vocabulary.mjs +148 -84
  109. package/engine/number-words.mjs +12 -1
  110. package/engine/pack-config.mjs +15 -25
  111. package/engine/pack-router.mjs +10 -16
  112. package/engine/pathnames.mjs +8 -22
  113. package/engine/pdf-build.mjs +773 -121
  114. package/engine/pdf-coverage.mjs +188 -0
  115. package/engine/pdf-index.mjs +54 -7
  116. package/engine/pdf-pages.mjs +247 -0
  117. package/engine/pdf-render.mjs +114 -4
  118. package/engine/pdf-toc.mjs +29 -32
  119. package/engine/project-init.mjs +46 -6
  120. package/engine/prose-config.mjs +1 -1
  121. package/engine/prose-lint.mjs +52 -88
  122. package/engine/readability-lint.mjs +9 -0
  123. package/engine/reckoning-markers.mjs +12 -1
  124. package/engine/reference-targets.mjs +7 -0
  125. package/engine/runtime-only-fields.mjs +5 -8
  126. package/engine/scene-levels.mjs +2 -2
  127. package/engine/scenes.mjs +1 -1
  128. package/engine/shortcode-references.mjs +7 -0
  129. package/engine/site-build.mjs +30 -16
  130. package/engine/site-index.mjs +10 -16
  131. package/engine/site-preview.mjs +6 -0
  132. package/engine/site-root.mjs +8 -1
  133. package/engine/social-tie-terms.mjs +12 -1
  134. package/engine/social-ties.mjs +3 -0
  135. package/engine/sql-tables.mjs +56 -20
  136. package/engine/standing-terms.mjs +11 -1
  137. package/engine/standings.mjs +11 -27
  138. package/engine/system-block.mjs +39 -141
  139. package/engine/system-vocabulary.mjs +6 -0
  140. package/engine/walk-files.mjs +69 -0
  141. package/engine/web-wikilinks.mjs +23 -3
  142. package/engine/wikilink-syntax.mjs +32 -21
  143. package/engine/wikilinks.mjs +27 -16
  144. package/engine/yaml-lint.mjs +7 -4
  145. package/githooks/attribution.mjs +8 -0
  146. package/githooks/commit-message.mjs +9 -0
  147. package/hm3/actors.mjs +9 -48
  148. package/hm3/default-item-art.mjs +9 -9
  149. package/hm3/item-fields.mjs +2 -9
  150. package/hm3/template-priority.mjs +6 -7
  151. package/hugo-theme/data/banner-kinds.yaml +78 -0
  152. package/hugo-theme/data/banners.yaml +2 -3
  153. package/hugo-theme/layouts/404.html +3 -3
  154. package/hugo-theme/layouts/partials/banner-url.html +31 -7
  155. package/hugo-theme/layouts/partials/hero-banner.html +13 -14
  156. package/hugo-theme/layouts/partials/related.html +1 -1
  157. package/hugo-theme/layouts/partials/warn-missing-banner.html +1 -1
  158. package/hugo-theme/static/css/style.css +1 -1
  159. package/manifest.mjs +11 -4
  160. package/markdownlint-config.mjs +1 -1
  161. package/package.json +6 -3
  162. package/release.mjs +83 -18
  163. package/sohl/actors.mjs +16 -27
  164. package/sohl/default-item-art.mjs +3 -3
  165. package/sohl/document-subtypes.mjs +1 -16
  166. package/sohl/infobox.mjs +2 -3
  167. package/sohl/item-builders.mjs +5 -8
  168. package/sohl/item-fields.mjs +5 -35
  169. package/sohl/items.mjs +0 -4
  170. package/sohl/kb-passes.mjs +3 -3
  171. package/types/container.d.mts +116 -5
  172. package/types/content-config.d.mts +83 -35
  173. package/types/deploy.d.mts +42 -6
  174. package/types/e2e.d.mts +35 -3
  175. package/types/engine/address-charset.d.mts +4 -17
  176. package/types/engine/address.d.mts +17 -8
  177. package/types/engine/art-fields.d.mts +7 -4
  178. package/types/engine/art-slots.d.mts +6 -0
  179. package/types/engine/base-compiler.d.mts +15 -17
  180. package/types/engine/content-address.d.mts +9 -12
  181. package/types/engine/content-embeds.d.mts +4 -2
  182. package/types/engine/content-format-check.d.mts +0 -64
  183. package/types/engine/content-index.d.mts +82 -3
  184. package/types/engine/content-links.d.mts +2 -4
  185. package/types/engine/culture-choice.d.mts +51 -8
  186. package/types/engine/data-keys.d.mts +2 -2
  187. package/types/engine/derived-fields.d.mts +1 -2
  188. package/types/engine/diagnostics.d.mts +27 -4
  189. package/types/engine/document-subtypes.d.mts +12 -31
  190. package/types/engine/event-views.d.mts +14 -2
  191. package/types/engine/exported-scene.d.mts +3 -2
  192. package/types/engine/field-spec.d.mts +13 -122
  193. package/types/engine/folder-notes.d.mts +0 -39
  194. package/types/engine/foreign-catalog.d.mts +2 -1
  195. package/types/engine/foundry-entries.d.mts +4 -5
  196. package/types/engine/frontmatter-lint.d.mts +2 -2
  197. package/types/engine/frontmatter.d.mts +19 -45
  198. package/types/engine/generate.d.mts +1 -1
  199. package/types/engine/helpers.d.mts +25 -42
  200. package/types/engine/homepage.d.mts +3 -10
  201. package/types/engine/ids.d.mts +0 -114
  202. package/types/engine/index-records.d.mts +6 -4
  203. package/types/engine/index.d.mts +0 -1
  204. package/types/engine/infobox-render.d.mts +7 -1
  205. package/types/engine/infobox.d.mts +3 -2
  206. package/types/engine/item-docs.d.mts +1 -1
  207. package/types/engine/item-registry.d.mts +2 -4
  208. package/types/engine/literature-notes.d.mts +1 -1
  209. package/types/engine/markdownlint-run.d.mts +40 -0
  210. package/types/engine/metadata-index.d.mts +9 -1
  211. package/types/engine/note-addresses.d.mts +27 -2
  212. package/types/engine/note-claims.d.mts +3 -5
  213. package/types/engine/note-ids.d.mts +2 -4
  214. package/types/engine/note-package.d.mts +1 -1
  215. package/types/engine/note-state.d.mts +1 -3
  216. package/types/engine/note-vocabulary.d.mts +28 -21
  217. package/types/engine/pack-config.d.mts +2 -6
  218. package/types/engine/pack-router.d.mts +4 -4
  219. package/types/engine/pathnames.d.mts +3 -3
  220. package/types/engine/pdf-build.d.mts +98 -12
  221. package/types/engine/pdf-coverage.d.mts +60 -0
  222. package/types/engine/pdf-index.d.mts +34 -5
  223. package/types/engine/pdf-pages.d.mts +143 -0
  224. package/types/engine/pdf-render.d.mts +45 -1
  225. package/types/engine/pdf-toc.d.mts +12 -14
  226. package/types/engine/prose-config.d.mts +1 -1
  227. package/types/engine/prose-lint.d.mts +11 -10
  228. package/types/engine/runtime-only-fields.d.mts +2 -2
  229. package/types/engine/site-build.d.mts +9 -5
  230. package/types/engine/site-index.d.mts +4 -5
  231. package/types/engine/site-root.d.mts +1 -1
  232. package/types/engine/sql-tables.d.mts +31 -2
  233. package/types/engine/standing-terms.d.mts +2 -1
  234. package/types/engine/standings.d.mts +7 -9
  235. package/types/engine/system-block.d.mts +10 -44
  236. package/types/engine/walk-files.d.mts +15 -0
  237. package/types/engine/wikilink-syntax.d.mts +18 -19
  238. package/types/engine/yaml-lint.d.mts +5 -4
  239. package/types/hm3/actors.d.mts +1 -2
  240. package/types/hm3/item-fields.d.mts +2 -4
  241. package/types/hm3/template-priority.d.mts +6 -7
  242. package/types/manifest.d.mts +3 -2
  243. package/types/release.d.mts +12 -7
  244. package/types/sohl/actors.d.mts +2 -1
  245. package/types/sohl/document-subtypes.d.mts +1 -2
  246. package/types/sohl/item-fields.d.mts +3 -7
  247. package/engine/retired-fields.mjs +0 -631
  248. package/types/engine/retired-fields.d.mts +0 -354
@@ -327,7 +327,7 @@ function offsetPosition(source, offset, opts = {}) {
327
327
  * @param {object} opts - As {@link markdownToTypst} takes.
328
328
  * @param {string} original - `source` before its definitions were blanked —
329
329
  * {@link unusedFootnoteDefinitions} locates a definition by searching for
330
- * its own text, which `source` no longer carries.
330
+ * its own text, which `source` does not carry.
331
331
  * @returns {void}
332
332
  */
333
333
  function reportUnrenderable(source, definitions, opts, original) {
@@ -475,6 +475,9 @@ function reportUnrenderable(source, definitions, opts, original) {
475
475
  * at. Every link the book sets as a URL is resolved against it, because a
476
476
  * reader holding a PDF has no page to resolve a path against — see
477
477
  * {@link renderLink}.
478
+ * @param {{get: (key: string) => ({title: string, page: string|number}|undefined)}} [opts.elsewhere] -
479
+ * The notes another book is home to, by the slug and addresses a link reaches
480
+ * them through — see {@link crossBookReference}.
478
481
  * @param {boolean} [opts.generated] - Whether the Markdown is a note's
479
482
  * generated sections rather than its author's text — see
480
483
  * {@link module:engine/generated-sections}. Their links follow the rules
@@ -518,6 +521,7 @@ export function markdownToTypst(markdown, opts = {}) {
518
521
  headingOffset,
519
522
  anchorPrefix,
520
523
  url: opts.url,
524
+ elsewhere: opts.elsewhere,
521
525
  indexMentions: opts.indexMentions === true,
522
526
  generated: Boolean(opts.generated),
523
527
  seen: new Map(),
@@ -1361,6 +1365,10 @@ function pageOfLabel(label) {
1361
1365
  * usable; one whose target the book leaves out is its words alone, with no
1362
1366
  * link and no finding.
1363
1367
  *
1368
+ * **A link to a note another book is home to** is not a link: a PDF link cannot
1369
+ * leave its file. It is set as {@link crossBookReference}, in generated text
1370
+ * and authored prose alike, and adds nothing to the index.
1371
+ *
1364
1372
  * The address slug is read from the tail of the URL, which is where every
1365
1373
  * address this toolchain publishes puts it — `…/<type>-<shortcode>/`. A
1366
1374
  * cross-package URL resolved through the link manifest is on another package's
@@ -1395,10 +1403,33 @@ function renderLink(href, inner, ctx) {
1395
1403
  const marker = ctx.indexMentions && slug ? indexMarker(slug) : "";
1396
1404
  return `${link}${marker}`;
1397
1405
  }
1406
+ const away = ctx.elsewhere?.get(slug);
1407
+ if (away) {
1408
+ // A mention in authored prose is what lists the note, under
1409
+ // `pdf.indexSeeAlso`.
1410
+ const marker = ctx.indexMentions && ctx.elsewhere.seeAlso ? indexMarker(away.slug) : "";
1411
+ return `${crossBookReference(inner, away)}${marker}`;
1412
+ }
1398
1413
  if (!url || ctx.generated) return inner;
1399
1414
  return `#link("${escapeTypstString(absolutePageUrl(url, ctx.url))}")[${inner}]`;
1400
1415
  }
1401
1416
 
1417
+ /**
1418
+ * A reference to a note another book prints, as Typst: the words the link
1419
+ * carried, then the book and the page, `Name (_Book title_, p. 12)`.
1420
+ *
1421
+ * The space before the page number does not break, so a number never starts a
1422
+ * line of its own.
1423
+ *
1424
+ * @param {string} inner - The already-rendered link text.
1425
+ * @param {{title: string, page: string|number}} away - The home book's title
1426
+ * and the page the entry starts on.
1427
+ * @returns {string} Typst markup.
1428
+ */
1429
+ export function crossBookReference(inner, away) {
1430
+ return `${inner} (#emph[${escapeTypst(away.title)}], p.~${away.page})`;
1431
+ }
1432
+
1402
1433
  /**
1403
1434
  * A width in inches, as a Typst length literal.
1404
1435
  *
@@ -1606,6 +1637,27 @@ export function bookTypstPreamble() {
1606
1637
  '#let book-accent = rgb("#7c3b1e")',
1607
1638
  '#let book-head = rgb("#5e2b14")',
1608
1639
  '#let book-faint = rgb("#6b6357")',
1640
+ // The cover is a page of its own: the picture fills it, cropped to fit as
1641
+ // a section banner is, and the title and subtitle sit in white over a
1642
+ // translucent dark band across the upper third so they read over any
1643
+ // art. It carries no running foot or number, and counts as page 1.
1644
+ "#let book-cover(path, title, subtitle) = page(margin: 0pt, fill: book-ink, footer: none)[\n" +
1645
+ ' #place(top + left, image(path, width: 100%, height: 100%, fit: "cover"))\n' +
1646
+ " #place(top + left, rect(width: 100%, height: 33.4%, fill: rgb(10, 8, 6, 170)))\n" +
1647
+ " #place(top + left, block(width: 100%, height: 33.4%)[\n" +
1648
+ " #align(center + horizon)[\n" +
1649
+ " #block(width: 80%)[\n" +
1650
+ " #set par(justify: false, first-line-indent: 0em)\n" +
1651
+ " #set text(fill: white, hyphenate: false)\n" +
1652
+ ' #text(size: 34pt, weight: "bold")[#title]\n' +
1653
+ ' #if subtitle != "" [\n' +
1654
+ " #v(0.6em)\n" +
1655
+ " #text(size: 16pt)[#subtitle]\n" +
1656
+ " ]\n" +
1657
+ " ]\n" +
1658
+ " ]\n" +
1659
+ " ])\n" +
1660
+ "]",
1609
1661
  "#let book-ornament = box(baseline: 1pt, " +
1610
1662
  "rotate(45deg, rect(width: 3pt, height: 3pt, fill: book-accent)))",
1611
1663
  "#let book-footer(name) = context {\n" +
@@ -1755,6 +1807,18 @@ export function bookTypstPreamble() {
1755
1807
  ].join("\n");
1756
1808
  }
1757
1809
 
1810
+ /**
1811
+ * One author as the title page prints it: `Name`, or `Name, Role` with the
1812
+ * role in title case.
1813
+ *
1814
+ * @param {{name: string, role?: string}} person - An entry of `authors`.
1815
+ * @returns {string} The line.
1816
+ */
1817
+ function authorLine({ name, role }) {
1818
+ if (!role) return name;
1819
+ return `${name}, ${role.charAt(0).toUpperCase()}${role.slice(1)}`;
1820
+ }
1821
+
1758
1822
  /**
1759
1823
  * The whole book, as one Typst document.
1760
1824
  *
@@ -1858,6 +1922,17 @@ export function bookTypstPreamble() {
1858
1922
  * plate draws without a picture.
1859
1923
  * @param {Map<string, {path: string, title: string}>} [opts.maps] - An entry
1860
1924
  * anchor to its SVG path and page heading. Each map takes a page after its entry.
1925
+ * @param {string} [opts.coverImage] - The staged cover picture, relative to the
1926
+ * `.typ`. When given the book opens on a full-page cover, page 1, with the
1927
+ * title and subtitle over it; without one the book opens on the title page.
1928
+ * @param {Array<{name: string, role?: string}>} [opts.authors] - Printed on the
1929
+ * title page one per line, as `Name` or `Name, Role`.
1930
+ * @param {string} [opts.copyright] - The title page foot's first line.
1931
+ * @param {string} [opts.license] - The title page foot's remaining lines; a
1932
+ * line break in it starts a new line.
1933
+ * @param {object} [opts.index] - As {@link module:engine/pdf-index.indexTerms}:
1934
+ * `owns` limits the index to the notes this book is home to, and `foreign`
1935
+ * (present only under `pdf.indexSeeAlso`) lists notes another book prints.
1861
1936
  * @returns {string} A complete `.typ` document.
1862
1937
  */
1863
1938
  export function renderBook({
@@ -1871,6 +1946,11 @@ export function renderBook({
1871
1946
  preamble = "",
1872
1947
  banners = new Map(),
1873
1948
  maps = new Map(),
1949
+ coverImage = "",
1950
+ authors = [],
1951
+ copyright = "",
1952
+ license = "",
1953
+ index = {},
1874
1954
  } = {}) {
1875
1955
  // The two halves of one superfamily, chosen together: matched metrics are
1876
1956
  // most of why the sans can carry every heading over a serif body without
@@ -1883,7 +1963,7 @@ export function renderBook({
1883
1963
  const out = [];
1884
1964
 
1885
1965
  out.push(bookTypstPreamble());
1886
- out.push(indexPreamble());
1966
+ out.push(indexPreamble({ seeAlso: index.foreign !== undefined }));
1887
1967
  out.push("");
1888
1968
  out.push(`#set document(title: "${escapeTypstString(title)}")`);
1889
1969
  // Cream stock and dark ink rather than a dark screen theme: 2,000 pages of
@@ -1930,6 +2010,14 @@ export function renderBook({
1930
2010
  out.push("");
1931
2011
  }
1932
2012
 
2013
+ // The cover, when the book has one, is page 1.
2014
+ if (coverImage) {
2015
+ out.push(
2016
+ `#book-cover("${escapeTypstString(coverImage)}", ` +
2017
+ `"${escapeTypstString(title)}", "${escapeTypstString(subtitle)}")`,
2018
+ );
2019
+ }
2020
+
1933
2021
  // Title page.
1934
2022
  out.push("#align(center + horizon)[");
1935
2023
  out.push(` #text(size: 30pt, weight: "bold")[${escapeTypst(title)}]`);
@@ -1941,7 +2029,29 @@ export function renderBook({
1941
2029
  out.push(" #v(2em)");
1942
2030
  out.push(` #text(size: 10pt)[${escapeTypst(version)}]`);
1943
2031
  }
2032
+ if (authors.length) {
2033
+ out.push(" #v(2.5em)");
2034
+ out.push(" #set par(first-line-indent: 0em, justify: false)");
2035
+ out.push(
2036
+ " #text(size: 12pt)[" +
2037
+ authors.map((person) => escapeTypst(authorLine(person))).join(" \\\n ") +
2038
+ "]",
2039
+ );
2040
+ }
1944
2041
  out.push("]");
2042
+ const foot = [copyright, ...String(license).split(/\r?\n/)]
2043
+ .map((line) => line.trim())
2044
+ .filter(Boolean);
2045
+ if (foot.length) {
2046
+ out.push("#place(bottom + center)[");
2047
+ out.push(" #set par(first-line-indent: 0em, justify: false)");
2048
+ out.push(
2049
+ " #block(width: 80%)[#text(size: 8.5pt, fill: book-faint)[" +
2050
+ foot.map((line) => escapeTypst(line)).join(" \\\n ") +
2051
+ "]]",
2052
+ );
2053
+ out.push("]");
2054
+ }
1945
2055
  out.push("#pagebreak()");
1946
2056
  out.push("");
1947
2057
 
@@ -2031,8 +2141,8 @@ export function renderBook({
2031
2141
  }
2032
2142
  }
2033
2143
 
2034
- const index = indexChapter(plan?.entries, title);
2035
- if (index) out.push(index);
2144
+ const chapter = indexChapter(plan?.entries, title, index);
2145
+ if (chapter) out.push(chapter);
2036
2146
 
2037
2147
  return `${out.join("\n")}\n`;
2038
2148
  }
@@ -22,9 +22,10 @@
22
22
  *
23
23
  * Three consequences follow, and they are behaviour rather than oversights:
24
24
  *
25
- * - **A note no clause selects is not in the book.** A project decides what its
26
- * own volume carries, so an omission is an editorial act and this pass has no
27
- * standing to call it a defect.
25
+ * - **A note no clause selects is not in the book.** This pass has no standing
26
+ * to call that a defect: whether a note belongs in some other book of the
27
+ * package is a question about every book at once, which the coverage report
28
+ * ({@link module:engine/pdf-coverage}) answers.
28
29
  * - **A note several clauses select appears several times.** A reference work
29
30
  * may legitimately carry an entry under more than one heading, so each
30
31
  * occurrence is its own page, its own outline node and its own anchor — and
@@ -169,20 +170,25 @@ const QUALIFIED_TABLE = /\bfrom\s+["'`]?[A-Za-z_][\w-]*["'`]?\s*\./i;
169
170
  * @param {object} [opts] - Options.
170
171
  * @param {string} [opts.text] - The file's source, for finding positions. Every
171
172
  * finding without one is still reported, just without a locator.
173
+ * @param {Array<string|number>} [opts.at] - Where `raw` sits in `text`: `[]`
174
+ * for a tree file, `["pdf", "books", i]` for a tree written inline in the
175
+ * configuration. Every key path — and so every position, here and in
176
+ * {@link runTreeFilters} — starts with it.
172
177
  * @returns {{nodes: object[], findings: object[]}} The flattened tree and what
173
178
  * was wrong with it.
174
179
  */
175
- export function parseDocumentTree(raw, { text } = {}) {
180
+ export function parseDocumentTree(raw, { text, at = [] } = {}) {
176
181
  const findings = [];
177
182
  const nodes = [];
178
183
  if (!raw || typeof raw !== "object" || !Array.isArray(raw.contents)) {
179
184
  findings.push({
180
185
  severity: "error",
181
186
  message: "the document tree must be a mapping with a `contents:` list",
187
+ ...(at.length ? position(text, at) : {}),
182
188
  });
183
189
  return { nodes, findings };
184
190
  }
185
- walkSections(raw.contents, ["contents"], [], {}, { nodes, findings, text });
191
+ walkSections(raw.contents, [...at, "contents"], [], {}, { nodes, findings, text });
186
192
  return { nodes, findings };
187
193
  }
188
194
 
@@ -199,9 +205,11 @@ export function parseDocumentTree(raw, { text } = {}) {
199
205
  * @param {string[]} trail - Section titles above this level.
200
206
  * @param {object} inherited - Presentation from ancestors.
201
207
  * @param {object} ctx - `{ nodes, findings, text }`, accumulated.
208
+ * @param {Array<string|number>} [inheritedPageAt] - Where the `page:` mapping
209
+ * the ancestors left in force is declared, when one is.
202
210
  * @returns {void}
203
211
  */
204
- function walkSections(contents, keyPath, trail, inherited, ctx) {
212
+ function walkSections(contents, keyPath, trail, inherited, ctx, inheritedPageAt) {
205
213
  contents.forEach((entry, i) => {
206
214
  const at = [...keyPath, i];
207
215
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
@@ -241,6 +249,9 @@ function walkSections(contents, keyPath, trail, inherited, ctx) {
241
249
  for (const key of PRESENTATION_KEYS) {
242
250
  if (entry[key] !== undefined) presentation[key] = entry[key];
243
251
  }
252
+ // A section's own `page:` replaces the inherited one whole, so the
253
+ // mapping in force is declared on the nearest section that wrote one.
254
+ const pageAt = entry.page !== undefined ? [...at, "page"] : inheritedPageAt;
244
255
 
245
256
  const here = [...trail, title];
246
257
  const list = Array.isArray(entry.contents) ? entry.contents : [];
@@ -256,10 +267,11 @@ function walkSections(contents, keyPath, trail, inherited, ctx) {
256
267
  depth: here.length,
257
268
  keyPath: at,
258
269
  presentation,
270
+ pageAt,
259
271
  items: collectItems(list, [...at, "contents"], here, ctx),
260
272
  });
261
273
 
262
- walkSections(list, [...at, "contents"], here, presentation, ctx);
274
+ walkSections(list, [...at, "contents"], here, presentation, ctx, pageAt);
263
275
  });
264
276
  }
265
277
 
@@ -357,14 +369,10 @@ function finding(ctx, keyPath, message, opts) {
357
369
  * something that is not a note, and unable to pick up the `doc<type>`
358
370
  * documentation rows that ride the same index as the notes they document.
359
371
  *
360
- * **A filter that selects nothing is an error**, and the distinction that makes
361
- * that consistent is worth stating. A *note* no clause selects is expected: the
362
- * book is a selection and a project decides what its own volume carries. A
363
- * *clause* that selects no note is not the same thing — a filter is a deliberate
364
- * act, so one matching nothing is either wrong or left over from a structure
365
- * that has moved on, and in both cases the tree should not carry it. Reported
366
- * with the section's name and the filter's position, so the choice between
367
- * fixing it and deleting it is the author's.
372
+ * **A filter that selects nothing is not a finding.** A section whose clauses
373
+ * select nothing is left out of the plan, and a book whose clauses select
374
+ * nothing at all is refused by the build. Notes that a mistyped clause fails to
375
+ * take are reported by the coverage check, which names each one.
368
376
  *
369
377
  * @param {object[]} nodes - From {@link parseDocumentTree}.
370
378
  * @param {{query: (sql: string) => Promise<{rows: object[]}>}} db - An open
@@ -383,18 +391,7 @@ export async function runTreeFilters(nodes, db, { keep = () => true, text } = {}
383
391
  const where = `\`${node.trail.join(" › ")}\``;
384
392
  try {
385
393
  const { rows } = await db.query(`SELECT * FROM notes WHERE ${item.where}`);
386
- const kept = rows.filter(keep);
387
- selections.set(item.id, kept);
388
- if (!kept.length) {
389
- findings.push({
390
- severity: "error",
391
- message:
392
- `${where}: the filter selected no notes — ` +
393
- `\`${item.where}\` matches nothing in this project, so ` +
394
- `either it is wrong or the section should not be here`,
395
- ...position(text, [...item.keyPath, "filter"]),
396
- });
397
- }
394
+ selections.set(item.id, rows.filter(keep));
398
395
  } catch (err) {
399
396
  findings.push({
400
397
  severity: "error",
@@ -426,11 +423,8 @@ export async function runTreeFilters(nodes, db, { keep = () => true, text } = {}
426
423
  *
427
424
  * **A section with nothing in it does not print**, and emptiness is judged after
428
425
  * its descendants are: a section holding only sections that all resolved to
429
- * nothing is itself empty. In a correct tree this never fires — a filter that
430
- * selects nothing is reported by {@link runTreeFilters} as the error it is — so
431
- * this is the graceful half of that failure rather than a feature: a build whose
432
- * filters are broken still produces a readable document to look at while they
433
- * are fixed. A section holding only prose is not empty; it is a preface.
426
+ * nothing is itself empty. The omission is silent. A section holding only prose
427
+ * is not empty; it is a preface.
434
428
  *
435
429
  * @param {object[]} nodes - From {@link parseDocumentTree}.
436
430
  * @param {object} [opts] - Options.
@@ -502,6 +496,7 @@ function emitSection(node, nodes, ctx) {
502
496
  trail: node.trail,
503
497
  depth: node.depth,
504
498
  presentation: node.presentation,
499
+ pageAt: node.pageAt,
505
500
  anchor: anchorFor(slugify(node.trail.join(" ")) || "section", ctx.seen),
506
501
  });
507
502
 
@@ -521,6 +516,7 @@ function emitSection(node, nodes, ctx) {
521
516
  trail: node.trail,
522
517
  depth: node.depth,
523
518
  presentation: node.presentation,
519
+ pageAt: node.pageAt,
524
520
  anchor: anchorFor(slugify(item.file.replace(/\.md$/i, "")) || "prose", ctx.seen),
525
521
  });
526
522
  contributed++;
@@ -542,6 +538,7 @@ function emitSection(node, nodes, ctx) {
542
538
  trail: node.trail,
543
539
  depth: node.depth,
544
540
  presentation: node.presentation,
541
+ pageAt: node.pageAt,
545
542
  anchor,
546
543
  });
547
544
  contributed++;
@@ -2,6 +2,12 @@
2
2
  * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
3
  * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
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
+ *
5
11
  * SPDX-License-Identifier: GPL-3.0-or-later
6
12
  */
7
13
 
@@ -13,6 +19,7 @@ import YAML from "yaml";
13
19
  import prettier from "prettier";
14
20
  import prettierConfig from "../prettier-config.mjs";
15
21
  import { CONFIG_FILENAMES, configFromData } from "./pack-config.mjs";
22
+ import { positionOfYamlPath } from "./diagnostics.mjs";
16
23
  import { checkLabelRegistry } from "../labels.mjs";
17
24
  import { formatNoteFrontmatter } from "./note-format.mjs";
18
25
 
@@ -128,6 +135,7 @@ function projectConfig(answers) {
128
135
  const config = {
129
136
  contentPackage: answers.contentPackage,
130
137
  packageKind: answers.kind,
138
+ authors: [{ name: answers.author }],
131
139
  };
132
140
  if (answers.kind !== "documentation") {
133
141
  config.compatibility = {
@@ -138,7 +146,19 @@ function projectConfig(answers) {
138
146
  config.packs = [{ name: "journals", label: "Journals", type: "JournalEntry" }];
139
147
  }
140
148
  config.publish = { address: { prefix: "" } };
141
- config.pdf = { title: answers.title, document: "book.yaml", out: "build/pdf" };
149
+ // The stem is the package's own name, so the one book is
150
+ // `<package>-<version>.pdf` rather than `<package>-<id>-<version>.pdf`.
151
+ config.pdf = {
152
+ out: "build/pdf",
153
+ books: [
154
+ {
155
+ id: "book",
156
+ title: answers.title,
157
+ document: "book.yaml",
158
+ out: answers.contentPackage,
159
+ },
160
+ ],
161
+ };
142
162
  config.site = {
143
163
  ...(answers.kind === "documentation" ? { title: answers.title } : {}),
144
164
  assets: "https://cdn.heroiclands.org",
@@ -154,7 +174,6 @@ function projectConfig(answers) {
154
174
  manifest: {
155
175
  title: answers.title,
156
176
  descriptionHtml: answers.description,
157
- authors: [{ name: answers.author }],
158
177
  license: "LICENSE.md",
159
178
  readme: "README.md",
160
179
  languages: [{ lang: "en", name: "English", path: "lang/en.json" }],
@@ -451,10 +470,12 @@ export function checkProject(rootDir) {
451
470
  }
452
471
  }
453
472
  let config;
473
+ let configSource = "";
454
474
  if (names.length === 1 && names[0] !== "package-build.config.mjs") {
455
475
  const file = names[0];
456
476
  try {
457
477
  const source = fs.readFileSync(path.join(root, file), "utf8");
478
+ configSource = source;
458
479
  config = YAML.parse(source);
459
480
  configFromData(config, path.join(root, file));
460
481
  } catch (err) {
@@ -465,10 +486,29 @@ export function checkProject(rootDir) {
465
486
  if (!nonempty(config.site?.assets) || !nonempty(config.site?.description)) {
466
487
  add(names[0], "site.assets and site.description are required for the website");
467
488
  }
468
- if (!nonempty(config.pdf?.title) || !nonempty(config.pdf?.document)) {
469
- add(names[0], "pdf.title and pdf.document are required for the book");
470
- } else if (!fs.existsSync(path.join(root, config.pdf.document))) {
471
- add(config.pdf.document, "the book document tree is missing");
489
+ const books = config.pdf?.books;
490
+ if (!Array.isArray(books) || books.length === 0) {
491
+ add(names[0], "pdf.books is required for the book");
492
+ } else {
493
+ books.forEach((book, i) => {
494
+ for (const [keyPath, file, what] of [
495
+ [["document"], book?.document, "document tree"],
496
+ [["coverImage"], book?.coverImage, "cover image"],
497
+ ...(Array.isArray(book?.front) ?
498
+ book.front.map((entry, j) => [["front", j], entry, "front-matter file"])
499
+ : []),
500
+ ]) {
501
+ if (nonempty(file) && !fs.existsSync(path.join(root, file))) {
502
+ findings.push({
503
+ file: names[0],
504
+ ...positionOfYamlPath(configSource, ["pdf", "books", i, ...keyPath], {
505
+ key: true,
506
+ }),
507
+ message: `book \`${book.id}\`: the ${what} \`${file}\` does not exist`,
508
+ });
509
+ }
510
+ }
511
+ });
472
512
  }
473
513
  if (config.packageKind !== "documentation") {
474
514
  if (!nonempty(config.packageBuild?.manifest?.title)) {
@@ -25,7 +25,7 @@
25
25
  * invokes it.
26
26
  *
27
27
  * **Neither of these is an override.** A consumer that declares its own
28
- * Prettier config or its own `.markdownlint-cli2.jsonc` wins; what ships here
28
+ * Prettier config or its own `.markdownlint-cli2.*` options file wins; what ships here
29
29
  * is the default a repository gets for declaring nothing. Repository *layout*
30
30
  * knowledge — which paths to skip — stays with the repository that has the
31
31
  * layout, in its own ignore files.
@@ -21,12 +21,13 @@
21
21
  * shared configuration *as a default a local one overrides*, and reporting
22
22
  * findings in the one parseable form every check in this package emits.
23
23
  *
24
- * **Neither tool's own file discovery is reimplemented.** Prettier decides what
25
- * it formats and what an ignore file excludes, through `getFileInfo`;
26
- * markdownlint expands its own globs and honours `.gitignore` itself. A second
27
- * implementation of either would drift from the tool it stands in for, and the
28
- * whole point of the command is that `package-build format --check` and a bare
29
- * `prettier --check .` report the same thing.
24
+ * **Prettier's own file discovery is not reimplemented.** Prettier decides what
25
+ * it formats and what an ignore file excludes, through `getFileInfo`; a second
26
+ * implementation would drift from the tool it stands in for, and the whole point
27
+ * of the command is that `package-build format --check` and a bare
28
+ * `prettier --check .` report the same thing. Markdown linting walks the tree
29
+ * with `./markdownlint-run.mjs`, which applies
30
+ * `.gitignore` files and the configured globs itself.
30
31
  *
31
32
  * @module
32
33
  */
@@ -42,17 +43,8 @@ import {
42
43
  sharedPrettierDivergence,
43
44
  sharedPrettierOptionsFor,
44
45
  } from "./prose-config.mjs";
45
-
46
- /**
47
- * Directories never walked, whatever the ignore files say.
48
- *
49
- * `node_modules` is in every consumer's `.gitignore` and would be excluded
50
- * anyway; skipping it in the walk rather than per file is the difference
51
- * between a check that takes a second and one that stats a hundred thousand
52
- * files to be told each is ignored. `.git` is in nobody's `.gitignore`, so it
53
- * has to be named.
54
- */
55
- const NEVER_WALK = Object.freeze(new Set([".git", "node_modules"]));
46
+ import { MarkdownConfigError, runMarkdownlint } from "./markdownlint-run.mjs";
47
+ import { walkFiles } from "./walk-files.mjs";
56
48
 
57
49
  /**
58
50
  * The ignore files Prettier consults, in its own default order.
@@ -101,37 +93,6 @@ const CONVENTION_PROBES = Object.freeze({ code: "index.mjs", markdown: "README.m
101
93
  /** The one line a consumer writes to adopt the shared configuration verbatim. */
102
94
  const SHARED_CONFIG_RE_EXPORT = 'export { default } from "@heroiclands/package-build/prettier";';
103
95
 
104
- /**
105
- * Every file under a root, minus the directories nothing should walk.
106
- *
107
- * @param {string} root - Absolute path to walk.
108
- * @returns {string[]} Absolute file paths, in directory order.
109
- */
110
- function walkFiles(root) {
111
- const out = [];
112
- /** @param {string} dir - Directory to descend into. */
113
- const descend = (dir) => {
114
- let entries;
115
- try {
116
- entries = fs.readdirSync(dir, { withFileTypes: true });
117
- } catch {
118
- // A directory that vanished or cannot be read is not a finding
119
- // about prose; leave it to whatever owns it.
120
- return;
121
- }
122
- for (const entry of entries) {
123
- const abs = path.join(dir, entry.name);
124
- if (entry.isDirectory()) {
125
- if (!NEVER_WALK.has(entry.name)) descend(abs);
126
- } else if (entry.isFile()) {
127
- out.push(abs);
128
- }
129
- }
130
- };
131
- descend(root);
132
- return out;
133
- }
134
-
135
96
  /**
136
97
  * Check — or rewrite — every file Prettier claims, under one root.
137
98
  *
@@ -408,10 +369,10 @@ function toDiagnostic(directory, result) {
408
369
  /**
409
370
  * Lint a repository's markdown against the shared rule set.
410
371
  *
411
- * {@link MARKDOWNLINT_CONFIG} is passed as markdownlint's `optionsDefault`,
412
- * which is precisely the "shipped default, consumer overrides" behaviour the
413
- * command promises: a repository with no configuration of its own gets these
414
- * rules, and a `.markdownlint-cli2.jsonc` found in the tree overrides them.
372
+ * {@link MARKDOWNLINT_CONFIG} is the default rule set, which is precisely the
373
+ * "shipped default, consumer overrides" behaviour the command promises: a
374
+ * repository with no configuration of its own gets these rules, and a
375
+ * `.markdownlint-cli2.*` options file found in the tree overrides them.
415
376
  *
416
377
  * The override is **key by key, and each key wholesale** — which is not the same
417
378
  * as "replaces it", and the difference is the one worth stating. A consumer file
@@ -419,55 +380,58 @@ function toDiagnostic(directory, result) {
419
380
  * and every per-rule option; but its `ignores` *replaces*
420
381
  * {@link MARKDOWN_IGNORES} rather than extending it, so such a file must restate
421
382
  * every shared entry it still wants. Omitting `CHANGELOG.md` there silently
422
- * starts linting a generated file. `node_modules` stays excluded by the
423
- * command's glob even when a consumer replaces `ignores`.
383
+ * starts linting a generated file. `node_modules` stays excluded even when a
384
+ * consumer replaces `ignores`.
385
+ *
386
+ * A configuration file the contract refuses (an unknown option, a key that
387
+ * cannot mean anything where it is written, a syntax error) is returned as a
388
+ * finding located in that file, and nothing is linted.
424
389
  *
425
390
  * @param {string} root - Repository to lint.
426
391
  * @param {object} [opts]
427
392
  * @param {readonly string[]} [opts.paths] - Globs to lint instead of every
428
393
  * markdown file.
429
394
  * @param {boolean} [opts.fix=false] - Apply the fixes markdownlint can make.
430
- * @param {(params: object) => Promise<number>} [opts.run] - The
431
- * markdownlint-cli2 entry point, for tests.
432
395
  * @returns {Promise<{findings: object[], exitCode: number}>} The findings, and
433
- * markdownlint's own exit code.
396
+ * an exit code that is 1 when there are any.
434
397
  */
435
398
  export async function lintMarkdown(root, opts = {}) {
436
399
  const { paths, fix = false } = opts;
437
- const run = opts.run ?? (await import("markdownlint-cli2")).main;
438
400
  const directory = path.resolve(root);
439
401
 
440
- /** @type {object[]} */
441
- const collected = [];
442
- // A formatter is normally a module id resolved from disk, but
443
- // markdownlint-cli2 passes a non-string through untouched — so the results
444
- // can be collected in process rather than parsed back out of its output.
445
- const collect = ({ results }) => {
446
- collected.push(...results);
447
- };
448
-
449
- const argv = [...(paths?.length ? paths : MARKDOWN_GLOBS), "!**/node_modules/**"];
450
- if (fix) argv.push("--fix");
451
-
452
- const exitCode = await run({
453
- directory,
454
- argv,
455
- optionsDefault: {
456
- config: MARKDOWNLINT_CONFIG,
457
- // Generated markdown reports on its generator, not its author, and
458
- // every consumer already lists its generated trees here.
459
- gitignore: true,
460
- ignores: [...MARKDOWN_IGNORES],
461
- noProgress: true,
462
- noBanner: true,
463
- },
464
- optionsOverride: { outputFormatters: [[collect]] },
465
- logMessage: () => {},
466
- logError: () => {},
467
- });
402
+ let results;
403
+ try {
404
+ ({ results } = await runMarkdownlint({
405
+ directory,
406
+ globs: paths,
407
+ fix,
408
+ defaults: {
409
+ config: MARKDOWNLINT_CONFIG,
410
+ // Generated markdown reports on its generator, not its author,
411
+ // and every consumer already lists its generated trees here.
412
+ gitignore: true,
413
+ globs: MARKDOWN_GLOBS,
414
+ ignores: [...MARKDOWN_IGNORES],
415
+ },
416
+ }));
417
+ } catch (err) {
418
+ if (!(err instanceof MarkdownConfigError)) throw err;
419
+ return {
420
+ findings: [
421
+ {
422
+ file: err.file,
423
+ ...(err.line ? { line: err.line } : {}),
424
+ ...(err.line && err.column ? { column: err.column } : {}),
425
+ severity: "error",
426
+ message: err.message,
427
+ },
428
+ ],
429
+ exitCode: 1,
430
+ };
431
+ }
468
432
 
469
433
  return {
470
- findings: collected.map((result) => toDiagnostic(directory, result)),
471
- exitCode,
434
+ findings: results.map((result) => toDiagnostic(directory, result)),
435
+ exitCode: results.length ? 1 : 0,
472
436
  };
473
437
  }