@heroiclands/package-build 22.15.0 → 22.16.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 (223) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/README.md +39 -678
  3. package/bin/{content-build.mjs → content-commands.mjs} +197 -118
  4. package/bin/package-build.mjs +303 -42
  5. package/bin/report.mjs +1 -1
  6. package/ci/ci-docker.mjs +6 -3
  7. package/ci/ci-steps.mjs +6 -4
  8. package/ci/content-format-reference.mjs +106 -0
  9. package/config.mjs +22 -2
  10. package/content-config.mjs +74 -134
  11. package/docs/api.md +294 -234
  12. package/docs/authoring/assets.md +31 -0
  13. package/docs/authoring/dates-and-calendars.md +33 -0
  14. package/docs/authoring/first-note.md +48 -0
  15. package/docs/authoring/frontmatter.md +40 -0
  16. package/docs/authoring/links-and-markup.md +137 -0
  17. package/docs/commands.md +631 -162
  18. package/docs/configuration.md +176 -140
  19. package/docs/content-format.md +50 -4229
  20. package/docs/design.md +32 -0
  21. package/docs/diagnostics.md +29 -22
  22. package/docs/getting-started.md +21 -877
  23. package/docs/guides/book.md +153 -0
  24. package/docs/guides/e2e.md +143 -0
  25. package/docs/guides/icons.md +126 -0
  26. package/docs/guides/localization.md +120 -0
  27. package/docs/guides/module-project.md +915 -0
  28. package/docs/guides/packs.md +167 -0
  29. package/docs/guides/reference-pages.md +148 -0
  30. package/docs/guides/release-and-deploy.md +114 -0
  31. package/docs/guides/site.md +152 -0
  32. package/docs/guides/system-project.md +45 -0
  33. package/docs/index.md +16 -0
  34. package/docs/project-setup.md +54 -35
  35. package/docs/reference/format-details.md +4420 -0
  36. package/docs/reference/note-types.md +531 -0
  37. package/engine/address-render.mjs +26 -0
  38. package/engine/address.mjs +3 -20
  39. package/engine/anchors.mjs +15 -6
  40. package/engine/asset-bindings.mjs +6 -2
  41. package/engine/base-compiler.mjs +47 -11
  42. package/engine/being-measurements.mjs +70 -0
  43. package/engine/calendar-artifacts.mjs +1 -1
  44. package/engine/calendar-choice.mjs +32 -0
  45. package/engine/calendar-human.mjs +71 -0
  46. package/engine/calendar-notes.mjs +41 -3
  47. package/engine/calendars.mjs +8 -1
  48. package/engine/changelog-lint.mjs +2 -2
  49. package/engine/code-fences.mjs +23 -97
  50. package/engine/compile-corpus.mjs +6 -7
  51. package/engine/content-admonitions.mjs +114 -0
  52. package/engine/content-captions.mjs +181 -0
  53. package/engine/content-embeds.mjs +2 -2
  54. package/engine/content-footnotes.mjs +34 -0
  55. package/engine/content-format-check.mjs +2 -2
  56. package/engine/content-format.mjs +127 -73
  57. package/engine/content-format.yaml +745 -0
  58. package/engine/content-html.mjs +1 -1
  59. package/engine/content-icons.mjs +45 -92
  60. package/engine/content-images.mjs +22 -74
  61. package/engine/content-index.mjs +12 -3
  62. package/engine/content-links.mjs +21 -26
  63. package/engine/content-lint.mjs +1 -5
  64. package/engine/content-secrets.mjs +3 -1
  65. package/engine/content-tables.mjs +47 -1257
  66. package/engine/culture-choice.mjs +32 -0
  67. package/engine/date-conversion.mjs +47 -0
  68. package/engine/declaration-check.mjs +87 -0
  69. package/engine/docs-checks.mjs +140 -0
  70. package/engine/documentation-links.mjs +31 -0
  71. package/engine/exported-scene.mjs +1 -1
  72. package/engine/extension-attributes.mjs +74 -0
  73. package/engine/field-reference.mjs +1 -1
  74. package/engine/field-spec.mjs +4 -0
  75. package/engine/foreign-catalog.mjs +5 -5
  76. package/engine/format-generated.mjs +15 -0
  77. package/engine/foundry-entries.mjs +3 -1
  78. package/engine/frontmatter-lint.mjs +5 -1
  79. package/engine/frontmatter.mjs +10 -0
  80. package/engine/generate.mjs +2 -2
  81. package/engine/helpers.mjs +71 -117
  82. package/engine/homepage.mjs +2 -3
  83. package/engine/index-records.mjs +1 -0
  84. package/engine/index.mjs +18 -3
  85. package/engine/infobox-registry.mjs +3 -1
  86. package/engine/infobox.mjs +38 -20
  87. package/engine/item-compiler.mjs +10 -8
  88. package/engine/item-docs.mjs +7 -6
  89. package/engine/itinerary-scenes.mjs +4 -2
  90. package/engine/journals.mjs +51 -10
  91. package/engine/macros.mjs +1 -1
  92. package/engine/map-build.mjs +1 -1
  93. package/engine/map-dot.mjs +1 -1
  94. package/engine/map-raster.mjs +4 -2
  95. package/engine/markdown-expressions.mjs +117 -0
  96. package/engine/metadata-index.mjs +7 -3
  97. package/engine/note-addresses.mjs +1 -0
  98. package/engine/note-dates.mjs +283 -61
  99. package/engine/note-package.mjs +5 -25
  100. package/engine/note-state.mjs +6 -2
  101. package/engine/note-vocabulary.mjs +74 -19
  102. package/engine/number-words.mjs +75 -0
  103. package/engine/office-holders.mjs +188 -0
  104. package/engine/pack-config.mjs +2 -15
  105. package/engine/pack-router.mjs +24 -5
  106. package/engine/packages.mjs +3 -2
  107. package/engine/pathnames.mjs +21 -24
  108. package/engine/pdf-build.mjs +217 -55
  109. package/engine/pdf-fonts.mjs +2 -2
  110. package/engine/pdf-images.mjs +82 -0
  111. package/engine/pdf-render.mjs +174 -22
  112. package/engine/populations.mjs +3 -2
  113. package/engine/project-init.mjs +499 -0
  114. package/engine/prose-config.mjs +1 -1
  115. package/engine/prose-lint.mjs +7 -6
  116. package/engine/readability-lint.mjs +86 -0
  117. package/engine/reckoning-markers.mjs +205 -93
  118. package/engine/scenes.mjs +5 -2
  119. package/engine/schema-check.mjs +3 -3
  120. package/engine/site-build.mjs +87 -37
  121. package/engine/site-config.mjs +8 -8
  122. package/engine/site-index.mjs +4 -0
  123. package/engine/site-maps.mjs +1 -1
  124. package/engine/site-preview.mjs +19 -8
  125. package/engine/social-ties.mjs +5 -1
  126. package/engine/sql-tables.mjs +92 -22
  127. package/engine/web-wikilinks.mjs +15 -31
  128. package/engine/wikilink-syntax.mjs +4 -1
  129. package/engine/wikilinks.mjs +12 -14
  130. package/hm3/item-fields.mjs +1 -1
  131. package/hm3/items.mjs +12 -45
  132. package/index.mjs +2 -14
  133. package/manifest.mjs +31 -4
  134. package/markdownlint-config.mjs +1 -1
  135. package/package.json +32 -11
  136. package/prettier-config.mjs +1 -1
  137. package/sohl/being-info.mjs +3 -145
  138. package/sohl/index.mjs +1 -1
  139. package/sohl/infobox.mjs +2 -1
  140. package/sohl/item-builders.mjs +3 -27
  141. package/sohl/item-fields.mjs +61 -22
  142. package/sohl/kb-passes.mjs +2 -2
  143. package/sohl/note-schemas.mjs +0 -8
  144. package/stage.mjs +19 -0
  145. package/types/config.d.mts +12 -3
  146. package/types/content-config.d.mts +15 -81
  147. package/types/engine/address-render.d.mts +13 -0
  148. package/types/engine/address.d.mts +2 -11
  149. package/types/engine/anchors.d.mts +3 -4
  150. package/types/engine/asset-bindings.d.mts +9 -2
  151. package/types/engine/base-compiler.d.mts +1 -2
  152. package/types/engine/being-measurements.d.mts +16 -0
  153. package/types/engine/calendar-choice.d.mts +14 -0
  154. package/types/engine/calendar-human.d.mts +4 -0
  155. package/types/engine/calendar-notes.d.mts +2 -0
  156. package/types/engine/calendars.d.mts +5 -3
  157. package/types/engine/code-fences.d.mts +6 -72
  158. package/types/engine/compile-corpus.d.mts +1 -3
  159. package/types/engine/content-admonitions.d.mts +24 -0
  160. package/types/engine/content-captions.d.mts +45 -0
  161. package/types/engine/content-footnotes.d.mts +10 -0
  162. package/types/engine/content-format-check.d.mts +17 -4
  163. package/types/engine/content-format.d.mts +24 -11
  164. package/types/engine/content-icons.d.mts +14 -23
  165. package/types/engine/content-images.d.mts +2 -2
  166. package/types/engine/content-index.d.mts +9 -2
  167. package/types/engine/content-links.d.mts +3 -5
  168. package/types/engine/content-tables.d.mts +11 -140
  169. package/types/engine/culture-choice.d.mts +14 -0
  170. package/types/engine/date-conversion.d.mts +4 -0
  171. package/types/engine/declaration-check.d.mts +24 -0
  172. package/types/engine/docs-checks.d.mts +28 -0
  173. package/types/engine/documentation-links.d.mts +7 -0
  174. package/types/engine/exported-scene.d.mts +6 -2
  175. package/types/engine/extension-attributes.d.mts +14 -0
  176. package/types/engine/field-spec.d.mts +9 -0
  177. package/types/engine/format-generated.d.mts +7 -0
  178. package/types/engine/frontmatter.d.mts +14 -4
  179. package/types/engine/generate.d.mts +1 -1
  180. package/types/engine/helpers.d.mts +37 -92
  181. package/types/engine/homepage.d.mts +2 -3
  182. package/types/engine/index.d.mts +5 -0
  183. package/types/engine/infobox-registry.d.mts +3 -1
  184. package/types/engine/infobox.d.mts +17 -6
  185. package/types/engine/item-compiler.d.mts +4 -4
  186. package/types/engine/item-docs.d.mts +4 -3
  187. package/types/engine/journals.d.mts +10 -7
  188. package/types/engine/map-graphviz.d.mts +1 -1
  189. package/types/engine/markdown-expressions.d.mts +17 -0
  190. package/types/engine/note-dates.d.mts +47 -1
  191. package/types/engine/note-package.d.mts +3 -19
  192. package/types/engine/note-state.d.mts +2 -1
  193. package/types/engine/note-vocabulary.d.mts +9 -0
  194. package/types/engine/number-words.d.mts +4 -0
  195. package/types/engine/office-holders.d.mts +14 -0
  196. package/types/engine/pack-config.d.mts +1 -1
  197. package/types/engine/packages.d.mts +3 -2
  198. package/types/engine/pdf-images.d.mts +38 -0
  199. package/types/engine/populations.d.mts +2 -2
  200. package/types/engine/project-init.d.mts +17 -0
  201. package/types/engine/prose-config.d.mts +1 -1
  202. package/types/engine/prose-lint.d.mts +3 -2
  203. package/types/engine/readability-lint.d.mts +22 -0
  204. package/types/engine/reckoning-markers.d.mts +4 -2
  205. package/types/engine/schema-check.d.mts +3 -3
  206. package/types/engine/site-build.d.mts +38 -28
  207. package/types/engine/site-config.d.mts +2 -2
  208. package/types/engine/site-index.d.mts +4 -0
  209. package/types/engine/site-preview.d.mts +1 -1
  210. package/types/engine/social-ties.d.mts +8 -2
  211. package/types/engine/sql-tables.d.mts +13 -2
  212. package/types/engine/web-wikilinks.d.mts +2 -1
  213. package/types/engine/wikilinks.d.mts +7 -1
  214. package/types/hm3/items.d.mts +5 -6
  215. package/types/labels.d.mts +40 -0
  216. package/types/manifest.d.mts +3 -3
  217. package/types/sohl/actors.d.mts +2 -2
  218. package/types/sohl/being-info.d.mts +3 -43
  219. package/types/sohl/index.d.mts +1 -1
  220. package/types/sohl/infobox.d.mts +5 -2
  221. package/types/sohl/item-fields.d.mts +4 -13
  222. package/types/stage.d.mts +9 -0
  223. package/CONTENT.md +0 -3239
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  /**
15
- * Shared helpers for the pack compilers in `packages/content-build/`.
15
+ * Shared helpers for the pack compilers in `packages/package-build/`.
16
16
  *
17
17
  * The HeroicLands vault is authoritative for compendium item data. Pack
18
18
  * compilers walk the vault, read markdown files with YAML frontmatter, and
@@ -37,6 +37,8 @@ import path from "path";
37
37
  import yaml from "yaml";
38
38
  import unidecode from "unidecode";
39
39
  import markdownit from "markdown-it";
40
+ import footnotePlugin from "markdown-it-footnote";
41
+ import deflistPlugin from "markdown-it-deflist";
40
42
  import { iconPlugin } from "./content-icons.mjs";
41
43
  import { imagePlugin, imagesIn } from "./content-images.mjs";
42
44
  import { resolveEmbeds } from "./content-embeds.mjs";
@@ -46,7 +48,6 @@ import log from "loglevel";
46
48
  import { loadPackConfig } from "./pack-config.mjs";
47
49
  import { declaresNoPack, packRouter } from "./pack-router.mjs";
48
50
  import { contentPackage, foundryPackageId } from "./content-package.mjs";
49
- import { searchableFrontmatter } from "./note-package.mjs";
50
51
  import { PACKAGE_BASE } from "./content-address.mjs";
51
52
  import { resolveNoteId } from "./note-ids.mjs";
52
53
  import { loadForeignIndexes, noContentIndexPackages } from "./metadata-index.mjs";
@@ -61,8 +62,11 @@ import { linkFindingMessage } from "./wikilink-syntax.mjs";
61
62
  // The declared tag vocabulary, which is where `draft` is stated. Read
62
63
  // from there rather than respelt, so the tag and its one reader cannot drift.
63
64
  import { isDraftNote } from "./note-vocabulary.mjs";
65
+ import { DERIVED_PACKED_TYPES, NEVER_PACKED_TYPES } from "./note-claims.mjs";
64
66
  import { expandContentTables } from "./content-tables.mjs";
65
67
  import { renderSecretBlocks } from "./content-secrets.mjs";
68
+ import { renderAdmonitions } from "./content-admonitions.mjs";
69
+ import { renderCaptionBlocks, scanCaptions } from "./content-captions.mjs";
66
70
  import { positionInBody } from "./diagnostics.mjs";
67
71
  // The pure `sohl:` frontmatter readers live in a leaf module so the item-type
68
72
  // registry can import them without reaching back through this one.
@@ -88,6 +92,8 @@ export {
88
92
  * the two HTML surfaces and be silently dropped by the third.
89
93
  */
