@heroiclands/package-build 23.12.8 → 24.0.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 (258) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +3 -2
  3. package/assets/preview/gitea.ini +12 -7
  4. package/assets/styles/content-base.css +293 -24
  5. package/bin/content-commands.mjs +370 -213
  6. package/bin/package-build-preview.mjs +2 -2
  7. package/bin/package-build.mjs +3 -2
  8. package/ci/check-banners.mjs +7 -7
  9. package/config.mjs +21 -6
  10. package/content-config.mjs +199 -80
  11. package/docs/api.md +110 -89
  12. package/docs/authoring/links-and-markup.md +469 -97
  13. package/docs/commands.md +259 -78
  14. package/docs/configuration.md +211 -103
  15. package/docs/content-format.md +4 -4
  16. package/docs/diagnostics.md +44 -2
  17. package/docs/getting-started.md +1 -1
  18. package/docs/guides/icons.md +1 -1
  19. package/docs/guides/module-project.md +5 -8
  20. package/docs/guides/release-and-deploy.md +2 -2
  21. package/docs/guides/site.md +112 -23
  22. package/docs/guides/system-project.md +1 -1
  23. package/docs/guides/writing-in-invented-scripts.md +1247 -0
  24. package/docs/index.md +1 -0
  25. package/docs/project-setup.md +28 -30
  26. package/docs/reference/format-details.md +677 -319
  27. package/docs/reference/note-types.md +47 -10
  28. package/docs/reference/theme-pages.md +178 -108
  29. package/docs/source-preview.md +16 -2
  30. package/engine/anchors.mjs +2 -2
  31. package/engine/asset-shipping.mjs +22 -10
  32. package/engine/base-compiler.mjs +62 -104
  33. package/engine/changelog-lint.mjs +1 -1
  34. package/engine/code-fences.mjs +3 -3
  35. package/engine/content-address.mjs +1 -1
  36. package/engine/content-alerts.mjs +8 -4
  37. package/engine/content-blocks.mjs +29 -14
  38. package/engine/content-embeds.mjs +1 -1
  39. package/engine/content-expand.mjs +376 -0
  40. package/engine/content-figures.mjs +18 -11
  41. package/engine/content-format.yaml +8 -0
  42. package/engine/content-glyphs.mjs +213 -0
  43. package/engine/content-images.mjs +6 -7
  44. package/engine/content-index.mjs +1 -1
  45. package/engine/content-links.mjs +3 -3
  46. package/engine/content-lint.mjs +1 -1
  47. package/engine/content-render.mjs +453 -0
  48. package/engine/content-scripts.mjs +376 -43
  49. package/engine/content-spans.mjs +6 -3
  50. package/engine/content-styles.mjs +91 -0
  51. package/engine/content-symbols.mjs +186 -0
  52. package/engine/content-verse.mjs +738 -0
  53. package/engine/core-report.mjs +169 -0
  54. package/engine/core-vocabulary.mjs +208 -0
  55. package/engine/dictionary-html.mjs +119 -0
  56. package/engine/dictionary-rows.mjs +15 -4
  57. package/engine/field-reference.mjs +5 -5
  58. package/engine/first-use.mjs +17 -27
  59. package/engine/frontmatter-lint.mjs +1 -1
  60. package/engine/heading-attributes.mjs +89 -0
  61. package/engine/helpers.mjs +61 -101
  62. package/engine/homepage.mjs +17 -29
  63. package/engine/html-minify.mjs +70 -0
  64. package/engine/index.mjs +8 -5
  65. package/engine/key-case-collisions.mjs +136 -0
  66. package/engine/language-data.mjs +203 -6
  67. package/engine/language-rows.mjs +8 -2
  68. package/engine/language-terms.mjs +186 -16
  69. package/engine/language-words.mjs +12 -0
  70. package/engine/logographic.mjs +255 -0
  71. package/engine/map-lexicon.mjs +262 -47
  72. package/engine/map-places.mjs +5 -4
  73. package/engine/markdown-expressions.mjs +0 -2
  74. package/engine/markdownlint-run.mjs +9 -3
  75. package/engine/markup-validation.mjs +21 -0
  76. package/engine/metre-declaration.mjs +134 -0
  77. package/engine/names.mjs +36 -9
  78. package/engine/note-matter.mjs +66 -0
  79. package/engine/note-vocabulary.mjs +187 -3
  80. package/engine/pdf-build.mjs +69 -133
  81. package/engine/pdf-render.mjs +205 -58
  82. package/engine/post-fields.mjs +157 -0
  83. package/engine/preview-index.mjs +130 -0
  84. package/engine/preview-source.mjs +450 -0
  85. package/engine/project-init.mjs +5 -12
  86. package/engine/prose-config.mjs +3 -3
  87. package/engine/read-stdin.mjs +29 -0
  88. package/engine/run-rows.mjs +2 -2
  89. package/engine/run-words.mjs +6 -112
  90. package/engine/script-emphasis-terms.mjs +38 -0
  91. package/engine/script-emphasis.mjs +295 -0
  92. package/engine/script-font-delivery.mjs +4 -4
  93. package/engine/site-build.mjs +260 -315
  94. package/engine/site-config.mjs +26 -238
  95. package/engine/site-files.mjs +175 -0
  96. package/engine/site-index.mjs +9 -4
  97. package/engine/site-preview.mjs +52 -525
  98. package/engine/site-render.mjs +593 -0
  99. package/engine/site-root.mjs +22 -13
  100. package/engine/site-search.mjs +161 -0
  101. package/engine/site-serve.mjs +273 -0
  102. package/engine/site-theme-banner.mjs +154 -0
  103. package/engine/site-theme-urls.mjs +125 -0
  104. package/engine/site-theme.mjs +926 -0
  105. package/engine/sql-tables.mjs +20 -3
  106. package/engine/text-maps.mjs +57 -10
  107. package/engine/typographer.mjs +248 -0
  108. package/engine/verse-metre.mjs +357 -0
  109. package/engine/wikilink-syntax.mjs +29 -47
  110. package/engine/wikilinks.mjs +758 -279
  111. package/engine/word-forms.mjs +364 -0
  112. package/engine/word-rows.mjs +12 -2
  113. package/engine/word-terms.mjs +38 -4
  114. package/manifest.mjs +3 -4
  115. package/package.json +11 -8
  116. package/{hugo-theme → site-theme}/data/banners.yaml +6 -10
  117. package/site-theme/layouts/base.hbs +31 -0
  118. package/site-theme/layouts/blog-list.hbs +30 -0
  119. package/site-theme/layouts/blog-post.hbs +40 -0
  120. package/site-theme/layouts/home.hbs +35 -0
  121. package/site-theme/layouts/list.hbs +20 -0
  122. package/site-theme/layouts/not-found.hbs +31 -0
  123. package/site-theme/layouts/page.hbs +7 -0
  124. package/site-theme/partials/copy-buttons.hbs +37 -0
  125. package/site-theme/partials/draft-notice.hbs +5 -0
  126. package/site-theme/partials/exturl.hbs +2 -0
  127. package/{hugo-theme/layouts/partials/footer.html → site-theme/partials/footer.hbs} +10 -7
  128. package/site-theme/partials/header.hbs +44 -0
  129. package/site-theme/partials/hero-banner.hbs +11 -0
  130. package/site-theme/partials/infobox-entry.hbs +2 -0
  131. package/site-theme/partials/infobox-section.hbs +31 -0
  132. package/site-theme/partials/infobox-value.hbs +2 -0
  133. package/site-theme/partials/infobox.hbs +18 -0
  134. package/site-theme/partials/list-members.hbs +9 -0
  135. package/site-theme/partials/nav-links.hbs +5 -0
  136. package/site-theme/partials/page-body.hbs +48 -0
  137. package/site-theme/partials/page-list.hbs +14 -0
  138. package/site-theme/partials/related.hbs +24 -0
  139. package/site-theme/partials/search-meta.hbs +6 -0
  140. package/site-theme/partials/search-names.hbs +2 -0
  141. package/site-theme/partials/search.hbs +52 -0
  142. package/site-theme/partials/tags.hbs +4 -0
  143. package/site-theme/partials/toc.hbs +7 -0
  144. package/{hugo-theme → site-theme}/static/css/style.css +106 -14
  145. package/stage.mjs +1 -2
  146. package/types/config.d.mts +8 -4
  147. package/types/content-config.d.mts +15 -15
  148. package/types/engine/asset-shipping.d.mts +5 -2
  149. package/types/engine/base-compiler.d.mts +20 -2
  150. package/types/engine/code-fences.d.mts +3 -3
  151. package/types/engine/content-address.d.mts +1 -1
  152. package/types/engine/content-alerts.d.mts +8 -2
  153. package/types/engine/content-blocks.d.mts +11 -4
  154. package/types/engine/content-expand.d.mts +288 -0
  155. package/types/engine/content-figures.d.mts +11 -3
  156. package/types/engine/content-glyphs.d.mts +85 -0
  157. package/types/engine/content-images.d.mts +6 -7
  158. package/types/engine/content-links.d.mts +1 -1
  159. package/types/engine/content-render.d.mts +199 -0
  160. package/types/engine/content-scripts.d.mts +70 -4
  161. package/types/engine/content-spans.d.mts +1 -1
  162. package/types/engine/content-styles.d.mts +33 -0
  163. package/types/engine/content-symbols.d.mts +35 -0
  164. package/types/engine/content-verse.d.mts +240 -0
  165. package/types/engine/core-report.d.mts +45 -0
  166. package/types/engine/core-vocabulary.d.mts +53 -0
  167. package/types/engine/dictionary-html.d.mts +8 -0
  168. package/types/engine/dictionary-rows.d.mts +10 -0
  169. package/types/engine/field-reference.d.mts +2 -2
  170. package/types/engine/heading-attributes.d.mts +20 -0
  171. package/types/engine/helpers.d.mts +3 -10
  172. package/types/engine/homepage.d.mts +11 -24
  173. package/types/engine/html-minify.d.mts +9 -0
  174. package/types/engine/index.d.mts +2 -1
  175. package/types/engine/key-case-collisions.d.mts +28 -0
  176. package/types/engine/language-data.d.mts +0 -2
  177. package/types/engine/language-terms.d.mts +24 -2
  178. package/types/engine/logographic.d.mts +45 -0
  179. package/types/engine/map-lexicon.d.mts +66 -13
  180. package/types/engine/map-places.d.mts +8 -0
  181. package/types/engine/markdown-expressions.d.mts +0 -2
  182. package/types/engine/metre-declaration.d.mts +39 -0
  183. package/types/engine/names.d.mts +34 -3
  184. package/types/engine/note-matter.d.mts +48 -0
  185. package/types/engine/pdf-render.d.mts +6 -2
  186. package/types/engine/post-fields.d.mts +44 -0
  187. package/types/engine/preview-index.d.mts +72 -0
  188. package/types/engine/preview-source.d.mts +51 -0
  189. package/types/engine/read-stdin.d.mts +11 -0
  190. package/types/engine/run-rows.d.mts +2 -2
  191. package/types/engine/run-words.d.mts +0 -15
  192. package/types/engine/script-emphasis-terms.d.mts +21 -0
  193. package/types/engine/script-emphasis.d.mts +84 -0
  194. package/types/engine/script-font-delivery.d.mts +4 -4
  195. package/types/engine/site-build.d.mts +71 -113
  196. package/types/engine/site-config.d.mts +14 -134
  197. package/types/engine/site-files.d.mts +73 -0
  198. package/types/engine/site-index.d.mts +11 -3
  199. package/types/engine/site-preview.d.mts +15 -19
  200. package/types/engine/site-render.d.mts +171 -0
  201. package/types/engine/site-root.d.mts +12 -4
  202. package/types/engine/site-search.d.mts +54 -0
  203. package/types/engine/site-serve.d.mts +44 -0
  204. package/types/engine/site-theme-banner.d.mts +51 -0
  205. package/types/engine/site-theme-urls.d.mts +60 -0
  206. package/types/engine/site-theme.d.mts +127 -0
  207. package/types/engine/sql-tables.d.mts +4 -1
  208. package/types/engine/text-maps.d.mts +17 -3
  209. package/types/engine/typographer.d.mts +11 -0
  210. package/types/engine/verse-metre.d.mts +131 -0
  211. package/types/engine/wikilink-syntax.d.mts +30 -48
  212. package/types/engine/wikilinks.d.mts +330 -14
  213. package/types/engine/word-forms.d.mts +83 -0
  214. package/types/manifest.d.mts +3 -4
  215. package/types/stage.d.mts +1 -2
  216. package/types/vendor/markdownlint.d.mts +8 -0
  217. package/vendor/markdownlint.mjs +20388 -0
  218. package/engine/content-poetry.mjs +0 -240
  219. package/engine/web-wikilinks.mjs +0 -594
  220. package/hugo-theme/layouts/404.html +0 -85
  221. package/hugo-theme/layouts/_default/baseof.html +0 -67
  222. package/hugo-theme/layouts/_default/list.html +0 -139
  223. package/hugo-theme/layouts/_default/single.html +0 -4
  224. package/hugo-theme/layouts/blog/list.html +0 -54
  225. package/hugo-theme/layouts/blog-post/single.html +0 -36
  226. package/hugo-theme/layouts/index.html +0 -75
  227. package/hugo-theme/layouts/partials/banner-url.html +0 -141
  228. package/hugo-theme/layouts/partials/cdn-url.html +0 -36
  229. package/hugo-theme/layouts/partials/dictionary-lists.html +0 -58
  230. package/hugo-theme/layouts/partials/draft-notice.html +0 -41
  231. package/hugo-theme/layouts/partials/draft-state.html +0 -26
  232. package/hugo-theme/layouts/partials/header.html +0 -35
  233. package/hugo-theme/layouts/partials/hero-banner.html +0 -85
  234. package/hugo-theme/layouts/partials/infobox/entry.html +0 -18
  235. package/hugo-theme/layouts/partials/infobox/items.html +0 -26
  236. package/hugo-theme/layouts/partials/infobox/section.html +0 -56
  237. package/hugo-theme/layouts/partials/infobox/value.html +0 -43
  238. package/hugo-theme/layouts/partials/infobox.html +0 -57
  239. package/hugo-theme/layouts/partials/page-body.html +0 -152
  240. package/hugo-theme/layouts/partials/related.html +0 -138
  241. package/hugo-theme/layouts/partials/search.html +0 -84
  242. package/hugo-theme/layouts/partials/site-url.html +0 -32
  243. package/hugo-theme/layouts/partials/tags.html +0 -30
  244. package/hugo-theme/layouts/partials/toc.html +0 -28
  245. package/hugo-theme/layouts/partials/warn-missing-banner.html +0 -3
  246. package/hugo-theme/layouts/shortcodes/cartouche.html +0 -64
  247. package/hugo-theme/layouts/shortcodes/exturl.html +0 -12
  248. package/hugo-theme/layouts/shortcodes/glyph.html +0 -43
  249. package/hugo-theme/layouts/shortcodes/pagelist.html +0 -63
  250. package/hugo-theme/theme.toml +0 -11
  251. package/types/engine/content-poetry.d.mts +0 -58
  252. package/types/engine/web-wikilinks.d.mts +0 -107
  253. /package/{hugo-theme → site-theme}/data/banner-kinds.yaml +0 -0
  254. /package/{hugo-theme → site-theme}/static/css/sohl-icons.css +0 -0
  255. /package/{hugo-theme → site-theme}/static/fonts/game-icons-kb.woff2 +0 -0
  256. /package/{hugo-theme → site-theme}/static/img/banners/takheperu-hieroglyphs.svg +0 -0
  257. /package/{hugo-theme → site-theme}/static/img/cartouches/takheperu.svg +0 -0
  258. /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,25 @@ 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
