@heroiclands/package-build 23.13.0 → 24.0.1

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 (236) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +2 -1
  3. package/assets/preview/gitea.ini +12 -7
  4. package/assets/styles/content-base.css +150 -1
  5. package/bin/content-commands.mjs +356 -210
  6. package/bin/package-build.mjs +3 -2
  7. package/ci/check-banners.mjs +7 -7
  8. package/config.mjs +21 -6
  9. package/content-config.mjs +199 -80
  10. package/docs/api.md +93 -72
  11. package/docs/authoring/links-and-markup.md +316 -78
  12. package/docs/commands.md +248 -80
  13. package/docs/configuration.md +210 -102
  14. package/docs/content-format.md +2 -2
  15. package/docs/diagnostics.md +2 -2
  16. package/docs/getting-started.md +1 -1
  17. package/docs/guides/icons.md +1 -1
  18. package/docs/guides/module-project.md +5 -8
  19. package/docs/guides/release-and-deploy.md +2 -2
  20. package/docs/guides/site.md +155 -23
  21. package/docs/guides/system-project.md +1 -1
  22. package/docs/guides/writing-in-invented-scripts.md +1247 -0
  23. package/docs/index.md +1 -0
  24. package/docs/project-setup.md +28 -30
  25. package/docs/reference/format-details.md +589 -308
  26. package/docs/reference/note-types.md +40 -10
  27. package/docs/reference/theme-pages.md +127 -108
  28. package/docs/source-preview.md +15 -1
  29. package/engine/asset-shipping.mjs +22 -10
  30. package/engine/base-compiler.mjs +62 -104
  31. package/engine/changelog-lint.mjs +1 -1
  32. package/engine/content-address.mjs +1 -1
  33. package/engine/content-alerts.mjs +8 -4
  34. package/engine/content-blocks.mjs +26 -11
  35. package/engine/content-embeds.mjs +1 -1
  36. package/engine/content-expand.mjs +376 -0
  37. package/engine/content-figures.mjs +13 -6
  38. package/engine/content-format.yaml +8 -0
  39. package/engine/content-glyphs.mjs +213 -0
  40. package/engine/content-images.mjs +6 -7
  41. package/engine/content-links.mjs +3 -3
  42. package/engine/content-lint.mjs +1 -1
  43. package/engine/content-render.mjs +453 -0
  44. package/engine/content-scripts.mjs +326 -27
  45. package/engine/content-spans.mjs +4 -1
  46. package/engine/content-styles.mjs +91 -0
  47. package/engine/content-symbols.mjs +186 -0
  48. package/engine/content-verse.mjs +6 -18
  49. package/engine/core-report.mjs +169 -0
  50. package/engine/core-vocabulary.mjs +208 -0
  51. package/engine/dictionary-html.mjs +119 -0
  52. package/engine/field-reference.mjs +1 -1
  53. package/engine/frontmatter-lint.mjs +1 -1
  54. package/engine/heading-attributes.mjs +89 -0
  55. package/engine/helpers.mjs +61 -101
  56. package/engine/homepage.mjs +17 -29
  57. package/engine/html-minify.mjs +70 -0
  58. package/engine/index.mjs +8 -5
  59. package/engine/language-rows.mjs +8 -2
  60. package/engine/language-terms.mjs +115 -14
  61. package/engine/language-words.mjs +12 -0
  62. package/engine/logographic.mjs +255 -0
  63. package/engine/map-lexicon.mjs +221 -25
  64. package/engine/map-places.mjs +5 -4
  65. package/engine/markdown-expressions.mjs +0 -2
  66. package/engine/markdownlint-run.mjs +9 -3
  67. package/engine/markup-validation.mjs +21 -0
  68. package/engine/metre-declaration.mjs +134 -0
  69. package/engine/note-matter.mjs +2 -2
  70. package/engine/note-vocabulary.mjs +187 -3
  71. package/engine/pdf-build.mjs +68 -133
  72. package/engine/pdf-render.mjs +102 -44
  73. package/engine/post-fields.mjs +157 -0
  74. package/engine/preview-index.mjs +130 -0
  75. package/engine/preview-source.mjs +450 -0
  76. package/engine/project-init.mjs +5 -12
  77. package/engine/prose-config.mjs +3 -3
  78. package/engine/run-words.mjs +3 -2
  79. package/engine/script-emphasis-terms.mjs +38 -0
  80. package/engine/script-emphasis.mjs +295 -0
  81. package/engine/script-font-delivery.mjs +4 -4
  82. package/engine/site-build.mjs +265 -313
  83. package/engine/site-config.mjs +26 -238
  84. package/engine/site-files.mjs +175 -0
  85. package/engine/site-index.mjs +9 -4
  86. package/engine/site-preview.mjs +50 -523
  87. package/engine/site-reach.mjs +213 -0
  88. package/engine/site-render.mjs +668 -0
  89. package/engine/site-root.mjs +90 -39
  90. package/engine/site-search.mjs +161 -0
  91. package/engine/site-serve.mjs +274 -0
  92. package/engine/site-theme-banner.mjs +154 -0
  93. package/engine/site-theme-urls.mjs +125 -0
  94. package/engine/site-theme.mjs +939 -0
  95. package/engine/sql-tables.mjs +9 -6
  96. package/engine/text-maps.mjs +57 -10
  97. package/engine/typographer.mjs +248 -0
  98. package/engine/verse-metre.mjs +357 -0
  99. package/engine/wikilink-syntax.mjs +29 -47
  100. package/engine/wikilinks.mjs +758 -279
  101. package/engine/word-forms.mjs +39 -24
  102. package/engine/word-rows.mjs +12 -2
  103. package/engine/word-terms.mjs +29 -4
  104. package/manifest.mjs +3 -4
  105. package/package.json +10 -7
  106. package/{hugo-theme → site-theme}/data/banners.yaml +6 -10
  107. package/site-theme/layouts/base.hbs +31 -0
  108. package/site-theme/layouts/blog-list.hbs +30 -0
  109. package/site-theme/layouts/blog-post.hbs +40 -0
  110. package/site-theme/layouts/home.hbs +35 -0
  111. package/site-theme/layouts/list.hbs +20 -0
  112. package/site-theme/layouts/not-found.hbs +31 -0
  113. package/site-theme/layouts/page.hbs +7 -0
  114. package/site-theme/partials/copy-buttons.hbs +37 -0
  115. package/site-theme/partials/draft-notice.hbs +5 -0
  116. package/site-theme/partials/exturl.hbs +2 -0
  117. package/{hugo-theme/layouts/partials/footer.html → site-theme/partials/footer.hbs} +10 -7
  118. package/site-theme/partials/header.hbs +44 -0
  119. package/site-theme/partials/hero-banner.hbs +11 -0
  120. package/site-theme/partials/infobox-entry.hbs +2 -0
  121. package/site-theme/partials/infobox-section.hbs +31 -0
  122. package/site-theme/partials/infobox-value.hbs +2 -0
  123. package/site-theme/partials/infobox.hbs +18 -0
  124. package/site-theme/partials/list-members.hbs +9 -0
  125. package/site-theme/partials/nav-links.hbs +5 -0
  126. package/site-theme/partials/page-body.hbs +48 -0
  127. package/site-theme/partials/page-list.hbs +14 -0
  128. package/site-theme/partials/related.hbs +24 -0
  129. package/site-theme/partials/search-meta.hbs +6 -0
  130. package/site-theme/partials/search-names.hbs +2 -0
  131. package/site-theme/partials/search.hbs +52 -0
  132. package/site-theme/partials/tags.hbs +4 -0
  133. package/site-theme/partials/toc.hbs +7 -0
  134. package/{hugo-theme → site-theme}/static/css/style.css +14 -0
  135. package/stage.mjs +1 -2
  136. package/types/config.d.mts +8 -4
  137. package/types/content-config.d.mts +15 -15
  138. package/types/engine/asset-shipping.d.mts +5 -2
  139. package/types/engine/base-compiler.d.mts +20 -2
  140. package/types/engine/content-address.d.mts +1 -1
  141. package/types/engine/content-alerts.d.mts +8 -2
  142. package/types/engine/content-blocks.d.mts +11 -4
  143. package/types/engine/content-expand.d.mts +288 -0
  144. package/types/engine/content-figures.d.mts +11 -3
  145. package/types/engine/content-glyphs.d.mts +85 -0
  146. package/types/engine/content-images.d.mts +6 -7
  147. package/types/engine/content-links.d.mts +1 -1
  148. package/types/engine/content-render.d.mts +199 -0
  149. package/types/engine/content-scripts.d.mts +62 -0
  150. package/types/engine/content-spans.d.mts +1 -1
  151. package/types/engine/content-styles.d.mts +33 -0
  152. package/types/engine/content-symbols.d.mts +35 -0
  153. package/types/engine/core-report.d.mts +45 -0
  154. package/types/engine/core-vocabulary.d.mts +53 -0
  155. package/types/engine/dictionary-html.d.mts +8 -0
  156. package/types/engine/field-reference.d.mts +1 -1
  157. package/types/engine/heading-attributes.d.mts +20 -0
  158. package/types/engine/helpers.d.mts +3 -10
  159. package/types/engine/homepage.d.mts +11 -24
  160. package/types/engine/html-minify.d.mts +9 -0
  161. package/types/engine/index.d.mts +2 -1
  162. package/types/engine/language-terms.d.mts +24 -2
  163. package/types/engine/logographic.d.mts +45 -0
  164. package/types/engine/map-lexicon.d.mts +48 -8
  165. package/types/engine/map-places.d.mts +8 -0
  166. package/types/engine/markdown-expressions.d.mts +0 -2
  167. package/types/engine/metre-declaration.d.mts +39 -0
  168. package/types/engine/pdf-render.d.mts +6 -2
  169. package/types/engine/post-fields.d.mts +44 -0
  170. package/types/engine/preview-index.d.mts +72 -0
  171. package/types/engine/preview-source.d.mts +51 -0
  172. package/types/engine/script-emphasis-terms.d.mts +21 -0
  173. package/types/engine/script-emphasis.d.mts +84 -0
  174. package/types/engine/script-font-delivery.d.mts +4 -4
  175. package/types/engine/site-build.d.mts +71 -113
  176. package/types/engine/site-config.d.mts +14 -134
  177. package/types/engine/site-files.d.mts +73 -0
  178. package/types/engine/site-index.d.mts +11 -3
  179. package/types/engine/site-preview.d.mts +15 -19
  180. package/types/engine/site-reach.d.mts +76 -0
  181. package/types/engine/site-render.d.mts +173 -0
  182. package/types/engine/site-root.d.mts +14 -4
  183. package/types/engine/site-search.d.mts +54 -0
  184. package/types/engine/site-serve.d.mts +44 -0
  185. package/types/engine/site-theme-banner.d.mts +51 -0
  186. package/types/engine/site-theme-urls.d.mts +60 -0
  187. package/types/engine/site-theme.d.mts +127 -0
  188. package/types/engine/text-maps.d.mts +17 -3
  189. package/types/engine/typographer.d.mts +11 -0
  190. package/types/engine/verse-metre.d.mts +131 -0
  191. package/types/engine/wikilink-syntax.d.mts +30 -48
  192. package/types/engine/wikilinks.d.mts +330 -14
  193. package/types/engine/word-forms.d.mts +3 -2
  194. package/types/manifest.d.mts +3 -4
  195. package/types/stage.d.mts +1 -2
  196. package/types/vendor/markdownlint.d.mts +8 -0
  197. package/vendor/markdownlint.mjs +20388 -0
  198. package/engine/web-wikilinks.mjs +0 -594
  199. package/hugo-theme/layouts/404.html +0 -85
  200. package/hugo-theme/layouts/_default/baseof.html +0 -67
  201. package/hugo-theme/layouts/_default/list.html +0 -139
  202. package/hugo-theme/layouts/_default/single.html +0 -4
  203. package/hugo-theme/layouts/blog/list.html +0 -54
  204. package/hugo-theme/layouts/blog-post/single.html +0 -36
  205. package/hugo-theme/layouts/index.html +0 -75
  206. package/hugo-theme/layouts/partials/banner-url.html +0 -141
  207. package/hugo-theme/layouts/partials/cdn-url.html +0 -36
  208. package/hugo-theme/layouts/partials/dictionary-lists.html +0 -58
  209. package/hugo-theme/layouts/partials/draft-notice.html +0 -41
  210. package/hugo-theme/layouts/partials/draft-state.html +0 -26
  211. package/hugo-theme/layouts/partials/header.html +0 -35
  212. package/hugo-theme/layouts/partials/hero-banner.html +0 -85
  213. package/hugo-theme/layouts/partials/infobox/entry.html +0 -18
  214. package/hugo-theme/layouts/partials/infobox/items.html +0 -26
  215. package/hugo-theme/layouts/partials/infobox/section.html +0 -56
  216. package/hugo-theme/layouts/partials/infobox/value.html +0 -43
  217. package/hugo-theme/layouts/partials/infobox.html +0 -57
  218. package/hugo-theme/layouts/partials/page-body.html +0 -152
  219. package/hugo-theme/layouts/partials/related.html +0 -138
  220. package/hugo-theme/layouts/partials/search.html +0 -84
  221. package/hugo-theme/layouts/partials/site-url.html +0 -32
  222. package/hugo-theme/layouts/partials/tags.html +0 -30
  223. package/hugo-theme/layouts/partials/toc.html +0 -28
  224. package/hugo-theme/layouts/partials/warn-missing-banner.html +0 -3
  225. package/hugo-theme/layouts/shortcodes/cartouche.html +0 -64
  226. package/hugo-theme/layouts/shortcodes/exturl.html +0 -12
  227. package/hugo-theme/layouts/shortcodes/glyph.html +0 -43
  228. package/hugo-theme/layouts/shortcodes/pagelist.html +0 -63
  229. package/hugo-theme/theme.toml +0 -11
  230. package/types/engine/web-wikilinks.d.mts +0 -107
  231. /package/{hugo-theme → site-theme}/data/banner-kinds.yaml +0 -0
  232. /package/{hugo-theme → site-theme}/static/css/sohl-icons.css +0 -0
  233. /package/{hugo-theme → site-theme}/static/fonts/game-icons-kb.woff2 +0 -0
  234. /package/{hugo-theme → site-theme}/static/img/banners/takheperu-hieroglyphs.svg +0 -0
  235. /package/{hugo-theme → site-theme}/static/img/cartouches/takheperu.svg +0 -0
  236. /package/{hugo-theme → site-theme}/static/js/inline-spoilers.js +0 -0
