@heroiclands/package-build 22.21.0 → 22.23.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 (152) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/assets/styles/content-base.css +162 -0
  3. package/bin/content-commands.mjs +77 -17
  4. package/bin/package-build.mjs +15 -2
  5. package/ci/content-format-reference.mjs +68 -1
  6. package/config.mjs +26 -0
  7. package/content-config.mjs +153 -6
  8. package/docs/api.md +120 -89
  9. package/docs/authoring/assets.md +14 -0
  10. package/docs/authoring/dates-and-calendars.md +41 -3
  11. package/docs/authoring/frontmatter.md +2 -2
  12. package/docs/authoring/links-and-markup.md +325 -35
  13. package/docs/commands.md +12 -10
  14. package/docs/configuration.md +117 -14
  15. package/docs/content-format.md +4 -2
  16. package/docs/guides/book.md +5 -4
  17. package/docs/guides/reference-pages.md +15 -0
  18. package/docs/reference/format-details.md +545 -104
  19. package/docs/reference/note-types.md +30 -13
  20. package/engine/actor-compiler.mjs +23 -0
  21. package/engine/anchored-sections.mjs +35 -4
  22. package/engine/anchors.mjs +26 -30
  23. package/engine/art-fields.mjs +118 -0
  24. package/engine/art-slots.mjs +7 -1
  25. package/engine/asset-index.mjs +160 -10
  26. package/engine/asset-replacement.mjs +93 -0
  27. package/engine/asset-types.mjs +240 -0
  28. package/engine/base-compiler.mjs +139 -22
  29. package/engine/base-styles.mjs +149 -0
  30. package/engine/bundles.mjs +13 -2
  31. package/engine/compendiums.mjs +8 -1
  32. package/engine/content-blocks.mjs +410 -0
  33. package/engine/content-embeds.mjs +19 -0
  34. package/engine/content-figures.mjs +468 -0
  35. package/engine/content-footnotes.mjs +197 -0
  36. package/engine/content-format-check.mjs +15 -12
  37. package/engine/content-format.yaml +1 -1
  38. package/engine/content-images.mjs +126 -25
  39. package/engine/content-index.mjs +38 -12
  40. package/engine/content-links.mjs +14 -6
  41. package/engine/content-tables.mjs +45 -4
  42. package/engine/content-tasklists.mjs +189 -0
  43. package/engine/docs-checks.mjs +4 -4
  44. package/engine/extension-attributes.mjs +48 -2
  45. package/engine/field-reference.mjs +31 -10
  46. package/engine/field-spec.mjs +10 -8
  47. package/engine/foreign-catalog.mjs +143 -18
  48. package/engine/frontmatter-lint.mjs +120 -65
  49. package/engine/frontmatter.mjs +3 -3
  50. package/engine/heading-attributes.mjs +321 -0
  51. package/engine/helpers.mjs +109 -41
  52. package/engine/homepage.mjs +8 -8
  53. package/engine/index.mjs +3 -0
  54. package/engine/infobox-registry.mjs +4 -1
  55. package/engine/infobox-render.mjs +15 -3
  56. package/engine/infobox.mjs +129 -15
  57. package/engine/itinerary-scenes.mjs +4 -10
  58. package/engine/journals.mjs +294 -50
  59. package/engine/lore-events.mjs +197 -0
  60. package/engine/macros.mjs +1 -1
  61. package/engine/markdown-expressions.mjs +434 -59
  62. package/engine/note-dates.mjs +354 -12
  63. package/engine/note-frontmatter.mjs +7 -16
  64. package/engine/note-vocabulary.mjs +183 -42
  65. package/engine/page-lists.mjs +358 -0
  66. package/engine/pathnames.mjs +36 -0
  67. package/engine/pdf-build.mjs +431 -74
  68. package/engine/pdf-images.mjs +64 -0
  69. package/engine/pdf-render.mjs +563 -66
  70. package/engine/rank-ladder.mjs +198 -0
  71. package/engine/reckoning-markers.mjs +1 -1
  72. package/engine/retired-fields.mjs +8 -13
  73. package/engine/scenes.mjs +26 -7
  74. package/engine/site-build.mjs +166 -23
  75. package/engine/site-config.mjs +10 -1
  76. package/engine/site-index.mjs +90 -3
  77. package/engine/site-preview.mjs +20 -7
  78. package/engine/social-ties.mjs +52 -14
  79. package/engine/sql-tables.mjs +66 -9
  80. package/engine/standings.mjs +89 -6
  81. package/engine/system-block.mjs +56 -30
  82. package/engine/web-wikilinks.mjs +48 -1
  83. package/hm3/actors.mjs +8 -5
  84. package/manifest.mjs +43 -24
  85. package/package.json +1 -1
  86. package/packagebuild-metadata.jsonl +13 -13
  87. package/sohl/actors.mjs +22 -99
  88. package/sohl/item-fields.mjs +5 -5
  89. package/sohl/items.mjs +1 -1
  90. package/sohl/note-schemas.mjs +11 -21
  91. package/types/config.d.mts +9 -0
  92. package/types/content-config.d.mts +48 -1
  93. package/types/engine/actor-compiler.d.mts +11 -0
  94. package/types/engine/anchored-sections.d.mts +24 -0
  95. package/types/engine/anchors.d.mts +9 -4
  96. package/types/engine/art-fields.d.mts +64 -0
  97. package/types/engine/asset-index.d.mts +43 -0
  98. package/types/engine/asset-replacement.d.mts +22 -0
  99. package/types/engine/asset-types.d.mts +18 -0
  100. package/types/engine/base-compiler.d.mts +19 -17
  101. package/types/engine/base-styles.d.mts +93 -0
  102. package/types/engine/content-blocks.d.mts +87 -0
  103. package/types/engine/content-embeds.d.mts +16 -0
  104. package/types/engine/content-figures.d.mts +101 -0
  105. package/types/engine/content-footnotes.d.mts +80 -0
  106. package/types/engine/content-format-check.d.mts +8 -6
  107. package/types/engine/content-images.d.mts +78 -12
  108. package/types/engine/content-index.d.mts +8 -2
  109. package/types/engine/content-tables.d.mts +12 -4
  110. package/types/engine/content-tasklists.d.mts +72 -0
  111. package/types/engine/extension-attributes.d.mts +22 -1
  112. package/types/engine/field-reference.d.mts +21 -0
  113. package/types/engine/field-spec.d.mts +20 -16
  114. package/types/engine/foreign-catalog.d.mts +39 -0
  115. package/types/engine/frontmatter.d.mts +1 -1
  116. package/types/engine/heading-attributes.d.mts +179 -0
  117. package/types/engine/helpers.d.mts +29 -10
  118. package/types/engine/homepage.d.mts +7 -7
  119. package/types/engine/index.d.mts +1 -0
  120. package/types/engine/infobox-registry.d.mts +4 -1
  121. package/types/engine/infobox-render.d.mts +6 -1
  122. package/types/engine/infobox.d.mts +6 -0
  123. package/types/engine/journals.d.mts +41 -12
  124. package/types/engine/lore-events.d.mts +14 -0
  125. package/types/engine/markdown-expressions.d.mts +46 -2
  126. package/types/engine/note-dates.d.mts +64 -1
  127. package/types/engine/note-frontmatter.d.mts +1 -4
  128. package/types/engine/note-vocabulary.d.mts +82 -6
  129. package/types/engine/page-lists.d.mts +126 -0
  130. package/types/engine/pathnames.d.mts +26 -0
  131. package/types/engine/pdf-build.d.mts +28 -6
  132. package/types/engine/pdf-images.d.mts +52 -0
  133. package/types/engine/pdf-render.d.mts +84 -2
  134. package/types/engine/rank-ladder.d.mts +32 -0
  135. package/types/engine/retired-fields.d.mts +4 -6
  136. package/types/engine/site-build.d.mts +27 -4
  137. package/types/engine/site-config.d.mts +9 -0
  138. package/types/engine/site-index.d.mts +67 -1
  139. package/types/engine/site-preview.d.mts +1 -1
  140. package/types/engine/sql-tables.d.mts +31 -7
  141. package/types/engine/standings.d.mts +21 -3
  142. package/types/engine/system-block.d.mts +37 -14
  143. package/types/engine/web-wikilinks.d.mts +3 -1
  144. package/types/hm3/actors.d.mts +21 -3
  145. package/types/manifest.d.mts +19 -23
  146. package/types/sohl/actors.d.mts +6 -7
  147. package/engine/content-admonitions.mjs +0 -114
  148. package/engine/content-captions.mjs +0 -181
  149. package/engine/content-secrets.mjs +0 -112
  150. package/types/engine/content-admonitions.d.mts +0 -24
  151. package/types/engine/content-captions.d.mts +0 -45
  152. package/types/engine/content-secrets.d.mts +0 -17