- import { inlineVerse, poetryStanzas, scanPoetry } from "./content-poetry.mjs";
88
+ import {
89
+ COLUMNS_CLASS,
90
+ STANZA_BREAK,
91
+ lineNumber,
92
+ rightToLeft,
93
+ scanVerse,
94
+ verseColumns,
95
+ verseStanzas,
96
+ } from "./content-verse.mjs";
98
97
  import { matchAllOutsideCode, replaceOutsideCode } from "./code-fences.mjs";
99
98
  import { HTML_TAG, htmlMessage } from "./content-html.mjs";
100
99
  import { EXPRESSION } from "./markdown-expressions.mjs";
100
+ import { inlineText } from "./name-markup.mjs";
101
+ import { cartouchePathname, glyphPathname, humanizeSlug, typstLength } from "./content-glyphs.mjs";
101
102
  import { WIKILINK } from "./wikilink-syntax.mjs";
102
103
  import { EMBED_PATTERN } from "./content-embeds.mjs";
103
104
  import { positionInBody } from "./diagnostics.mjs";
@@ -188,43 +189,22 @@ export function labelFor(anchor) {
188
189
  }
189
190
 
190
191
  /**
191
- * 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.
192
195
  *
193
196
  * `html: false` is the load-bearing setting: raw HTML in a note has no route to
194
197
  * Typst at all, which is why {@link module:engine/content-html} reports it. With
195
198
  * HTML disabled markdown-it emits the tag as text, so it arrives in the book
196
- * 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.
197
202
  *
198
203
  * @param {object} [registry] - The icon registry.
199
204
  * @returns {object} A markdown-it instance.
200
205
  */