@@ -36,7 +36,7 @@
36
36
  * ## Links, and the one rule that decides them
37
37
  *
38
38
  * A wikilink is already resolved to a URL before this module sees it — by the
39
- * same {@link module:engine/web-wikilinks} pass the site uses, so the two
39
+ * same {@link module:engine/wikilinks} resolver the site uses, so the two
40
40
  * surfaces cannot disagree about where a link points. What differs is what a
41
41
  * *book* does with the answer:
42
42
  *
@@ -61,19 +61,10 @@
61
61
  * @module
62
62
  */
63
63
 
64
- import MarkdownIt from "markdown-it";
65
- import footnotePlugin from "markdown-it-footnote";
66
- import deflistPlugin from "markdown-it-deflist";
67
-
64
+ import { createRenderer } from "./content-render.mjs";
68
65
  import { bookDraftNoticePreamble } from "./draft-notice.mjs";
69
- import { iconPlugin, ICON_PATTERN, ICON_SIZES } from "./content-icons.mjs";
70
- import {
71
- IMAGE_CLASSES,
72
- IMAGE_FLOATS,
73
- IMAGE_PATTERN,
74
- checkImages,
75
- imagePlugin,
76
- } from "./content-images.mjs";
66
+ import { ICON_PATTERN, ICON_SIZES } from "./content-icons.mjs";
67
+ import { IMAGE_CLASSES, IMAGE_FLOATS, IMAGE_PATTERN, checkImages } from "./content-images.mjs";
77
68
  import { bookImageWidthIn } from "./pdf-images.mjs";