@@ -42,8 +42,15 @@ import footnotePlugin from "markdown-it-footnote";
42
42
  import deflistPlugin from "markdown-it-deflist";
43
43
  import { iconPlugin } from "./content-icons.mjs";
44
44
  import { imagePlugin, imagesIn } from "./content-images.mjs";
45
- import { resolveEmbeds } from "./content-embeds.mjs";
46
- import { foundryAddressProblem, pathnameProblem, resolvePathname } from "./pathnames.mjs";
45
+ import { embedRole, resolveEmbeds } from "./content-embeds.mjs";
46
+ import { collectAssetRecords } from "./asset-index.mjs";
47
+ import { assetImageInfoByPathname } from "./art-fields.mjs";
48
+ import {
49
+ assetPathnameKey,
50
+ foundryAddressProblem,
51
+ pathnameProblem,
52
+ resolvePathname,
53
+ } from "./pathnames.mjs";
47
54
  import log from "loglevel";
48
55
 
49
56
  import { loadPackConfig } from "./pack-config.mjs";
@@ -65,9 +72,9 @@ import { linkFindingMessage } from "./wikilink-syntax.mjs";
65
72
  import { isDraftNote } from "./note-vocabulary.mjs";
66
73
  import { DERIVED_PACKED_TYPES, NEVER_PACKED_TYPES } from "./note-claims.mjs";
67
74
  import { expandContentTables } from "./content-tables.mjs";
68
- import { renderSecretBlocks } from "./content-secrets.mjs";
69
- import { renderAdmonitions } from "./content-admonitions.mjs";
70
- import { renderCaptionBlocks, scanCaptions } from "./content-captions.mjs";
75
+ import { renderBlocks } from "./content-blocks.mjs";
76
+ import { headingAttributesPlugin } from "./heading-attributes.mjs";
77
+ import { renderFigureBlocks, scanFigures } from "./content-figures.mjs";
71
78
  import { positionInBody } from "./diagnostics.mjs";