201
206
  export function createParser(registry) {
202
- const md = new MarkdownIt({ html: false, linkify: false, typographer: false });
203
- md.use(footnotePlugin);
204
- md.use(alertMarkdownPlugin);
205
- md.use(spanMarkdownPlugin);
206
- // Verse is held inside a literal fence. Parse its inline content before
207
- // footnote_tail collects definitions, using the surrounding note's env.
208
- md.core.ruler.after("inline", "poetry_inline", (state) => {
209
- for (const token of state.tokens) {
210
- if (token.type !== "fence" || !/^poetry(?:\s|$)/.test(token.info?.trim() || ""))
211
- continue;
212
- token.meta ??= {};
213
- token.meta.poetryStanzas = poetryStanzas(token.content).map((stanza) =>
214
- stanza.map(({ text, level }) => {
215
- const children = [];
216
- state.md.inline.parse(inlineVerse(text), state.md, state.env, children);
217
- return { children, level };
218
- }),
219
- );
220
- }
221
- });
222
- md.use(deflistPlugin);
223
- md.use(iconPlugin(registry));
224
- // The same plugin the HTML surfaces use, so one directive is read once and
225
- // three renderers read the same `meta` off the same token.
226
- md.use(imagePlugin());
227
- return md;
207
+ return createRenderer("book", { icons: registry }).md;
228
208
  }