90
94
  export const md = markdownit({ html: true })
95
+ .use(footnotePlugin)
96
+ .use(deflistPlugin)
91
97
  .use(
92
98
  // Resolved per render, not at import: this constant is built before any
93
99
  // configuration is read, and a package's own icons live in the
@@ -126,10 +132,35 @@ export const md = markdownit({ html: true })
126
132
  }),
127
133
  );
128
134
 
135
+ md.renderer.rules.footnote_block_open = () =>
136
+ '<section class="footnotes"><h2>Footnotes</h2><ol class="footnotes-list">\n';
137
+ md.renderer.rules.footnote_caption = (tokens, idx, _options, env) => {
138
+ const meta = tokens[idx].meta;
139
+ const number = env.footnoteNumbers?.get(meta.label) ?? meta.id + 1;
140
+ return `[${number}${meta.subId > 0 ? `:${meta.subId}` : ""}]`;
141
+ };
142
+ md.renderer.rules.footnote_anchor_name = (tokens, idx, _options, env) => {
143
+ const meta = tokens[idx].meta;
144
+ const number = env.footnoteNumbers?.get(meta.label) ?? meta.id + 1;
145
+ return `${env.docId ? `-${env.docId}-` : ""}${number}`;
146
+ };
147
+ md.renderer.rules.footnote_open = (tokens, idx, options, env, renderer) => {
148
+ const meta = tokens[idx].meta;
149
+ const number = env.footnoteNumbers?.get(meta.label) ?? meta.id + 1;
150
+ const id = renderer.rules.footnote_anchor_name(tokens, idx, options, env, renderer);
151
+ return `<li id="fn${id}" class="footnote-item" value="${number}">`;
152
+ };
153
+
129
154
  /** Render a note body with Foundry's secret section markup. */