72
79
  // The pure `sohl:` frontmatter readers live in a leaf module so the item-type
73
80
  // registry can import them without reaching back through this one.
@@ -84,6 +91,9 @@ export {
84
91
  parseValueDesc,
85
92
  } from "./frontmatter.mjs";
86
93
 
94
+ /** @type {Map<string, object>|undefined} */
95
+ let localAssetImageInfoCache;
96
+
87
97
  /**
88
98
  * The markdown renderer every surface shares.
89
99
  *
@@ -95,6 +105,9 @@ export {
95
105
  export const md = markdownit({ html: true })
96
106
  .use(footnotePlugin)
97
107
  .use(deflistPlugin)
108
+ // A heading's attribute block, written onto the heading element rather than
109
+ // typeset into it — the reading the website's renderer does for itself.
110
+ .use(headingAttributesPlugin)
98
111
  .use(
99
112
  // Resolved per render, not at import: this constant is built before any
100
113
  // configuration is read, and a package's own icons live in the
@@ -114,25 +127,65 @@ export const md = markdownit({ html: true })
114
127
  // serves a file from inside the install, so a body image's address is
115
128
  // translated by the same rule `img:` follows.
116
129
  .use(
117
- imagePlugin((src) => {
118
- // **Reported upstream, never here.** A renderer has no channel to
119
- // report through, and this one runs inside the very passes whose
120
- // job is to collect findings — a throw would take the whole lint
121
- // down and lose every other finding in the tree. So nothing reaches
122
- // this point unreported: {@link convertNoteWikilinks} refuses a body
123
- // image with no address inside the install before a compiler renders
124
- // one, and the address passes report the same pathname with a line
125
- // and a column. What is left here is a fallback for a caller with no
126
- // configuration to resolve against, where the authored pathname is
127
- // the most honest thing to emit.
128
- try {
129
- return resolveImg(src, loadPackConfig()) ?? src;
130
- } catch {
131
- return src;
132
- }
133
- }),
130
+ imagePlugin(
131
+ (src) => {
132
+ // **Reported upstream, never here.** A renderer has no channel to
133
+ // report through, and this one runs inside the very passes whose
134
+ // job is to collect findings — a throw would take the whole lint
135
+ // down and lose every other finding in the tree. So nothing reaches
136
+ // this point unreported: {@link convertNoteWikilinks} refuses a body
137
+ // image with no address inside the install before a compiler renders
138
+ // one, and the address passes report the same pathname with a line
139
+ // and a column. What is left here is a fallback for a caller with no
140
+ // configuration to resolve against, where the authored pathname is
141
+ // the most honest thing to emit.
142
+ try {
143
+ return resolveImg(src, loadPackConfig()) ?? src;
144
+ } catch {
145
+ return src;
146
+ }
147
+ },
148
+ (src) => {
149
+ // A body image is free to write the bare, own-package form,
150
+ // so the address is normalized to the pathname the cache
151
+ // below keys by before it is looked up.
152
+ try {
153
+ const key = assetPathnameKey(src, loadPackConfig());
154
+ return key ? localAssetImageInfo().get(key) : undefined;
155
+ } catch {
156
+ return undefined;
157
+ }
158
+ },
159
+ ),
134
160
  );
135
161
 
162
+ /**
163
+ * The pictures this package's own tree ships, by the pathname their address
164
+ * resolves to — read lazily and cached for the process, exactly as
165
+ * {@link loadPackConfig} is, since one compile reads one tree once.
166
+ *
167
+ * **Local only.** A picture embedded from a dependency carries no role or
168
+ * pixel size here: fetching a foreign package's own asset index to size one
169
+ * inline Foundry image is a cost this lazy cache does not pay, and the
170
+ * picture simply draws at the medium's ordinary size, as it always has.
171
+ *
172
+ * @returns {Map<string, {type: string, role?: string, width: number|"", height: number|""}>}
173
+ */
174
+ function localAssetImageInfo() {
175
+ if (localAssetImageInfoCache) return localAssetImageInfoCache;
176
+ try {
177
+ const config = loadPackConfig();
178
+ const records = collectAssetRecords(config.paths.assets, {
179
+ contentPackage: config.contentPackage,
180
+ problems: [],
181
+ });
182
+ localAssetImageInfoCache = assetImageInfoByPathname(records);
183
+ } catch {
184
+ localAssetImageInfoCache = new Map();
185
+ }
186
+ return localAssetImageInfoCache;
187
+ }
188
+
136
189
  md.renderer.rules.footnote_block_open = () =>
137
190
  '<section class="footnotes"><h2>Footnotes</h2><ol class="footnotes-list">\n';