229
209
 
230
210
  /**
@@ -772,20 +752,24 @@ function renderBlock(tokens, i, out, ctx) {
772
752
  }
773
753
  case "fence":
774
754
  case "code_block": {
775
- if (token.type === "fence" && /^poetry(?:\s|$)/.test(token.info?.trim() || "")) {
776
- const content = token.meta.poetryStanzas
777
- .map((stanza) =>
778
- stanza
779
- .map(({ children, level }) => {
780
- const verse = renderInline({ children }, ctx);
781
- return `${level ? `#h(${level * 1.25}em)` : ""}${verse}`;
782
- })
783
- .join(" \\\n"),
784
- )
785
- .join("\n\n");
786
- const poem = scanPoetry(
787
- `${token.markup}${token.info}\n${token.content}${token.markup}`,
788
- ).blocks[0];
755
+ if (token.type === "fence" && /^verse(?:\s|$)/.test(token.info?.trim() || "")) {
756
+ const opening = `${token.markup}${token.info}`;
757
+ const poem = scanVerse(`${opening}\n${token.content}${token.markup}`).blocks[0];
758
+ const step = Number(poem?.attributes.numstep ?? 0) || 0;
759
+ const content =
760
+ poem?.classes.includes(COLUMNS_CLASS) ?
761
+ verseGrid(token, poem, opening, step, ctx)
762
+ : step ? numberedVerse(token, step, ctx)
763
+ : token.meta.verseStanzas
764
+ .map((stanza) =>
765
+ stanza
766
+ .map(({ children, level }) => {
767
+ const verse = renderInline({ children }, ctx);
768
+ return `${level ? `#h(${level * 1.25}em)` : ""}${verse}`;
769
+ })
770
+ .join(" \\\n"),
771
+ )
772
+ .join("\n\n");
789
773
  const anchor = poem?.id ? ` <${sectionLabel(ctx.anchorPrefix, poem.id)}>` : "";
790
774
  out.push(
791
775
  `\n#block(width: 100%)[${scriptWrapped(content, poem?.classes, poem?.attributes)}]${anchor}\n`,
@@ -1251,6 +1235,82 @@ function typstLanguage(tag) {
1251
1235
  /** The classes that set a run in a font: a face's run carries `script`. */