130
- export function renderFoundryMarkdown(body) {
155
+ export function renderFoundryMarkdown(body, captions, footnoteNumbers, docId) {
131
156
  const { markdown } = renderSecretBlocks(body, "foundry", (inner) => md.render(inner));
132
- return md.render(markdown);
157
+ const admonitions = renderAdmonitions(markdown, (inner) => md.render(inner));
158
+ const captioned = renderCaptionBlocks(
159
+ admonitions.markdown,
160
+ (block) => md.render(block),
161
+ captions,
162
+ );
163
+ return md.render(captioned.markdown, { footnoteNumbers, docId });
133
164
  }
134
165
 
135
166
  /**
@@ -143,6 +174,10 @@ export function renderFoundryMarkdown(body) {
143
174
  * {@link positionInBody}. If the file has no frontmatter block, returns
144
175
  * `{ frontmatter: null, body: "", description: "" }` with a warn log, and no
145
176
  * position: there is no body to have one.
177
+ * @param {string} filePath - Markdown file to read.
178
+ * @param {{addressContext?: object}} [opts] - Address defaults for decoded fields.
179
+ * @returns {{frontmatter: object|null, body: string, description: string,
180
+ * bodyLine?: number, bodyColumn?: number}} Parsed note and body position.
146
181
  */
147
182
  export function parseMarkdownFile(filePath, { addressContext } = {}) {
148
183
  const content = fs.readFileSync(filePath, "utf8");
@@ -257,6 +292,7 @@ export function assertSuppliedCorpus(records, who) {
257
292
  * @param {readonly string[]} opts.skipDirectories - Directory names to ignore.
258
293
  * Required: the scope is the caller's to state, so two passes cannot
259
294
  * disagree about which files are the corpus.
295
+ * @param {object} [opts.addressContext] - Address resolution context.
260
296
  * @yields {{frontmatter: object|null, body: string, description: string,
261
297
  * file: string, absPath: string, bodyLine?: number, bodyColumn?: number}}
262
298
  * One entry per `.md` file found.
@@ -470,6 +506,9 @@ export function systemTemplatePriority(fm, label) {
470
506
  /**
471
507
  * Generates a compendium-source filename: `Name_id.json` with non-
472
508
  * alphanumeric runs replaced by underscores.
509
+ * @param {string} name - Document name.
510
+ * @param {string} id - Document id.
511
+ * @returns {string} Filename for the document.
473
512
  */
474
513
  export function makeFilename(name, id) {
475
514
  return `${unidecode(name)}_${id}`.replace(/[^0-9a-zA-Z]+/g, "_") + ".json";
@@ -531,6 +570,9 @@ export function resolveImg(raw, config = loadPackConfig()) {
531
570
  /**
532
571
  * Resolves the display name from frontmatter, preferring `name.full`,
533
572
  * falling back to `name` (if string), then `defaultValue`.
573
+ * @param {object|null|undefined} fm - Parsed frontmatter.
574
+ * @param {string} [defaultValue] - Name when the note provides none.
575
+ * @returns {string} Display name.
534
576
  */
535
577
  export function resolveName(fm, defaultValue = "Unnamed") {
536
578
  const fullName = getFrontmatter(fm, "name.full", null);
@@ -707,20 +749,16 @@ import { collectAnchors } from "./anchors.mjs";
707
749
  * the index and the compile agree about where each note landed; defaults to
708
750
  * this repository's own.
709
751
  * @param {object} [opts]
710
- * @param {readonly string[]} [opts.skipDirectories] - Part of the options bag
711
- * every corpus reader takes; the scope is already settled by `records`.
712
752
  * @param {object} [opts.config] - The resolved build configuration; loaded when
713
753
  * omitted.
714
754
  * @param {readonly object[]} [opts.records] - The corpus, derived once per
715
755
  * compile and handed in. Required: see {@link assertSuppliedCorpus}.
716
- * @param {object[]} [opts.problems] - Part of the same options bag; the notes
717
- * the index cannot record are collected where the corpus is derived.
718
756
  * @returns {{byShortcode: Map, types: Set}} From `buildWikilinkIndex`.
719
757
  */
720
758
  export function buildContentLinkIndex(
721
759
  contentBase,
722
760
  router = packRouter(),
723
- { skipDirectories, config, records, problems } = {},
761
+ { config, records } = {},
724
762
  ) {
725
763
  const docs = [];
726
764
  /** The files this package ships, by canonical address. */
@@ -764,17 +802,21 @@ export function buildContentLinkIndex(
764
802
  assertTypeNotRetired(fm.type, absPath);
765
803
  const base = String(record.file.name).replace(/_/g, " ");
766
804
  const documentation = recordsByAddress.get(encodeAddresses(record.documentation));
805
+ const noOwnDocument =
806
+ declaresNoPack(fm) ||
807
+ NEVER_PACKED_TYPES.has(fm.type) ||
808
+ DERIVED_PACKED_TYPES.has(fm.type);
767
809
  docs.push({
768
810
  documentationUuid: documentation?.foundry?.none?.uuid ?? null,
769
811
  type: fm.type,
770
812
  id: fm.id,
771
813
  // Where this note's own document lands, and where the JournalEntry
772
814
  // its prose compiles into lands — two documents, two packs.
773
- pack: router.resolveOrNull(fm, packForType(fm.type).docType),
774
- docPack: router.resolveOrNull(fm, "JournalEntry"),
775
- // Whether the note declares `pack: none` and so has no document
776
- // at all: a link to it names a page, never a compendium entry.
777
- none: declaresNoPack(fm),
815
+ pack: noOwnDocument ? null : router.resolveOrNull(fm, packForType(fm.type).docType),
816
+ docPack: noOwnDocument ? null : router.resolveOrNull(fm, "JournalEntry"),
817
+ // A note with no single document still has an address, but no
818
+ // compendium UUID for a wikilink to open.
819
+ none: noOwnDocument,
778
820
  shortcode: fm.shortcode ?? null,
779
821
  // What the note *is*, carried so a caller resolving a reference
780
822
  // can group by the family its target declares rather than only by
@@ -812,7 +854,7 @@ export function buildContentLinkIndex(
812
854
  }
813
855
  throw new Error(
814
856
  "Cross-package links cannot be resolved from an unusable index; " +
815
- "re-run `content-build deps fetch`.",
857
+ "re-run `package-build deps fetch`.",
816
858
  );
817
859
  }
818
860
  log.debug(
@@ -883,6 +925,9 @@ export function convertNoteWikilinks(
883
925
  pack,
884
926
  docPack,
885
927
  index,
928
+ captionLabels: new Map(
929
+ scanCaptions(source).captions.map((caption) => [caption.id, caption.label]),
930
+ ),
886
931
  });
887
932
  /**
888
933
  * Where one unresolved link sits, in file coordinates.
@@ -973,115 +1018,21 @@ export function convertNoteWikilinks(
973
1018
  /* ------------------------------------------------------------------------ */
974
1019
 
975
1020
  /**
976
- * Every note in the content tree, in the shape the `dataview` table expander
977
- * searches: its frontmatter plus where it sits in the tree. Ordered by path so
978
- * a table that leaves rows tied still emits identically on every build.
979
- *
980
- * @param {string} contentBase - Root of the content tree.
981
- * @param {object} [opts]
982
- * @param {readonly string[]} [opts.skipDirectories] - Part of the options bag
983
- * every corpus reader takes; the scope is already settled by `records`.
984
- * @param {object} [opts.config] - The resolved build configuration; loaded when
985
- * omitted.
986
- * @param {readonly object[]} [opts.records] - The corpus, derived once per
987
- * compile and handed in. Required: see {@link assertSuppliedCorpus}.
988
- * @param {object[]} [opts.problems] - Part of the same options bag; the notes
989
- * the walk cannot read are collected where the corpus is derived.
990
- * @returns {Array<{fm: object, path: string, tld: string, folder: string,
991
- * absPath: string}>}
992
- */
993
- export function collectContentDocs(
994
- contentBase,
995
- { skipDirectories, config, records, problems } = {},
996
- ) {
997
- const docs = [];
998
- const resolved = config ?? loadPackConfig();
999
- assertSuppliedCorpus(records, "collectContentDocs");
1000
- for (const record of records) {
1001
- if (!isNoteRecord(record)) continue;
1002
- const fm = authoredFrontmatter(record);
1003
- const absPath = noteFile(contentBase, record);
1004
- const segments = String(record.file.path).split("/");
1005
- docs.push({
1006
- // With its package supplied for a `WHERE … package = "…"` query —
1007
- // synthesised from the configuration this build resolved, since no
1008
- // note declares it and the ambient one is a different
1009
- // configuration in a worktree or under `PACKAGE_BUILD_CONFIG`.
1010
- fm: searchableFrontmatter(fm, resolved.contentPackage),
1011
- // POSIX-separated and relative to the content root — what a
1012
- // `path:` search term globs, on every platform.
1013
- path: segments.join("/"),
1014
- tld: segments[0],
1015
- folder: segments[segments.length - 2] ?? segments[0],
1016
- absPath,
1017
- });
1018
- }
1019
- docs.sort((a, b) =>
1020
- a.absPath < b.absPath ? -1
1021
- : a.absPath > b.absPath ? 1
1022
- : 0,
1023
- );
1024
- log.debug(`Content table index: ${docs.length} searchable note(s)`);
1025
- return docs;
1026
- }
1027
-
1028
- /**
1029
- * A note is linkable from a generated table cell when it carries the identity
1030
- * {@link convertWikilinks} addresses it by — a `type` and a `shortcode`. Every
1031
- * type routes to a pack ({@link packForType}), so nothing else can make a note
1032
- * unlinkable; a note missing either renders as plain text rather than shipping a
1033
- * literal wikilink into a journal.
1034
- */
1035
- const packLinkable = (doc) => Boolean(doc.fm?.shortcode) && Boolean(doc.fm?.type);
1036
-
1037
- /**
1038
- * Expand the fenced `dataview` tables in one note's markdown, before wikilinks
1039
- * are resolved — so a generated cell may itself be a wikilink.
1040
- *
1041
- * A table searches the whole tree, which is one package's notes and nothing
1042
- * else — so there is no longer a package to scope on. It used to filter, back
1043
- * when a tree could hold several packages' notes and `package:` said which was
1044
- * which; that field is retired and the filter with it.
1045
- *
1046
- * @param {string} body - The note's markdown body.
1047
- * @param {object} ctx
1048
- * @param {Array<object>} ctx.docs - From {@link collectContentDocs}.
1049
- * @param {string} ctx.name - The note, for the error message.
1050
- * @param {object} [ctx.fm] - The source note's frontmatter, which is what a
1051
- * query's `this` reads. Its entry in `docs` supplies the path as well.
1052
- * @param {number} [ctx.bodyLine] - 1-based file line of the body's first line,
1053
- * so a failing directive can be reported at its position in the file.
1054
- * @param {object[]} [ctx.sqlTables] - This note's prepared `sql` results, in
1055
- * document order, from
1056
- * {@link module:engine/sql-tables.prepareSqlTables}. An `sql` directive with
1057
- * no prepared result fails the note: nothing here runs a query.
1058
- * @returns {{markdown: string, lineMap: Array<{line: number,
1059
- * generated: boolean}>}} The body with every table expanded, and where each
1060
- * emitted line came from — which is what lets a diagnostic about the
1061
- * expanded body name an authored position.
1062
- * @throws {Error} When a query is malformed or unsupported — the note fails to
1063
- * compile rather than shipping a table-shaped hole. The error carries
1064
- * `position`, the directive's own line.
1021
+ * Expand prepared SQL tables before resolving wikilinks in generated cells.
1022
+ * @param {string} body - The note body.
1023
+ * @param {object} ctx - Source and prepared query results.
1024
+ * @param {string} ctx.name - Name used in diagnostics.
1025
+ * @param {number} [ctx.bodyLine] - The first body line in the source file.
1026
+ * @param {object[]} [ctx.sqlTables] - Prepared SQL results in document order.
1027
+ * @returns {{markdown: string, lineMap: Array<{line: number, generated: boolean}>}}
1065
1028
  */
1066
- export function expandNoteTables(body, { docs, name, fm, bodyLine, sqlTables }) {
1067
- const self =
1068
- fm ?
1069
- (docs.find((d) => d.fm?.id && d.fm.id === fm.id) ?? {
1070
- fm: searchableFrontmatter(fm),
1071
- })
1072
- : undefined;
1029
+ export function expandNoteTables(body, { name, bodyLine, sqlTables }) {
1073
1030
  const { markdown, errors, lineMap } = expandContentTables(body ?? "", {
1074
- docs,
1075
- linkable: packLinkable,
1076
1031
  source: name,
1077
- self,
1078
1032
  sqlTables,
1079
1033
  });
1080
1034
  if (errors.length) {
1081
1035
  const err = new Error(errors.map((e) => `content table — ${e.reason}`).join("; "));
1082
- // The first failing directive's line. Reporting one position for a
1083
- // message that may name several is honest here: a caller opens the
1084
- // file at the first thing to fix, and the message lists the rest.
1085
1036
  if (bodyLine !== undefined && errors[0].line !== undefined) {
1086
1037
  err.position = { line: bodyLine + errors[0].line };
1087
1038
  }
@@ -1098,6 +1049,9 @@ export function expandNoteTables(body, { docs, name, fm, bodyLine, sqlTables })
1098
1049
  * Builds a compendium-source filename for a folder JSON document:
1099
1050
  * `folder_Name_id.json` with non-alphanumeric runs replaced by
1100
1051
  * underscores.
1052
+ * @param {string} name - Folder name.
1053
+ * @param {string} id - Folder id.
1054
+ * @returns {string} Filename for the folder.
1101
1055
  */
1102
1056
  export function folderFilename(name, id) {
1103
1057
  return `folder_${unidecode(name)}_${id}`.replace(/[^0-9a-zA-Z]+/g, "_") + ".json";
@@ -264,9 +264,8 @@ export function checkHomepageAddressFields(fm, { isAuthored } = {}) {
264
264
  * {@link module:engine/diagnostics}. {@link lintContentTree} already reports an
265
265
  * empty walk against the same locator.
266
266
  *
267
- * The rule reads no `site:` configuration and does not vary by
268
- * `publish.site`: that setting chooses whether the *content* surfaces are
269
- * published, and the homepage is the floor underneath both modes.
267
+ * The rule applies to every content tree, whether it contains only the
268
+ * homepage or other notes as well.
270
269
  *
271
270
  * @param {ReadonlyArray<{file: string}>} found - The homepage notes, in walk
272
271
  * order. Paths may be absolute or relative to the working directory.
@@ -61,6 +61,7 @@ export const DERIVED_KEYS = Object.freeze([
61
61
  "documentation",
62
62
  "documents",
63
63
  "asset",
64
+ "resolvedDates",
64
65
  ]);
65
66
 
66
67
  /**
package/engine/index.mjs CHANGED
@@ -42,6 +42,9 @@ export * as systemBlock from "./system-block.mjs";
42
42
  /** Fenced-code detection, so a rewrite never edits a code block. */
43
43
  export * as codeFences from "./code-fences.mjs";
44
44
 
45
+ /** Format generated text using the destination's Prettier configuration. */
46
+ export { formatGenerated } from "./format-generated.mjs";
47
+
45
48
  /** The `sohl:` frontmatter readers, shared by every content package. */
46
49
  export * as frontmatter from "./frontmatter.mjs";
47
50
 
@@ -109,13 +112,13 @@ export * as mapPlaces from "./map-places.mjs";
109
112
  /** The geometry of the map from a place: bearings to angles, days to rings, hops composed. */
110
113
  export * as mapLayout from "./map-layout.mjs";
111
114
 
112
- /** The three drawings of `content-build map`, as DOT. */
115
+ /** The three drawings of `package-build map`, as DOT. */
113
116
  export * as mapDot from "./map-dot.mjs";
114
117
 
115
118
  /** GraphViz, as the map command finds and runs it. */
116
119
  export * as mapGraphviz from "./map-graphviz.mjs";
117
120
 
118
- /** `content-build map`: the drawings, written under `build/map/`. */
121
+ /** `package-build map`: the drawings, written under `build/map/`. */
119
122
  export * as mapBuild from "./map-build.mjs";
120
123
 
121
124
  /** The map from each place, drawn for the site and written into its page. */
@@ -130,6 +133,18 @@ export * as calendars from "./calendars.mjs";
130
133
  /** A note's dates: one authored string, one grammar, one record shape. */
131
134
  export * as noteDates from "./note-dates.mjs";
132
135
 
136
+ /** Exact day conversion between an authored calendar and the canonical axis. */
137
+ export * as dateConversion from "./date-conversion.mjs";
138
+
139
+ /** Inline frontmatter and date expressions in Markdown prose. */
140
+ export * as markdownExpressions from "./markdown-expressions.mjs";
141
+
142
+ /** A being's or place's chosen calendar note. */
143
+ export * as calendarChoice from "./calendar-choice.mjs";
144
+
145
+ /** A being's primary culture Address, checked against lore subtype. */
146
+ export * as cultureChoice from "./culture-choice.mjs";
147
+
133
148
  /** Era markers resolved from calendar notes in a corpus. */
134
149
  export * as reckoningMarkers from "./reckoning-markers.mjs";
135
150
 
@@ -220,7 +235,7 @@ export * as contentLinks from "./content-links.mjs";
220
235
  /** Wikilinks resolved to a **web URL** — the site half of the pair below. */
221
236
  export * as webWikilinks from "./web-wikilinks.mjs";
222
237
 
223
- /** Dataview-style content tables, expanded into markdown at compile time. */
238
+ /** Prepared SQL content tables, expanded into Markdown at compile time. */
224
239
  export * as contentTables from "./content-tables.mjs";
225
240
 
226
241
  /** Markdown parsing, stats, folders, images, and the wikilink index. */
@@ -111,17 +111,19 @@ export function compilesSystemDocument(fm, map, router) {
111
111
  * Resolves a reference to `{name, url?, uuid?, address?, subType?}`.
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
+ * @param {object} [options.dates] - Calendar conversion context.
114
115
  * @returns {object[]} The boxes, in the order every medium renders them.
115
116
  * @throws {Error} When the built set disagrees with what the note's type maps
116
117
  * to — see {@link module:engine/infobox.assertInfoboxSet}.
117
118
  */
118
- export function noteInfoboxes(fm, { resolve, router = packRouter() } = {}) {
119
+ export function noteInfoboxes(fm, { resolve, router = packRouter(), dates } = {}) {
119
120
  const boxes = buildInfoboxes(fm, {
120
121
  maps: KNOWN_DOCUMENT_SUBTYPE_MAPS,
121
122
  providers: KNOWN_INFOBOXES,
122
123
  compilesDocument: (note, map) => compilesSystemDocument(note, map, router),
123
124
  resolveField: resolveFieldValue,
124
125
  resolve,
126
+ dates,
125
127
  });
126
128
  return /** @type {object[]} */ (
127
129
  assertInfoboxSet(boxes, fm, { maps: KNOWN_DOCUMENT_SUBTYPE_MAPS })
@@ -119,6 +119,8 @@ 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";
123
+ import { displayBeingHeight, displayBeingWeight } from "./being-measurements.mjs";
122
124
 
123
125
  /**
124
126
  * How a section arranges what it holds.
@@ -300,8 +302,8 @@ export const NOTE_FIELD_PRESENTATION = Object.freeze({
300
302
  "being.ageYears": Object.freeze({
301
303
  withheld: "the `age` row's own machinery, not a second fact about the subject",
302
304
  }),
303
- "being.height": Object.freeze({ group: "appearance", phrase: heightPhrase }),
304
- "being.weight": Object.freeze({ group: "appearance", phrase: weightPhrase }),
305
+ "being.height": Object.freeze({ group: "appearance", phrase: displayBeingHeight }),
306
+ "being.weight": Object.freeze({ group: "appearance", phrase: displayBeingWeight }),
305
307
  "being.frame": Object.freeze({ group: "appearance", phrase: (v) => `${v} frame` }),
306
308
  "being.appearance.eye_color": Object.freeze({
307
309
  group: "appearance",
@@ -348,18 +350,6 @@ export function overlayFor(presentation, type, name) {
348
350
  /** What a composed group's row is called. @type {Readonly<Record<string, string>>} */
349
351
  const GROUP_LABELS = Object.freeze({ appearance: "Appearance" });
350
352
 
351
- /** Metres as feet and inches, which is how the corpus reads a height. */
352
- function heightPhrase(metres) {
353
- const inches = Math.round(Number(metres) * 39.3701);
354
- const feet = Math.floor(inches / 12);
355
- return `${feet}′ ${inches - feet * 12}″`;
356
- }
357
-
358
- /** Kilograms as pounds, which is how the corpus reads a weight. */
359
- function weightPhrase(kilograms) {
360
- return `${Math.round(Number(kilograms) * 2.20462)} lbs`;
361
- }
362
-
363
353
  /** Whether a value is a plain mapping. */
364
354
  function isMapping(value) {
365
355
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
@@ -701,7 +691,7 @@ export function noteInfobox(fm, options = {}) {
701
691
  */
702
692
  function noteBox(
703
693
  fm,
704
- { resolve, vocabulary = NOTE_VOCABULARY, presentation = NOTE_FIELD_PRESENTATION } = {},
694
+ { resolve, dates, vocabulary = NOTE_VOCABULARY, presentation = NOTE_FIELD_PRESENTATION } = {},
705
695
  ) {
706
696
  const rows = [];
707
697
  /** @type {Set<string>} */
@@ -721,7 +711,34 @@ function noteBox(
721
711
  // The current name wins, which is the whole of the retirement window's
722
712
  // behaviour and is `readAliasedField`'s answer, not a second one.
723
713
  const own = getFrontmatter(data, field.name, undefined);
724
- const raw = own !== undefined ? own : readAliasedField(fm, field.name, { inData: true });
714
+ let raw = own !== undefined ? own : readAliasedField(fm, field.name, { inData: true });
715
+ if (
716
+ ((fm?.type === "being" && ["born", "died"].includes(field.name)) ||
717
+ (fm?.type === "place" && field.name === "present")) &&
718
+ dates
719
+ ) {
720
+ const parsed = parseNoteDate(raw, dates).date;
721
+ const era =
722
+ parsed?.marker ? dates.markers?.get(parsed.marker)
723
+ : parsed?.qualifier ? dates.eras?.get(parsed.qualifier)
724
+ : null;
725
+ let printable = null;
726
+ if (parsed?.known && data.calendar) {
727
+ try {
728
+ printable = formatDateInCalendar(
729
+ parsed,
730
+ isAddressTuple(data.calendar) ?
731
+ renderAddress(data.calendar)
732
+ : data.calendar,
733
+ dates,
734
+ );
735
+ } catch {
736
+ // The calendar reference is checked at the authored field.
737
+ }
738
+ }
739
+ printable ??= formatNoteDate(parsed, era, dates.daysPerYear);
740
+ if (printable) raw = printable.prose ?? printable.text;
741
+ }
725
742
  const structured = structuredRows(
726
743
  field,
727
744
  raw,
@@ -889,7 +906,7 @@ export function isDeclaredDefault(field, raw) {
889
906
  *
890
907
  * @param {object} fm - The note's frontmatter.
891
908
  * @param {readonly object[]} fields - The system's field declaration.
892
- * @param {object} ctx - `{ block, resolve, resolveField, taken, presentation }`.
909
+ * @param {{block: string, resolve?: Function, resolveField?: Function, taken?: Set<string>, presentation?: object}} ctx - Rendering context.
893
910
  * @returns {object[]} Zero or one section.
894
911
  */
895
912
  export function systemRowsSection(
@@ -956,9 +973,10 @@ export function buildInfoboxes(fm, options) {
956
973
  resolveField,
957
974
  resolve,
958
975
  vocabulary = NOTE_VOCABULARY,
976
+ dates,
959
977
  } = options;
960
978
 
961
- const { box: note, shown: taken } = noteBox(fm, { resolve, vocabulary });
979
+ const { box: note, shown: taken } = noteBox(fm, { resolve, vocabulary, dates });
962
980
  const boxes = [note];
963
981
 
964
982
  for (const map of maps ?? []) {
@@ -1017,7 +1035,7 @@ export function sectionHolds(section) {
1017
1035
  * against a restatement of it.
1018
1036
  *
1019
1037
  * @param {object} fm - The note's frontmatter.
1020
- * @param {object} options - `{ maps }`.
1038
+ * @param {{maps: readonly object[]}} options - Document subtype mappings.
1021
1039
  * @returns {string[]} The ids.
1022
1040
  */
1023
1041
  export function requiredInfoboxIds(fm, { maps }) {
@@ -1040,7 +1058,7 @@ export function requiredInfoboxIds(fm, { maps }) {
1040
1058
  *
1041
1059
  * @param {readonly object[]} boxes - What was built.
1042
1060
  * @param {object} fm - The note's frontmatter.
1043
- * @param {object} options - `{ maps, where }`.
1061
+ * @param {{maps: readonly object[], where?: string}} options - Document subtype mappings and context.
1044
1062
  * @returns {readonly object[]} The boxes, unchanged, so a caller may assert
1045
1063
  * inline.
1046
1064
  * @throws {Error} Naming the note, what is missing and what is extra.
@@ -50,7 +50,7 @@ import log from "loglevel";
50
50
 
51
51
  import { resolveName } from "./helpers.mjs";
52
52
  import { BasePackCompiler } from "./base-compiler.mjs";
53
- import { journalPageId, splitPages } from "./journals.mjs";
53
+ import { assertUniquePages, journalPageId, splitPages } from "./journals.mjs";
54
54
  import { foundryPackageId } from "./content-package.mjs";
55
55
  import { itemDocEntryId, itemDocPointer } from "./item-docs.mjs";
56
56
  // The whitelist and the per-type `system` builders both come from the resolved
@@ -76,15 +76,15 @@ import { assertNoDerivedFields } from "./derived-fields.mjs";
76
76
  import { assertNoRuntimeOnlyFields } from "./runtime-only-fields.mjs";
77
77
 
78
78
  /**
79
- * The description an item carries: a pointer to its **item doc**, the
80
- * JournalEntry the journals pass compiles this same body into.
79
+ * The description an item carries: a pointer to a page of its **item doc**,
80
+ * the JournalEntry the journals pass compiles this same body into.
81
81
  *
82
82
  * The prose is not rendered into the item at all. Carrying it would duplicate
83
83
  * it onto every actor holding the item — 7.59 MB of copies across the actors
84
84
  * pack, of which 133 KB was distinct — where a link is 60 bytes and always
85
85
  * current. The two passes derive the target from the note's own id, so neither
86
- * has to see the other's output; both split the *converted* markdown, so an H1
87
- * carrying a wikilink names the same page on both sides.
86
+ * has to see the other's output. `{#description}` selects the named page;
87
+ * an item without that anchor points to its first page.
88
88
  *
89
89
  * An item with no prose points at nothing, exactly as the journals pass writes
90
90
  * no entry for it.
@@ -97,9 +97,11 @@ import { assertNoRuntimeOnlyFields } from "./runtime-only-fields.mjs";
97
97
  */
98
98
  export function itemDescription(markdown, fm, name) {
99
99
  if (!String(markdown).trim()) return "";
100
- const [leadPage] = splitPages(markdown, name);
101
- const pageId = journalPageId(itemDocEntryId(fm.id), leadPage);
102
- return itemDocPointer(foundryPackageId(), fm.id, name, pageId);
100
+ const pages = splitPages(markdown, name);
101
+ assertUniquePages(pages, name);
102
+ const page = pages.find((entry) => entry.anchorSlug === "description") ?? pages[0];
103
+ const pageId = journalPageId(itemDocEntryId(fm.id), page);
104
+ return itemDocPointer(foundryPackageId(), fm.id, name, pageId, fm);
103
105
  }
104
106
 
105
107
  /**
@@ -18,7 +18,7 @@
18
18
  * An item note's body describes what the thing *is*. That is documentation, and
19
19
  * documentation belongs in the journals pack, so each item note compiles into a
20
20
  * JournalEntry and the item's `system.docHtml` becomes nothing but a `@UUID`
21
- * link to that entry's first page — the description-as-pointer convention,
21
+ * link to its `{#description}` page, or its first page when that anchor is absent — the description-as-pointer convention,
22
22
  * which {@link sohl.utils.descriptionLinkTarget} recognises and
23
23
  * Display Description follows.
24
24
  *
@@ -92,7 +92,7 @@ export function itemDocEntryId(itemId) {
92
92
 
93
93
  /**
94
94
  * The description an item carries in place of its prose: a `@UUID` link to the
95
- * first page of its item doc, and nothing else.
95
+ * description page of its item doc, and nothing else.
96
96
  *
97
97
  * "Nothing else" is the whole convention — a description that is *only* a link
98
98
  * is a pointer, and anything alongside it would make it ordinary prose that
@@ -104,18 +104,19 @@ export function itemDocEntryId(itemId) {
104
104
  * @param {string} name - The item's name, used as the link's label. It shows
105
105
  * only if the target ever fails to resolve, where a broken link naming the
106
106
  * item beats a bare UUID.
107
- * @param {string} firstPageId - The id of the entry's first page, from
107
+ * @param {string} pageId - The id of the selected entry page, from
108
108
  * {@link journalPageId}.
109
+ * @param {object} [fm] - The source note, used to route GM-only prose.
109
110
  * @returns {string} The pointer to store in `system.docHtml`.
110
111
  */
111
- export function itemDocPointer(packageId, itemId, name, firstPageId) {
112
+ export function itemDocPointer(packageId, itemId, name, pageId, fm) {
112
113
  // An item doc is a *derived* document: it lands in the default
113
114
  // JournalEntry pack whatever Item pack the item itself was routed to.
114
115
  const entryUuid = compendiumUuid(
115
116
  packageId,
116
117
  "doc",
117
118
  itemDocEntryId(itemId),
118
- packRouter().defaultOf("JournalEntry"),
119
+ fm ? packRouter().resolve(fm, "JournalEntry") : packRouter().defaultOf("JournalEntry"),
119
120
  );
120
- return `@UUID[${pageUuid(entryUuid, firstPageId)}]{${name}}`;
121
+ return `@UUID[${pageUuid(entryUuid, pageId)}]{${name}}`;
121
122
  }
@@ -6,6 +6,7 @@
6
6
 
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
+ import { resolvePackageBuildConfig } from "../config.mjs";
9
10
 
10
11
  import { buildMaps, relatedPlaces } from "./map-build.mjs";
11
12
  import { renderDot } from "./map-graphviz.mjs";
@@ -74,6 +75,7 @@ export function buildItineraryScenes({
74
75
  stats,
75
76
  foreign = loadForeignIndexes(config, [config.contentPackage]),
76
77
  }) {
78
+ const buildConfig = resolvePackageBuildConfig(config);
77
79
  const world = mapWorld({ records, foreignIndex: foreign.index, contentBase, config });
78
80
  const centres = relatedPlaces(world.places).filter((sc) => world.places.get(sc)?.local);
79
81
  const findings = foreign.stale.map((stale) => ({
@@ -83,7 +85,7 @@ export function buildItineraryScenes({
83
85
  }));
84
86
  const assetDir = path.resolve(
85
87
  config.rootDir,
86
- config.packageBuild.stageDir,
88
+ buildConfig.stageDir,
87
89
  "assets/maps/generated-itineraries",
88
90
  );
89
91
  fs.rmSync(assetDir, { recursive: true, force: true });
@@ -112,7 +114,7 @@ export function buildItineraryScenes({
112
114
  const [width, height] = pngDimensions(png);
113
115
  const positions = pinPositions(fs.readFileSync(plain, "utf8"), width, height);
114
116
  const sceneId = makeId("itinerary-scene", `${config.contentPackage}:${centre}`);
115
- const src = `${config.packageKind}/${config.foundryPackage.id}/assets/maps/generated-itineraries/from-${centre}.png`;
117
+ const src = `${buildConfig.packageKind}/${buildConfig.packageId}/assets/maps/generated-itineraries/from-${centre}.png`;
116
118
  const scene = buildScene(
117
119
  {
118
120
  id: sceneId,