138
191
  md.renderer.rules.footnote_caption = (tokens, idx, _options, env) => {
@@ -152,16 +205,26 @@ md.renderer.rules.footnote_open = (tokens, idx, options, env, renderer) => {
152
205
  return `<li id="fn${id}" class="footnote-item" value="${number}">`;
153
206
  };
154
207
 
155
- /** Render a note body with Foundry's secret section markup. */
156
- export function renderFoundryMarkdown(body, captions, footnoteNumbers, docId) {
157
- const { markdown } = renderSecretBlocks(body, "foundry", (inner) => md.render(inner));
158
- const admonitions = renderAdmonitions(markdown, (inner) => md.render(inner));
159
- const captioned = renderCaptionBlocks(
160
- admonitions.markdown,
161
- (block) => md.render(block),
162
- captions,
163
- );
164
- return md.render(captioned.markdown, { footnoteNumbers, docId });
208
+ /**
209
+ * Render a note body with Foundry's named-block markup.
210
+ *
211
+ * @param {string} body - The body, tables expanded and wikilinks resolved.
212
+ * @param {Array<{id: string, label: string}>} [figures] - Labels assigned by
213
+ * a note-wide scan, matched by id — see
214
+ * {@link module:engine/content-figures.renderFigureBlocks}.
215
+ * @param {Map<string, number>} [footnoteNumbers] - Shared across the note.
216
+ * @param {string} [docId] - Threaded into footnote anchors.
217
+ * @param {(address: string) => string|undefined} [resolveRole] - From a
218
+ * picture's address to the role its asset declares — see
219
+ * {@link module:engine/content-figures.scanFigures}.
220
+ * @returns {string} The rendered HTML.
221
+ */
222
+ export function renderFoundryMarkdown(body, figures, footnoteNumbers, docId, resolveRole) {
223
+ const blocks = renderBlocks(body, "foundry");
224
+ const figured = renderFigureBlocks(blocks.markdown, (block) => md.render(block), figures, {
225
+ resolveRole,
226
+ });
227
+ return md.render(figured.markdown, { footnoteNumbers, docId });
165
228
  }
166
229
 
167
230
  /**
@@ -547,11 +610,11 @@ export function makeFilename(name, id) {
547
610
  * the other.
548
611
  *
549
612
  * **`title` does not follow this rule**, and must not be made to. On a
550
- * `type: affiliation` note `title` is *also* a declared item field whose default
551
- * is `""` (`sohl/item-fields.mjs`), resolved from the very same shared top-level
552
- * key the site emitter reads as the page title — so `title: null` stringifies
553
- * into the compiled document as the literal `"null"`. One key, two destinations
554
- * that disagree about what empty means.
613
+ * `type: affiliation` note `title` is a declared item field with its own
614
+ * default (`""`, in `sohl/item-fields.mjs`), authored only inside the item's
615
+ * own `sohl` block — the closed top-level vocabulary has no `title` entry, so
616
+ * there is no shared key for this function's null-versus-blank distinction to
617
+ * apply to.
555
618
  *
556
619
  * **`banner:` does not follow it either, deliberately.** It is not a file
557
620
  * inside a Foundry install: it reaches no compiled document and no book, its
@@ -943,7 +1006,9 @@ export function convertNoteWikilinks(
943
1006
  docPack,
944
1007
  index,
945
1008
  captionLabels: new Map(
946
- scanCaptions(source).captions.map((caption) => [caption.id, caption.label]),
1009
+ scanFigures(source, { resolveRole: (address) => embedRole(index, address) })
1010
+ .figures.filter((figure) => figure.id)
1011
+ .map((figure) => [figure.id, figure.label]),
947
1012
  ),
948
1013
  });
949
1014
  /**
@@ -1035,18 +1100,21 @@ export function convertNoteWikilinks(
1035
1100
  /* ------------------------------------------------------------------------ */
1036
1101
 
1037
1102
  /**
1038
- * Expand prepared SQL tables before resolving wikilinks in generated cells.
1103
+ * Expand prepared SQL tables and page lists before resolving wikilinks in the
1104
+ * links they generate.
1039
1105
  * @param {string} body - The note body.
1040
- * @param {object} ctx - Source and prepared query results.
1106
+ * @param {object} ctx - Source and prepared results.
1041
1107
  * @param {string} ctx.name - Name used in diagnostics.
1042
1108
  * @param {number} [ctx.bodyLine] - The first body line in the source file.
1043
1109
  * @param {object[]} [ctx.sqlTables] - Prepared SQL results in document order.
1110
+ * @param {object[]} [ctx.pageLists] - Prepared page lists in document order.
1044
1111
  * @returns {{markdown: string, lineMap: Array<{line: number, generated: boolean}>}}
1045
1112
  */
1046
- export function expandNoteTables(body, { name, bodyLine, sqlTables }) {
1113
+ export function expandNoteTables(body, { name, bodyLine, sqlTables, pageLists }) {
1047
1114
  const { markdown, errors, lineMap } = expandContentTables(body ?? "", {
1048
1115
  source: name,
1049
1116
  sqlTables,
1117
+ pageLists,
1050
1118
  });
1051
1119
  if (errors.length) {
1052
1120
  const err = new Error(errors.map((e) => `content table — ${e.reason}`).join("; "));
@@ -131,12 +131,12 @@ export function isHomepage(fm) {
131
131
  * `name` and `shortcode` are permitted: the shortcode is what a link is
132
132
  * written with, and `name` titles the page like every other note's.
133
133
  *
134
- * **A named class, not an allow-list, and that boundary is the decision.** A
135
- * homepage's frontmatter is *emitted into the published page*
136
- * ({@link homepageFrontmatter}), so an unrecognised key is a Hugo or theme
137
- * parameter this build has never heard of and has no standing to refuse.
138
- * Rejecting unknown keys would make every new theme parameter wait on a
139
- * package-build release.
134
+ * **A named class, not an allow-list, and that boundary is the decision.**
135
+ * What this class answers is a key the top-level vocabulary *accepts* and a
136
+ * homepage still cannot use, which is a different question from an unknown
137
+ * key: the frontmatter lint refuses those for every note alike, naming the
138
+ * closed vocabulary, so repeating that judgement per type would be a second
139
+ * answer to one question.
140
140
  *
141
141
  * `aliases` is deliberately not in the class: it is a **retired** field, refused
142
142
  * on every note whatever its type, so it is answered there rather than
@@ -340,7 +340,7 @@ function positionOfType(file) {
340
340
  * `packageBuild.manifest.title` — the name Foundry already shows for the
341
341
  * package — so a homepage that adds nothing to it need not restate it. An
342
342
  * authored `name.full` wins, because a front page may greet a reader
343
- * differently from a package browser. A top-level `title` is also readable.
343
+ * differently from a package browser.
344
344
  *
345
345
  * Falls back to `contentPackage` last, so a package that has no manifest of its
346
346
  * own still yields a titled page rather than a blank heading.
@@ -350,7 +350,7 @@ function positionOfType(file) {
350
350
  * @returns {string} The title.
351
351
  */
352
352
  export function homepageTitle(fm, config) {
353
- const authored = fm?.name?.full ?? fm?.title;
353
+ const authored = fm?.name?.full;
354
354
  if (typeof authored === "string" && authored.trim()) return authored;
355
355
  const manifest = /** @type {Record<string, unknown>|undefined} */ (
356
356
  config?.packageBuild?.manifest
package/engine/index.mjs CHANGED
@@ -220,6 +220,9 @@ export * as contentHtml from "./content-html.mjs";
220
220
  /** An image saying how wide it is and where it sits, in two closed vocabularies. */
221
221
  export * as contentImages from "./content-images.mjs";
222
222
 
223
+ /** A list item opened with `[ ]` or `[x]`, which every surface reads as text. */
224
+ export * as contentTaskLists from "./content-tasklists.mjs";
225
+
223
226
  /** `![[…]]` — the wikilink that renders a picture where it stands. */
224
227
  export * as contentEmbeds from "./content-embeds.mjs";
225
228
 
@@ -112,11 +112,13 @@ export function compilesSystemDocument(fm, map, router) {
112
112
  * @param {object} [options.router] - The pack router deciding which system
113
113
  * compiles a document for this note. Defaults to the consuming repository's.
114
114
  * @param {object} [options.dates] - Calendar conversion context.
115
+ * @param {string} [options.contentPackage] - This build's content package,
116
+ * the default a note box's short-form Address resolves against.
115
117
  * @returns {object[]} The boxes, in the order every medium renders them.
116
118
  * @throws {Error} When the built set disagrees with what the note's type maps
117
119
  * to — see {@link module:engine/infobox.assertInfoboxSet}.
118
120
  */
119
- export function noteInfoboxes(fm, { resolve, router = packRouter(), dates } = {}) {
121
+ export function noteInfoboxes(fm, { resolve, router = packRouter(), dates, contentPackage } = {}) {
120
122
  const boxes = buildInfoboxes(fm, {
121
123
  maps: KNOWN_DOCUMENT_SUBTYPE_MAPS,
122
124
  providers: KNOWN_INFOBOXES,
@@ -124,6 +126,7 @@ export function noteInfoboxes(fm, { resolve, router = packRouter(), dates } = {}
124
126
  resolveField: resolveFieldValue,
125
127
  resolve,
126
128
  dates,
129
+ contentPackage,
127
130
  });
128
131
  return /** @type {object[]} */ (
129
132
  assertInfoboxSet(boxes, fm, { maps: KNOWN_DOCUMENT_SUBTYPE_MAPS })
@@ -36,7 +36,7 @@
36
36
 
37
37
  import { encodeAddresses } from "./address-values.mjs";
38
38
  import { sectionHolds } from "./infobox.mjs";
39
- import { escapeTypst, escapeTypstString } from "./pdf-render.mjs";
39
+ import { absolutePageUrl, escapeTypst, escapeTypstString } from "./pdf-render.mjs";
40
40
 
41
41
  /**
42
42
  * How many cells an attribute grid packs into a row.
@@ -226,16 +226,22 @@ export function sectionHasContent(section) {
226
226
  * reference, so the link works on paper as a cross-reference and in a PDF
227
227
  * viewer as a jump. Everything else is set as its own words.
228
228
  *
229
+ * A panel's rows address pages the same way prose does, so a page the book does
230
+ * not print is set as the absolute URL a reader can follow — see
231
+ * {@link module:engine/pdf-render.absolutePageUrl}.
232
+ *
229
233
  * @param {object} value - A `link` value.
230
234
  * @param {Map<string, string>} links - Address slug → the book's anchor.
231
235
  * @param {(anchor: string) => string} labelFor - The anchor's Typst label.
236
+ * @param {string} [site] - The absolute address this book's pages are served at.
232
237
  * @returns {string} Typst markup.
233
238
  */
234
- export function linkToTypst(value, links, labelFor) {
239
+ export function linkToTypst(value, links, labelFor, site) {
235
240
  const text = escapeTypst(value?.text ?? "");
236
241
  const anchor = value?.address ? links?.get(encodeAddresses(value.address)) : undefined;
237
242
  if (anchor) return `#link(<${labelFor(anchor)}>)[${text}]`;
238
- if (value?.url) return `#link("${escapeTypstString(value.url)}")[${text}]`;
243
+ if (value?.url)
244
+ return `#link("${escapeTypstString(absolutePageUrl(value.url, site))}")[${text}]`;
239
245
  return text;
240
246
  }
241
247
 
@@ -375,7 +381,13 @@ export function infoboxTypstPreamble() {
375
381
  "fill: infobox-rule)[#upper(t)]; v(0.20em) }",
376
382
  '#let infobox-head(t) = { v(0.40em); text(size: 7.6pt, weight: "bold", ' +
377
383
  "tracking: 1.3pt, fill: infobox-rule)[#upper(t)]; v(0.16em) }",
384
+ // One definition-list style across the three surfaces: the term right
385
+ // against a fixed column so every term in the panel shares one edge,
386
+ // and space below the row rather than rows run together. A page has one
387
+ // width, so the stacked state the screen surfaces fall back to has
388
+ // nothing to answer here and is not drawn.
378
389
  "#let infobox-row(k, v) = grid(columns: (2.05cm, 1fr), column-gutter: 4pt, " +
390
+ "inset: (bottom: 2pt), align: (right + top, left + top), " +
379
391
  "text(size: 7pt, tracking: 0.5pt, fill: infobox-faint)[#upper(k)], text(size: 8pt)[#v])",
380
392
  "#let infobox-cell(lab, val) = align(center)[" +
381
393
  "#text(size: 7pt, tracking: 0.6pt, fill: infobox-faint)[#lab]#h(2.5pt)" +
@@ -119,9 +119,20 @@ import { NOTE_VOCABULARY, dataFields } from "./note-vocabulary.mjs";
119
119
  import { readAliasedField } from "./retired-fields.mjs";
120
120
  import { subtypeRow } from "./document-subtypes.mjs";
121
121
  import { authoredKey } from "./system-block.mjs";
122
- import { formatDateInCalendar, formatNoteDate, parseNoteDate } from "./note-dates.mjs";
122
+ import {
123
+ formatDateInCalendar,
124
+ formatNoteDate,
125
+ parseNoteDate,
126
+ resolvedDateFields,
127
+ } from "./note-dates.mjs";
123
128
  import { displayBeingHeight, displayBeingWeight } from "./being-measurements.mjs";
124
- import { officeAnchor, officeRoster, readStandings, standingPhrase } from "./standings.mjs";
129
+ import {
130
+ officeAnchor,
131
+ officeRoster,
132
+ rankAnchor,
133
+ readStandings,
134
+ standingPhrase,
135
+ } from "./standings.mjs";
125
136
 
126
137
  /**
127
138
  * How a section arranges what it holds.
@@ -284,7 +295,7 @@ export const NOTE_FIELD_PRESENTATION = Object.freeze({
284
295
  bgImage: Object.freeze({ withheld: "an image, which the box never carries" }),
285
296
  banner: Object.freeze({ withheld: "an image, which the box never carries" }),
286
297
  overlay: Object.freeze({ withheld: "an image, which the box never carries" }),
287
- "lore.event": Object.freeze({ withheld: "chronology machinery, not a summary row" }),
298
+ "lore.events": Object.freeze({ withheld: "chronology machinery, not a summary row" }),
288
299
 
289
300
  assocSkill: Object.freeze({ label: "Skill" }),
290
301
  assocAffiliation: Object.freeze({ label: "Affiliation" }),
@@ -327,7 +338,8 @@ export const NOTE_FIELD_PRESENTATION = Object.freeze({
327
338
  }),
328
339
  "being.appearance.complexion": Object.freeze({
329
340
  group: "appearance",
330
- phrase: (v) => `${humanizeValue(v)} complexion`,
341
+ whole: true,
342
+ phrase: (v) => `${humanizedList(v)} complexion`,
331
343
  }),
332
344
  "being.appearance.extra_features": Object.freeze({ group: "appearance" }),
333
345
  });
@@ -469,6 +481,24 @@ export function humanizeValue(value) {
469
481
  .trim();
470
482
  }
471
483
 
484
+ /**
485
+ * Several enumerated values as one clause — `weathered, ruddy and scarred`.
486
+ *
487
+ * A field that admits one value or a list of them reads as a single phrase, so
488
+ * the noun it qualifies is said once rather than once per value. Internal: the
489
+ * phrase that wants it is declared in this module.
490
+ *
491
+ * @param {unknown} value - One value, or a list of them.
492
+ * @returns {string} The text.
493
+ */
494
+ function humanizedList(value) {
495
+ const parts = (Array.isArray(value) ? value : [value])
496
+ .filter((entry) => entry !== undefined && entry !== null && entry !== "")
497
+ .map((entry) => humanizeValue(entry));
498
+ if (parts.length < 2) return parts[0] ?? "";
499
+ return `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;
500
+ }
501
+
472
502
  /**
473
503
  * A value as a row shows it.
474
504
  *
@@ -633,16 +663,71 @@ function rosterRows(raw) {
633
663
  return rows;
634
664
  }
635
665
 
636
- /** Expand structured references using the existing linked-row shape. */
637
- function structuredRows(field, raw, resolve, label, fm) {
666
+ /**
667
+ * A ladder of named ranks, one row each, ordered by the level a body confers.
668
+ *
669
+ * The **title is the label and its description is the value**, the same
670
+ * convention {@link rosterRows} draws a body's offices with, so a reader
671
+ * meets the two lists the same way. A rung's optional `lore` — already
672
+ * resolved to an Address by the time this runs — reads alongside the
673
+ * description rather than replacing it, since the description is what the
674
+ * rung itself states and the lore is a pointer to more.
675
+ *
676
+ * @param {unknown} raw - The authored `governance.ranks` list.
677
+ * @param {(ref: unknown, hint?: object) => object|undefined} resolve - The
678
+ * medium's resolver.
679
+ * @returns {object[]|null} One row per rung, in ascending level, or `null`
680
+ * where the value is not a list of them.
681
+ */
682
+ function rankRows(raw, resolve) {
683
+ if (!Array.isArray(raw)) return null;
684
+ const rungs = raw
685
+ .filter((rung) => isMapping(rung) && hasValue(rung.title))
686
+ .toSorted((a, b) => Number(a.level ?? 0) - Number(b.level ?? 0));
687
+ const rows = [];
688
+ for (const rung of rungs) {
689
+ const anchor = rankAnchor(String(rung.title));
690
+ const description =
691
+ hasValue(rung.description) ? String(rung.description) : String(rung.title);
692
+ const lore = hasValue(rung.lore) ? linkValue(rung.lore, resolve, { type: "lore" }) : null;
693
+ rows.push({
694
+ ...(anchor ? { id: anchor } : {}),
695
+ label: String(rung.title),
696
+ kind: "text",
697
+ value: lore?.text ? `${description} (Lore: ${lore.text})` : description,
698
+ });
699
+ }
700
+ return rows;
701
+ }
702
+
703
+ /**
704
+ * Expand structured references using the existing linked-row shape.
705
+ *
706
+ * @param {string} [contentPackage] - This build's content package, the
707
+ * default a short-form Address resolves against — the same value the site
708
+ * build and the pack compiler read `contentPackage()` for. A row naming a
709
+ * short Address with none handed to it skips rather than guesses.
710
+ */
711
+ function structuredRows(field, raw, resolve, label, contentPackage) {
638
712
  if (field.standings) return standingRows(field, raw, resolve, label);
639
713
  if (field.roster) {
640
714
  const roster = rosterRows(raw);
641
715
  if (roster) return roster;
642
716
  }
643
- if (Array.isArray(raw) && raw.some(isMapping)) {
644
- const links = raw
645
- .filter((entry) => isMapping(entry) && hasValue(entry.to))
717
+ if (field.ranks) {
718
+ const ranks = rankRows(raw, resolve);
719
+ if (ranks) return ranks;
720
+ }
721
+ // A relationship entry — `{to, ...}` — is a mapping that is not itself an
722
+ // Address: an authored plain list of addresses normalises to a list of
723
+ // Address tuples, which `isMapping` also reports true for, and treating
724
+ // one as a relationship keyed off a `to` it never carries is what emptied
725
+ // `lore`, `parents`, `domains` and `economy` silently.
726
+ const relations =
727
+ Array.isArray(raw) ? raw.filter((entry) => isMapping(entry) && !isAddressTuple(entry)) : [];
728
+ if (relations.length) {
729
+ const links = relations
730
+ .filter((entry) => hasValue(entry.to))
646
731
  .map((entry) => {
647
732
  const link = linkValue(entry.to, resolve, { type: field.ref ?? "place" });
648
733
  const details = Object.entries(entry)
@@ -666,7 +751,7 @@ function structuredRows(field, raw, resolve, label, fm) {
666
751
  const address = parseAddress(
667
752
  target,
668
753
  {
669
- package: fm.package ?? "local",
754
+ package: contentPackage,
670
755
  system: "note",
671
756
  types: new Set(field.accepts),
672
757
  },
@@ -759,6 +844,8 @@ export function linkValue(ref, resolve, hint) {
759
844
  * @param {object} [options] - Options.
760
845
  * @param {(ref: unknown) => object|undefined} [options.resolve] - Resolves a
761
846
  * reference to `{name, url?, uuid?, address?}`.
847
+ * @param {string} [options.contentPackage] - This build's content package,
848
+ * the default a relation row's short-form Address resolves against.
762
849
  * @param {object} [options.vocabulary] - The note vocabulary to read.
763
850
  * @param {object} [options.presentation] - The overlay to read.
764
851
  * @returns {object} The box.
@@ -784,12 +871,18 @@ export function noteInfobox(fm, options = {}) {
784
871
  */
785
872
  function noteBox(
786
873
  fm,
787
- { resolve, dates, vocabulary = NOTE_VOCABULARY, presentation = NOTE_FIELD_PRESENTATION } = {},
874
+ {
875
+ resolve,
876
+ dates,
877
+ contentPackage,
878
+ vocabulary = NOTE_VOCABULARY,
879
+ presentation = NOTE_FIELD_PRESENTATION,
880
+ } = {},
788
881
  ) {
789
882
  const rows = [];
790
883
  /** @type {Set<string>} */
791
884
  const shown = new Set();
792
- const name = fm?.name?.full ?? fm?.title;
885
+ const name = fm?.name?.full;
793
886
  if (hasValue(name)) rows.push({ label: "Name", kind: "text", value: String(name) });
794
887
  if (Array.isArray(fm?.name?.aliases) && fm.name.aliases.length) {
795
888
  rows.push({ label: "Aliases", kind: "list", value: fm.name.aliases });
@@ -843,7 +936,7 @@ function noteBox(
843
936
  raw,
844
937
  resolve,
845
938
  overlay.label ?? humanizeFieldName(field.name),
846
- fm,
939
+ contentPackage,
847
940
  );
848
941
  if (structured) {
849
942
  rows.push(...structured);
@@ -857,7 +950,9 @@ function noteBox(
857
950
  label: GROUP_LABELS[overlay.group] ?? humanizeFieldName(overlay.group),
858
951
  entries: [],
859
952
  };
860
- const parts = Array.isArray(raw) ? raw.filter(hasValue) : [raw];
953
+ // A `whole` phrase reads the value entire, so several values compose
954
+ // into one clause rather than repeating the noun once each.
955
+ const parts = overlay.whole || !Array.isArray(raw) ? [raw] : raw.filter(hasValue);
861
956
  for (const part of parts) {
862
957
  group.entries.push(overlay.phrase ? overlay.phrase(part) : humanizeValue(part));
863
958
  }
@@ -879,6 +974,22 @@ function noteBox(
879
974
  shown.add(field.name);
880
975
  }
881
976
 
977
+ // `events` itself stays withheld — the family's shape is chronology
978
+ // machinery, not a summary row — but a reader meets each occurrence's
979
+ // own next date, the way a being's computed `age` reaches the box.
980
+ if (fm?.type === "lore" && Array.isArray(data.events) && data.events.length && dates) {
981
+ const resolved = resolvedDateFields(fm, dates);
982
+ for (const eventEntry of resolved.events ?? []) {
983
+ if (!eventEntry.next) continue;
984
+ rows.push({
985
+ label: "Next occurrence",
986
+ kind: "text",
987
+ value: eventEntry.next.prose ?? eventEntry.next.text,
988
+ });
989
+ shown.add("events");
990
+ }
991
+ }
992
+
882
993
  return {
883
994
  box: {
884
995
  id: NOTE_BOX_ID,
@@ -1061,6 +1172,8 @@ export function systemRowsSection(
1061
1172
  * Resolves one declared field against the note.
1062
1173
  * @param {(ref: unknown) => object|undefined} [options.resolve] - Resolves a
1063
1174
  * reference to `{name, url?, uuid?, address?}`.
1175
+ * @param {string} [options.contentPackage] - This build's content package,
1176
+ * the default a note box's short-form Address resolves against.
1064
1177
  * @param {object} [options.vocabulary] - The note vocabulary to read.
1065
1178
  * @returns {object[]} The boxes.
1066
1179
  */
@@ -1071,11 +1184,12 @@ export function buildInfoboxes(fm, options) {
1071
1184
  compilesDocument,
1072
1185
  resolveField,
1073
1186
  resolve,
1187
+ contentPackage,
1074
1188
  vocabulary = NOTE_VOCABULARY,
1075
1189
  dates,
1076
1190
  } = options;
1077
1191
 
1078
- const { box: note, shown: taken } = noteBox(fm, { resolve, vocabulary, dates });
1192
+ const { box: note, shown: taken } = noteBox(fm, { resolve, vocabulary, dates, contentPackage });
1079
1193
  const boxes = [note];
1080
1194
 
1081
1195
  for (const map of maps ?? []) {
@@ -15,18 +15,10 @@ import { loadForeignIndexes } from "./metadata-index.mjs";
15
15
  import { authoredFrontmatter, isNoteRecord } from "./index-records.mjs";
16
16
  import { buildScene } from "./map-notes.mjs";
17
17
  import { buildJournalEntry } from "./journals.mjs";
18
+ import { imageDimensions } from "./asset-types.mjs";
18
19
  import { makeId } from "./ids.mjs";
19
20
  import { stripAdventureKeys } from "./bundle-notes.mjs";
20
21
 
21
- /** The PNG's dimensions, from its fixed header. */
22
- function pngDimensions(file) {
23
- const bytes = fs.readFileSync(file);
24
- if (bytes.toString("hex", 0, 8) !== "89504e470d0a1a0a") {
25
- throw new Error(`${file} is not a PNG`);
26
- }
27
- return [bytes.readUInt32BE(16), bytes.readUInt32BE(20)];
28
- }
29
-
30
22
  /** GraphViz's plain coordinates, converted to pixel positions on its PNG. */
31
23
  export function pinPositions(plain, width, height) {
32
24
  const graph = /^graph\s+\S+\s+(\S+)\s+(\S+)/m.exec(plain);
@@ -111,7 +103,9 @@ export function buildItineraryScenes({
111
103
  const plain = path.join(outDir, `from-${centre}.plain`);
112
104
  renderDot(dot, png, { engine: "neato", format: "png", nop: 2, dpi: 150 });
113
105
  renderDot(dot, plain, { engine: "neato", format: "plain", nop: 2 });
114
- const [width, height] = pngDimensions(png);
106
+ const size = imageDimensions(png, ".png");
107
+ if (!size) throw new Error(`${png} carries no readable PNG size`);
108
+ const { width, height } = size;
115
109
  const positions = pinPositions(fs.readFileSync(plain, "utf8"), width, height);
116
110
  const sceneId = makeId("itinerary-scene", `${config.contentPackage}:${centre}`);
117
111
  const src = `${buildConfig.packageKind}/${buildConfig.packageId}/assets/maps/generated-itineraries/from-${centre}.png`;