1252
1236
  const FONT_CLASSES = Object.freeze(["script", ...Object.values(GENERIC_FONTS)]);
1253
1237
 
1238
+ /** A line number in the book: muted, as on the page. */
1239
+ const typstNumber = (number) => (number ? `[#text(fill: luma(45%))[${number}]]` : "[]");
1240
+
1241
+ /**
1242
+ * A numbered verse without columns: a grid of each line's number and the
1243
+ * line, with a row of space between stanzas.
1244
+ *
1245
+ * @param {object} token - The verse's fence token.
1246
+ * @param {number} step - Its `numstep`.
1247
+ * @param {object} ctx - Render context.
1248
+ * @returns {string} Typst markup.
1249
+ */
1250
+ function numberedVerse(token, step, ctx) {
1251
+ let count = 0;
1252
+ const stanzas = token.meta.verseStanzas.map((stanza) =>
1253
+ stanza
1254
+ .map(({ children, level }) => {
1255
+ const verse = renderInline({ children }, ctx);
1256
+ return ` ${typstNumber(lineNumber(++count, step))}, [${level ? `#h(${level * 1.25}em)` : ""}${verse}],`;
1257
+ })
1258
+ .join("\n"),
1259
+ );
1260
+ return [
1261
+ "#grid(columns: (2.5em, 1fr), column-gutter: 1em, row-gutter: 0.35em,",
1262
+ stanzas.join("\n grid.cell(colspan: 2)[#v(0.5em)],\n"),
1263
+ ")",
1264
+ ].join("\n");
1265
+ }
1266
+
1267
+ /**
1268
+ * A verse in columns: a grid with a row per verse line, its number first when
1269
+ * the verse is numbered, then each column's entries stacked, the first in the
1270
+ * normal tone and each later one grey. A part whose text runs right to left
1271
+ * is set right to left inside its cell, and the grid keeps the page's
1272
+ * direction, so the columns stay in order. A `***` line is a row of space
1273
+ * across the grid.
1274
+ *
1275
+ * @param {object} token - The expanded verse's fence token.
1276
+ * @param {object} poem - The verse, from `scanVerse`.
1277
+ * @param {string} opening - Its opening line.
1278
+ * @param {number} step - Its `numstep`.
1279
+ * @param {object} ctx - Render context.
1280
+ * @returns {string} Typst markup.
1281
+ */
1282
+ function verseGrid(token, poem, opening, step, ctx) {
1283
+ const counts = verseColumns(poem, opening).map((column) => column.entries.length);
1284
+ const width = counts.length + (step ? 1 : 0);
1285
+ const columns = [...(step ? ["2.5em"] : []), ...counts.map(() => "1fr")];
1286
+ const raw = verseStanzas(token.content);
1287
+ const rows = [];
1288
+ let count = 0;
1289
+ token.meta.verseStanzas.forEach((group, at) => {
1290
+ if (raw[at].length === 1 && STANZA_BREAK.test(raw[at][0].text)) {
1291
+ rows.push(` grid.cell(colspan: ${width})[#v(0.5em)],`);
1292
+ return;
1293
+ }
1294
+ const parts = group.map(({ children }, k) => {
1295
+ const part = renderInline({ children }, ctx);
1296
+ return rightToLeft(raw[at][k].text) ? `#set text(dir: rtl)\n${part}` : part;
1297
+ });
1298
+ let next = 0;
1299
+ const cells = counts.map((n) => {
1300
+ const stacked = parts.slice(next, (next += n));
1301
+ if (stacked.length === 1) return `[${stacked[0]}]`;
1302
+ return `[#stack(dir: ttb, spacing: 0.3em, ${stacked.map((part, k) => (k ? `[#text(fill: luma(45%))[${part}]]` : `[${part}]`)).join(", ")})]`;
1303
+ });
1304
+ const number = step ? [typstNumber(lineNumber(++count, step))] : [];
1305
+ rows.push(` ${[...number, ...cells].join(", ")},`);
1306
+ });
1307
+ return [
1308
+ `#grid(columns: (${columns.join(", ")}${columns.length === 1 ? "," : ""}), column-gutter: 1.5em, row-gutter: 0.65em,`,
1309
+ ...rows,
1310
+ ")",
1311
+ ].join("\n");
1312
+ }
1313
+
1254
1314
  /**
1255
1315
  * Wrap a block's Typst in its font, its language and its direction.
1256
1316
  *
@@ -1296,6 +1356,12 @@ function renderInline(token, ctx) {
1296
1356
  out.push(inlineRaw(child.content));
1297
1357
  break;
1298
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
+ }
1299
1365
  // Inline secrecy has no print presentation; other classes and ids survive.
1300
1366
  const classes = (child.attrGet?.("class") ?? "")
1301
1367
  .split(/\s+/)
@@ -1330,6 +1396,16 @@ function renderInline(token, ctx) {
1330
1396
  out.push(`]${id ? ` <${sectionLabel(ctx.anchorPrefix, id)}>` : ""}`);
1331
1397
  break;
1332
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;
1333
1409
  case "strong_open":
1334
1410
  out.push("#strong[");
1335
1411
  break;
@@ -1384,6 +1460,74 @@ function renderInline(token, ctx) {
1384
1460
  return out.join("");
1385
1461
  }
1386
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
+
1387
1531
  /**
1388
1532
  * The index of the inline token closing the one at `i`.
1389
1533
  *
@@ -1801,6 +1945,9 @@ export function bookTypstPreamble(runs = {}, families = {}) {
1801
1945
  // run to the trimmed edge of the paper.
1802
1946
  "#let book-title-measure = book-text-width - 2.5cm",
1803
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")',
1804
1951
  '#let book-paper = rgb("#f4efe4")',
1805
1952
  '#let book-accent = rgb("#7c3b1e")',
1806
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
+ }