78
69
  import { CHRONOLOGY_SLUG } from "./generated-sections.mjs";
79
70
 
@@ -89,15 +80,14 @@ export const BOOK_IMAGE_WIDTHS = Object.freeze({
89
80
  import { markupAnchorFindings } from "./anchors.mjs";
90
81
  import { indexChapter, indexMarker, indexPreamble } from "./pdf-index.mjs";
91
82
  import { slugify } from "./content-slug.mjs";
92
- import { alertMarkdownPlugin, scanAlerts, ALERT_TYPES } from "./content-alerts.mjs";
93
- import { spanMarkdownPlugin, scanSpans } from "./content-spans.mjs";
83
+ import { scanAlerts, ALERT_TYPES } from "./content-alerts.mjs";
84
+ import { scanSpans } from "./content-spans.mjs";
94
85
  import { GENERIC_FONTS, renderRuns, scanRuns } from "./content-scripts.mjs";
95
86
  import { scanFigures } from "./content-figures.mjs";
96
87
  import { BLOCK_CONTAINERS, scanBlocks, BLOCK_NAMES } from "./content-blocks.mjs";
97
88
  import {
98
89
  COLUMNS_CLASS,
99
90
  STANZA_BREAK,
100
- inlineVerse,
101
91
  lineNumber,
102
92
  rightToLeft,
103
93
  scanVerse,
@@ -107,6 +97,8 @@ import {
107
97
  import { matchAllOutsideCode, replaceOutsideCode } from "./code-fences.mjs";
108
98
  import { HTML_TAG, htmlMessage } from "./content-html.mjs";
109
99
  import { EXPRESSION } from "./markdown-expressions.mjs";
100
+ import { inlineText } from "./name-markup.mjs";
101
+ import { cartouchePathname, glyphPathname, humanizeSlug, typstLength } from "./content-glyphs.mjs";
110
102
  import { WIKILINK } from "./wikilink-syntax.mjs";
111
103
  import { EMBED_PATTERN } from "./content-embeds.mjs";
112
104
  import { positionInBody } from "./diagnostics.mjs";
@@ -197,43 +189,22 @@ export function labelFor(anchor) {
197
189
  }
198
190
 
199
191
  /**
200
- * A markdown-it configured to parse, not to render.
192
+ * The shared parser configured for the book: the `book` target of
193
+ * {@link module:engine/content-render.createRenderer}, which parses and leaves
194
+ * the output to this module's Typst stage.
201
195
  *
202
196
  * `html: false` is the load-bearing setting: raw HTML in a note has no route to
203
197
  * Typst at all, which is why {@link module:engine/content-html} reports it. With
204
198
  * HTML disabled markdown-it emits the tag as text, so it arrives in the book
205
- * visibly wrong rather than invisibly missing.
199
+ * visibly wrong rather than invisibly missing. The one tag it reads is the
200
+ * emphasis a script run's convention sets, which the run's rewrite writes and
201
+ * the book prints in its rubric ink or under an overline.
206
202
  *
207
203
  * @param {object} [registry] - The icon registry.
208
204
  * @returns {object} A markdown-it instance.
209
205
  */
210
206
  export function createParser(registry) {
211
- const md = new MarkdownIt({ html: false, linkify: false, typographer: false });
212
- md.use(footnotePlugin);
213
- md.use(alertMarkdownPlugin);
214
- md.use(spanMarkdownPlugin);
215
- // Verse is held inside a literal fence. Parse its inline content before
216
- // footnote_tail collects definitions, using the surrounding note's env.
217
- md.core.ruler.after("inline", "verse_inline", (state) => {
218
- for (const token of state.tokens) {
219
- if (token.type !== "fence" || !/^verse(?:\s|$)/.test(token.info?.trim() || ""))
220
- continue;
221
- token.meta ??= {};
222
- token.meta.verseStanzas = verseStanzas(token.content).map((stanza) =>
223
- stanza.map(({ text, level }) => {
224
- const children = [];
225
- state.md.inline.parse(inlineVerse(text), state.md, state.env, children);
226
- return { children, level };
227
- }),
228
- );
229
- }
230
- });
231
- md.use(deflistPlugin);
232
- md.use(iconPlugin(registry));
233
- // The same plugin the HTML surfaces use, so one directive is read once and
234
- // three renderers read the same `meta` off the same token.
235
- md.use(imagePlugin());
236
- return md;
207
+ return createRenderer("book", { icons: registry }).md;
237
208
  }
238
209
 
239
210
  /**
@@ -1385,6 +1356,12 @@ function renderInline(token, ctx) {
1385
1356
  out.push(inlineRaw(child.content));
1386
1357
  break;
1387
1358
  case "span_open": {
1359
+ const sign = renderGlyph(children, i, ctx) ?? renderCartouche(children, i, ctx);
1360
+ if (sign) {
1361
+ out.push(sign.typst);
1362
+ i = sign.close;
1363
+ break;
1364
+ }
1388
1365
  // Inline secrecy has no print presentation; other classes and ids survive.
1389
1366
  const classes = (child.attrGet?.("class") ?? "")
1390
1367
  .split(/\s+/)
@@ -1419,6 +1396,16 @@ function renderInline(token, ctx) {
1419
1396
  out.push(`]${id ? ` <${sectionLabel(ctx.anchorPrefix, id)}>` : ""}`);
1420
1397
  break;
1421
1398
  }
1399
+ case "script_emphasis_open":
1400
+ out.push(
1401
+ child.meta?.convention === "rubric" ?
1402
+ "#text(fill: book-rubric)["
1403
+ : "#overline[",
1404
+ );
1405
+ break;
1406
+ case "script_emphasis_close":
1407
+ out.push("]");
1408
+ break;
1422
1409
  case "strong_open":
1423
1410
  out.push("#strong[");
1424
1411
  break;
@@ -1473,6 +1460,74 @@ function renderInline(token, ctx) {
1473
1460
  return out.join("");
1474
1461
  }
1475
1462
 
1463
+ /**
1464
+ * A glyph span as the picture the book carries, or as its label when the
1465
+ * package ships no file for it.
1466
+ *
1467
+ * @param {object[]} children - Inline children.
1468
+ * @param {number} i - The index of a `span_open`.
1469
+ * @param {object} ctx - Render context.
1470
+ * @returns {{typst: string, close: number}|undefined} The Typst and the index
1471
+ * of the closing token; `undefined` when the span is not a well-formed glyph.
1472
+ */
1473
+ function renderGlyph(children, i, ctx) {
1474
+ const open = children[i];
1475
+ const classes = (open.attrGet?.("class") ?? "").split(/\s+/);
1476
+ const slug = open.attrGet?.("slug");
1477
+ if (!classes.includes("glyph") || !slug || open.meta?.problems?.length) return undefined;
1478
+ const close = childMatching(children, i, "span_open", "span_close");
1479
+ const label = inlineText(children.slice(i + 1, close)).trim() || humanizeSlug(slug);
1480
+ const staged = ctx.images.get(glyphPathname(slug));
1481
+ if (!staged) return { typst: escapeTypst(label), close };
1482
+ const picture = `image("${escapeTypstString(staged)}", height: 1.8em)`;
1483
+ const frame = open.attrGet?.("frame");
1484
+ const typst =
1485
+ frame === "tablet" ?
1486
+ `#box(fill: rgb("#ece1c8"), inset: (x: 0.3em, y: 0.15em), radius: 0.2em, baseline: 25%, ${picture})`
1487
+ : open.attrGet?.("size") === "block" ? `#align(center, ${picture})`
1488
+ : `#box(baseline: 25%, ${picture})`;
1489
+ return { typst, close };
1490
+ }
1491
+
1492
+ /**
1493
+ * A cartouche span as the figure the book prints. Print cannot wrap text
1494
+ * around it, so `align` places the figure between the lines of its sentence.
1495
+ *
1496
+ * @param {object[]} children - Inline children.
1497
+ * @param {number} i - The index of a `span_open`.
1498
+ * @param {object} ctx - Render context.
1499
+ * @returns {{typst: string, close: number}|undefined} The Typst and the index
1500
+ * of the closing token; `undefined` when the span is not a well-formed
1501
+ * cartouche.
1502
+ */
1503
+ function renderCartouche(children, i, ctx) {
1504
+ const open = children[i];
1505
+ const classes = (open.attrGet?.("class") ?? "").split(/\s+/);
1506
+ const slug = open.attrGet?.("slug");
1507
+ if (!classes.includes("cartouche") || !slug || open.meta?.problems?.length) return undefined;
1508
+ const close = childMatching(children, i, "span_open", "span_close");
1509
+ const align = open.attrGet?.("align");
1510
+ const height = open.attrGet?.("height");
1511
+ const caption = open.attrGet?.("caption");
1512
+ const alt = inlineText(children.slice(i + 1, close)).trim();
1513
+ const staged = ctx.images.get(cartouchePathname(slug));
1514
+ const label = caption ? escapeTypst(caption) : "";
1515
+ const text = (content) => `#text(size: 7.6pt, style: "italic", fill: luma(45%))[${content}]`;
1516
+ if (!staged) {
1517
+ const shown = label || escapeTypst(alt || humanizeSlug(slug));
1518
+ return { typst: `\n#align(center, ${text(shown)})\n`, close };
1519
+ }
1520
+ const size =
1521
+ height ? typstLength(height)
1522
+ : align ? "7em"
1523
+ : "10em";
1524
+ const picture = `image("${escapeTypstString(staged)}", height: ${size})`;
1525
+ return {
1526
+ typst: `\n#align(${align ?? "center"}, block(breakable: false)[#${picture}${label ? ` \\ ${text(label)}` : ""}])\n`,
1527
+ close,
1528
+ };
1529
+ }
1530
+
1476
1531
  /**
1477
1532
  * The index of the inline token closing the one at `i`.
1478
1533
  *
@@ -1890,6 +1945,9 @@ export function bookTypstPreamble(runs = {}, families = {}) {
1890
1945
  // run to the trimmed edge of the paper.
1891
1946
  "#let book-title-measure = book-text-width - 2.5cm",
1892
1947
  '#let book-ink = rgb("#241f1a")',
1948
+ // A rubric script's emphasis: a vermilion that holds 4.5:1 on the paper
1949
+ // and stays apart from the ink in lightness.
1950
+ '#let book-rubric = rgb("#9c281b")',
1893
1951
  '#let book-paper = rgb("#f4efe4")',
1894
1952
  '#let book-accent = rgb("#7c3b1e")',
1895
1953
  '#let book-head = rgb("#5e2b14")',
@@ -0,0 +1,157 @@
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
+ * **A blog post** is a `doc` of `subType: post`. It states the day it was
16
+ * published in `data.date`, as `YYYY-MM-DD`, and may name its writer in
17
+ * `data.author`; a post that names none carries the site's byline, the first
18
+ * entry of `authors`. Its `tags` are the top-level ones any note carries.
19
+ *
20
+ * Both fields belong to a post alone. {@link checkPostFields} requires the date
21
+ * on a post, refuses either field on any other `doc`, and refuses a date that
22
+ * is not a calendar day or an author that is blank.
23
+ *
24
+ * @module
25
+ */
26
+
27
+ import { positionInFrontmatter, positionOfFrontmatterPath } from "./diagnostics.mjs";
28
+
29
+ /** The `doc` subType a post is. */
30
+ export const POST_SUBTYPE = "post";
31
+
32
+ /** A calendar day as a post states it. */
33
+ const DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
34
+
35
+ /**
36
+ * Whether a note's frontmatter, or a page's parameters, are a post's.
37
+ *
38
+ * @param {{type?: unknown, subType?: unknown}|null|undefined} fm - The frontmatter.
39
+ * @returns {boolean} Whether it is a `doc` of `subType: post`.
40
+ */
41
+ export function isPost(fm) {
42
+ return (
43
+ String(fm?.type ?? "").toLowerCase() === "doc" &&
44
+ String(fm?.subType ?? "").toLowerCase() === POST_SUBTYPE
45
+ );
46
+ }
47
+
48
+ /**
49
+ * Whether a string is a calendar day, `YYYY-MM-DD`, that exists.
50
+ *
51
+ * @param {unknown} value - The value.
52
+ * @returns {boolean} Whether it is one.
53
+ */
54
+ export function isCalendarDay(value) {
55
+ if (typeof value !== "string") return false;
56
+ const match = DAY.exec(value);
57
+ if (!match) return false;
58
+ const [year, month, day] = match.slice(1).map(Number);
59
+ const date = new Date(Date.UTC(year, month - 1, day));
60
+ return (
61
+ date.getUTCFullYear() === year &&
62
+ date.getUTCMonth() === month - 1 &&
63
+ date.getUTCDate() === day
64
+ );
65
+ }
66
+
67
+ /** `data.date`, as the vocabulary declares it. */
68
+ export const POST_DATE_FIELD = Object.freeze({
69
+ name: "date",
70
+ shape: "string",
71
+ kind: "string",
72
+ describe:
73
+ "The day a post was published, as `YYYY-MM-DD`. Required on a `post`; no other " +
74
+ "subType states it. Posts are listed and fed newest first by it.",
75
+ });
76
+
77
+ /** `data.author`, as the vocabulary declares it. */
78
+ export const POST_AUTHOR_FIELD = Object.freeze({
79
+ name: "author",
80
+ shape: "string",
81
+ kind: "string",
82
+ describe:
83
+ "Who wrote a post, as the byline prints them. A post that names none carries the " +
84
+ "site's byline, the first entry of `authors`; no other subType states it.",
85
+ });
86
+
87
+ /**
88
+ * Scope `data.date` and `data.author` to posts, and check their values.
89
+ *
90
+ * A post with no date is an error at its `subType:` line; a date that is not a
91
+ * calendar day, or an author that is blank, is an error at the value. Either
92
+ * field on a `doc` that is not a post is an error at the key.
93
+ *
94
+ * @param {object} note - The note, as the link index hands it over.
95
+ * @returns {object[]} Findings.
96
+ */
97
+ export function checkPostFields(note) {
98
+ const type = String(note?.type ?? note?.fm?.type ?? "").toLowerCase();
99
+ if (type !== "doc") return [];
100
+ const raw = note.raw ?? "";
101
+ const data = note?.fm?.data;
102
+ const has = (key) =>
103
+ data !== null &&
104
+ typeof data === "object" &&
105
+ !Array.isArray(data) &&
106
+ data[key] !== undefined &&
107
+ data[key] !== null;
108
+ const finding = (keyPath, message, key = false) => ({
109
+ file: note.file,
110
+ ...positionOfFrontmatterPath(raw, keyPath, { key }),
111
+ severity: "error",
112
+ message,
113
+ });
114
+
115
+ if (!isPost({ type, subType: note?.fm?.subType })) {
116
+ return ["date", "author"]
117
+ .filter(has)
118
+ .map((key) =>
119
+ finding(
120
+ ["data", key],
121
+ `\`data.${key}\` is stated by a post, and this note's \`subType\` is not ` +
122
+ `\`${POST_SUBTYPE}\``,
123
+ true,
124
+ ),
125
+ );
126
+ }
127
+
128
+ const findings = [];
129
+ if (!has("date")) {
130
+ findings.push({
131
+ file: note.file,
132
+ ...positionInFrontmatter(raw, "subType", undefined, { topLevel: true }),
133
+ severity: "error",
134
+ message:
135
+ "a post is listed and fed by the day it was published, so it must state " +
136
+ "`data.date`, as `YYYY-MM-DD`",
137
+ });
138
+ } else if (typeof data.date === "string" && !isCalendarDay(data.date)) {
139
+ findings.push(
140
+ finding(
141
+ ["data", "date"],
142
+ `\`data.date\` is \`${String(data.date)}\`, which is not a calendar day written ` +
143
+ "`YYYY-MM-DD`",
144
+ ),
145
+ );
146
+ }
147
+ if (has("author") && typeof data.author === "string" && !data.author.trim()) {
148
+ findings.push(
149
+ finding(
150
+ ["data", "author"],
151
+ "`data.author` names who wrote the post; leave it out for the site's byline " +
152
+ "rather than writing it blank",
153
+ ),
154
+ );
155
+ }
156
+ return findings;
157
+ }
@@ -0,0 +1,130 @@
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
+ * **What an editor with a content index gives the preview.** {@link previewIndex}
16
+ * turns a package's configuration and its content-index records into the one
17
+ * object {@link module:engine/site-preview.renderPreview} takes as `index`. A
18
+ * preview given it resolves links, embeds, marked text, icons and figure
19
+ * numbers as the website does; a preview without one shows the buffer's source.
20
+ *
21
+ * Build it once per index and reuse it for every render: it holds the link
22
+ * index, the asset address space, the languages' maps and the events of every
23
+ * note.
24
+ *
25
+ * @module
26
+ */
27
+
28
+ import { addressSlug } from "./content-address.mjs";
29
+ import { expandContent } from "./content-expand.mjs";
30
+ import { embedRole } from "./content-embeds.mjs";
31
+ import { renderImageFigures } from "./content-images.mjs";
32
+ import { runContext } from "./content-scripts.mjs";
33
+ import { eventNoteIndex } from "./event-fields.mjs";
34
+ import { isHomepage } from "./homepage.mjs";
35
+ import { authoredFrontmatter, isNoteRecord, isStub } from "./index-records.mjs";
36
+ import { noContentIndexPackages } from "./metadata-index.mjs";
37
+ import { isGmNote } from "./note-vocabulary.mjs";
38
+ import { assetAddressIndex } from "./art-fields.mjs";
39
+ import { assetPathnameKey, pathnameProblem, resolvePathname } from "./pathnames.mjs";
40
+ import { buildSiteIndex } from "./site-index.mjs";
41
+ import { siteBase } from "../content-config.mjs";
42
+
43
+ /**
44
+ * What a preview reads to render with an index.
45
+ *
46
+ * @typedef {object} PreviewIndex
47
+ * @property {object} runs - The package's languages, faces and maps.
48
+ * @property {object|undefined} icons - The package's icon registry.
49
+ * @property {(src: string) => ({role?: string, width?: number|"", height?: number|""}|undefined)} lookupAsset -
50
+ * From a picture's address to its asset.
51
+ * @property {(src: string) => string} resolveSrc - From a picture's authored
52
+ * pathname to the address the website serves it at.
53
+ * @property {(markdown: string, note: {fm: object, file?: string}) => {markdown: string, figures: object[]}} expand -
54
+ * Expand a buffer's body as the website expands a page: links, embeds,
55
+ * expressions and figure numbers resolved. A query, which a buffer cannot
56
+ * answer, is left as written.
57
+ */
58
+
59
+ /**
60
+ * Build the object a preview with an index renders from.
61
+ *
62
+ * @param {object} options
63
+ * @param {object} options.config - The resolved package configuration.
64
+ * @param {object[]} options.records - The content index's records: every note
65
+ * and every asset.
66
+ * @param {object} [options.foreign] - The dependencies' indexes, as
67
+ * {@link module:engine/metadata-index.loadForeignIndexes} returns them.
68
+ * Omitted, the package links only to itself.
69
+ * @returns {PreviewIndex} The index.
70
+ */
71
+ export function previewIndex({ config, records, foreign }) {
72
+ const base = siteBase(config);
73
+ const entries = [];
74
+ for (const record of records) {
75
+ if (!isNoteRecord(record) || isStub(record)) continue;
76
+ const fm = authoredFrontmatter(record);
77
+ if (isGmNote(fm) || typeof fm.shortcode !== "string" || !fm.shortcode) continue;
78
+ entries.push({
79
+ kind: "content",
80
+ fm,
81
+ body: "",
82
+ anchors: record.anchors ?? [],
83
+ pkg: record.package ?? config.contentPackage,
84
+ name: fm.name?.full ?? fm.shortcode,
85
+ url: isHomepage(fm) ? base : `${base}${addressSlug(fm)}/`,
86
+ });
87
+ }
88
+ const site = buildSiteIndex(entries, {
89
+ package: config.contentPackage,
90
+ currentDate: config.currentDate,
91
+ foreignIndex: foreign?.index,
92
+ foreignReferences: foreign?.references,
93
+ records,
94
+ noIndexPackages: noContentIndexPackages(config),
95
+ });
96
+ const assets = assetAddressIndex(records, { config, foreign, types: site.contentTypes });
97
+ const events = eventNoteIndex(records, {
98
+ contentPackage: site.contentPackage,
99
+ foreignIndex: foreign?.index,
100
+ dates: site.dateContext,
101
+ });
102
+ const resolveRole = (address) => embedRole(assets, address);
103
+ const webSrc = (src) =>
104
+ pathnameProblem(src) ? src : (resolvePathname(src, config)?.web ?? src);
105
+ const lookupAsset = (src) => {
106
+ const key = assetPathnameKey(src, config);
107
+ return key ? assets?.byPath?.get(key) : undefined;
108
+ };
109
+ return {
110
+ runs: runContext(config, records),
111
+ icons: config.icons,
112
+ lookupAsset,
113
+ resolveSrc: webSrc,
114
+ expand: (markdown, { fm, file }) => {
115
+ const expanded = expandContent(markdown, {
116
+ target: "web",
117
+ index: site,
118
+ note: { fm, file, source: file || "<buffer>" },
119
+ events,
120
+ resolveRole,
121
+ links: {
122
+ foreign: foreign?.index,
123
+ assets,
124
+ afterLinks: (block) => renderImageFigures(block, webSrc, lookupAsset),
125
+ },
126
+ });
127
+ return { markdown: expanded.markdown, figures: expanded.figures.figures };
128
+ },
129
+ };
130
+ }