@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
@@ -27,15 +27,17 @@
27
27
  */
28
28
 
29
29
  import { parseAddress, renderAddress, isAddressTuple } from "./address.mjs";
30
- import { NOTE_VOCABULARY } from "./note-vocabulary.mjs";
30
+ import { NOTE_VOCABULARY, isGmNote } from "./note-vocabulary.mjs";
31
31
  import { encodeAddresses } from "./address-values.mjs";
32
32
  import fs from "node:fs";
33
33
  import os from "node:os";
34
34
  import path from "node:path";
35
35
 
36
36
  import { FENCE_LINE, parseHeaderArgs } from "./code-fences.mjs";
37
+ import { booleanAttribute } from "./extension-attributes.mjs";
37
38
  import { MARKET_CLASSES } from "./market-class.mjs";
38
39
  import { parseMarkdownFile } from "./helpers.mjs";
40
+ import { sqlQueriesInMarkdown } from "./markdown-expressions.mjs";
39
41
  // The record accessors only — see `engine/index-records.mjs`.
40
42
  import { isNoteRecord, noteFile, sortKeysDeep } from "./index-records.mjs";
41
43
 
@@ -79,7 +81,7 @@ export function findSqlBlocks(markdown) {
79
81
  const closer = new RegExp(`^[ \\t]*${marker[0]}{${marker.length},}[ \\t]*$`);
80
82
  let close = i + 1;
81
83
  while (close < lines.length && !closer.test(lines[close])) close += 1;
82
- const { language, args } = parseHeaderArgs(info);
84
+ const { language, args, problems } = parseHeaderArgs(info);
83
85
  if (language !== "sql") {
84
86
  // Not ours, but still a fence: skip its body so a `sql` line inside
85
87
  // some other block is never read as a directive.
@@ -87,20 +89,30 @@ export function findSqlBlocks(markdown) {
87
89
  continue;
88
90
  }
89
91
  if (close >= lines.length) continue;
90
- const level = Number(args["section-level"]);
92
+ const level = Number(args["section-level"] ?? 2);
93
+ const errors = [...problems];
94
+ for (const key of Object.keys(args)) {
95
+ if (!["allow-empty", "section-level"].includes(key))
96
+ errors.push(`${key} is not an SQL fence attribute`);
97
+ }
98
+ let allowEmpty = false;
99
+ if (Object.hasOwn(args, "allow-empty")) {
100
+ try {
101
+ allowEmpty = booleanAttribute(args["allow-empty"], "allow-empty");
102
+ } catch (error) {
103
+ errors.push(error.message);
104
+ }
105
+ }
106
+ if (!Number.isInteger(level) || level < 1 || level > 6)
107
+ errors.push("section-level needs an integer from 1 through 6");
91
108
  blocks.push({
92
109
  line: i,
93
110
  close,
94
111
  indent,
95
112
  query: lines.slice(i + 1, close).join("\n"),
96
- // `:allow-empty` says a table selecting nothing is intended.
97
- // Spelled on the fence rather than in the query because it is a
98
- // statement about this directive and not part of SQL.
99
- allowEmpty: args["allow-empty"] === true,
100
- sectionLevel: Number.isInteger(level) && level >= 1 && level <= 6 ? level : 2,
101
- // Every header argument, so a caller can read one this module makes
102
- // no use of — the point of taking a real grammar rather than a
103
- // regex per property.
113
+ allowEmpty,
114
+ sectionLevel: level,
115
+ problems: errors,
104
116
  args,
105
117
  block: lines.slice(i, close + 1).join("\n"),
106
118
  });
@@ -150,10 +162,16 @@ export function findSqlBlocks(markdown) {
150
162
  * @param {string} [opts.dir] - Directory for the temporary file.
151
163
  * @param {Array<{id: string, file: string}>} [opts.dependencies] - Each
152
164
  * declared dependency's cached index, attached as a schema named `id`.
165
+ * @param {object} [opts.addressContext] - Address resolution context.
166
+ * @param {"all"|"public"} [opts.audience] - Whether to exclude GM notes from
167
+ * local and dependency relations.
153
168
  * @returns {Promise<{query: (sql: string) => Promise<object[]>,
154
169
  * close: () => Promise<void>}>} The open database.
155
170
  */
156
- export async function openNotesDatabase(records, { dir, dependencies = [], addressContext } = {}) {
171
+ export async function openNotesDatabase(
172
+ records,
173
+ { dir, dependencies = [], addressContext, audience = "all" } = {},
174
+ ) {
157
175
  const { DuckDBInstance } = await import("@duckdb/node-api");
158
176
  const base = dir ?? fs.mkdtempSync(path.join(os.tmpdir(), "content-sql-"));
159
177
  fs.mkdirSync(base, { recursive: true });
@@ -166,7 +184,7 @@ export async function openNotesDatabase(records, { dir, dependencies = [], addre
166
184
  const instance = await DuckDBInstance.create(":memory:");
167
185
  const connection = await instance.connect();
168
186
  await connection.run("SET threads=1");
169
- await createRelations(connection, jsonl);
187
+ await createRelations(connection, jsonl, "", audience);
170
188
 
171
189
  // One schema per declared dependency, so `FROM sohl.notes` reads the notes
172
190
  // that package published. Quoted, because a package id may carry a hyphen
@@ -176,7 +194,7 @@ export async function openNotesDatabase(records, { dir, dependencies = [], addre
176
194
  if (!dep?.id || !dep?.file || !fs.existsSync(dep.file)) continue;
177
195
  const schema = `"${String(dep.id).replace(/"/g, '""')}"`;
178
196
  await connection.run(`CREATE SCHEMA IF NOT EXISTS ${schema}`);
179
- await createRelations(connection, dep.file, `${schema}.`);
197
+ await createRelations(connection, dep.file, `${schema}.`, audience);
180
198
  }
181
199
 
182
200
  // The market scale as a relation, so a table prints `village` beside the
@@ -320,7 +338,7 @@ const WITH_STUBS_SCHEMA = "__with_stubs";
320
338
  * @param {string} [prefix] - A schema to qualify the view names with.
321
339
  * @returns {Promise<void>}
322
340
  */
323
- async function createRelations(connection, file, prefix = "") {
341
+ async function createRelations(connection, file, prefix = "", audience = "all") {
324
342
  const read = readJsonAuto(file);
325
343
  const described = await connection.runAndReadAll(`DESCRIBE ${read}`);
326
344
  const columns = new Set(described.getRowObjects().map((row) => String(row.column_name)));
@@ -335,10 +353,15 @@ async function createRelations(connection, file, prefix = "") {
335
353
  "COALESCE(list_contains(TRY_CAST(tags AS VARCHAR[]), 'draft'), false) " +
336
354
  "OR COALESCE(TRY_CAST(tags AS VARCHAR) = 'draft', false)"
337
355
  : "false";
356
+ const visible =
357
+ audience === "public" && columns.has("tags") ?
358
+ "NOT (COALESCE(list_contains(TRY_CAST(tags AS VARCHAR[]), 'gm'), false) " +
359
+ "OR COALESCE(TRY_CAST(tags AS VARCHAR) = 'gm', false))"
360
+ : "true";
338
361
  await connection.run(
339
362
  `CREATE VIEW ${prefix}entries AS SELECT *, ` +
340
363
  `CASE WHEN ${stub} THEN 'stub' WHEN ${draft} THEN 'draft' ` +
341
- `ELSE 'full' END AS state FROM (${read})`,
364
+ `ELSE 'full' END AS state FROM (${read}) WHERE ${visible}`,
342
365
  );
343
366
  await connection.run(
344
367
  `CREATE VIEW ${prefix}notes AS SELECT * FROM ${prefix}entries WHERE state <> 'stub'`,
@@ -467,6 +490,7 @@ function cellText(value, column) {
467
490
  * @param {(ref: string) => boolean} [opts.linkable] - Whether an address can be
468
491
  * linked to; defaults to linking any non-empty `_ref`.
469
492
  * @param {number} [opts.sectionLevel=2] - Heading level for `_section`.
493
+ * @param {object} [opts.addressContext] - Address resolution context.
470
494
  * @returns {string} The markdown.
471
495
  */
472
496
  export function renderSqlTable(
@@ -563,6 +587,7 @@ export async function prepareSqlTables(db, sources, { linkable } = {}) {
563
587
  prepared.set(source, forNote);
564
588
  for (const block of blocks) {
565
589
  try {
590
+ if (block.problems.length) throw new Error(block.problems.join("; "));
566
591
  const result = await runSqlQuery(db, block.query);
567
592
  forNote.push({
568
593
  markdown: renderSqlTable(result, {
@@ -589,6 +614,42 @@ export async function prepareSqlTables(db, sources, { linkable } = {}) {
589
614
  return prepared;
590
615
  }
591
616
 
617
+ /** Evaluate scalar SQL calls before synchronous Markdown renderers run. */
618
+ export async function prepareInlineSqlExpressions(db, sources) {
619
+ const byNote = new Map();
620
+ const cache = new Map();
621
+ for (const { source, markdown, frontmatter } of sources) {
622
+ const queries = sqlQueriesInMarkdown(markdown, frontmatter);
623
+ if (!queries.length) continue;
624
+ const values = new Map();
625
+ byNote.set(source, values);
626
+ for (const query of queries) {
627
+ if (!cache.has(query)) {
628
+ try {
629
+ const result = await db.query(query);
630
+ if (result.columnNames.length !== 1 || result.rows.length !== 1)
631
+ throw new RangeError("scalar SQL needs exactly one column and one row");
632
+ const value = result.rows[0][result.columnNames[0]];
633
+ if (value === null || value === undefined || value === "")
634
+ throw new RangeError("scalar SQL result is empty");
635
+ if (
636
+ value !== null &&
637
+ !["string", "number", "boolean", "bigint"].includes(typeof value)
638
+ )
639
+ throw new TypeError(
640
+ "scalar SQL result must be a number, string, Boolean, or null",
641
+ );
642
+ cache.set(query, { value });
643
+ } catch (error) {
644
+ cache.set(query, { error: String(error?.message ?? error).split("\n")[0] });
645
+ }
646
+ }
647
+ values.set(query, cache.get(query));
648
+ }
649
+ }
650
+ return byNote;
651
+ }
652
+
592
653
  /**
593
654
  * Answer every `sql` directive in a content tree.
594
655
  *
@@ -609,16 +670,22 @@ export async function prepareSqlTables(db, sources, { linkable } = {}) {
609
670
  * @param {object[]} [opts.records] - Index records the caller already derived.
610
671
  * A command that also builds a link index holds them already, and deriving
611
672
  * them twice is the duplicated-corpus failure this closes.
673
+ * @param {"all"|"public"} [opts.audience] - Whether to exclude GM notes.
612
674
  * @returns {Promise<Map<string, object[]>|undefined>} Results by note path, or
613
675
  * nothing when the tree has no such directive.
614
676
  */
615
- export async function prepareTreeSqlTables(contentBase, { config, skipDirectories, records } = {}) {
677
+ export async function prepareTreeSqlTables(
678
+ contentBase,
679
+ { config, skipDirectories, records, audience = "all" } = {},
680
+ ) {
616
681
  // Imported here rather than at module scope: the index reaches the pack
617
682
  // compilers through `manifest-emit` → `journals`, so a static import from a
618
683
  // module they load would close a cycle and leave `BasePackCompiler`
619
684
  // uninitialised for whichever module the runtime happened to load first.
620
685
  const { indexRecordsFor } = await import("./content-index.mjs");
621
- const indexRecords = records ?? indexRecordsFor({ contentBase, config, skipDirectories });
686
+ const indexRecords = (
687
+ records ?? indexRecordsFor({ contentBase, config, skipDirectories })
688
+ ).filter((record) => audience !== "public" || !isGmNote(record));
622
689
 
623
690
  // Which notes carry a directive, discovered over the same corpus every
624
691
  // other pass reads rather than over a walk of this one's own. The
@@ -633,8 +700,9 @@ export async function prepareTreeSqlTables(contentBase, { config, skipDirectorie
633
700
  for (const record of indexRecords) {
634
701
  if (!isNoteRecord(record)) continue;
635
702
  const absPath = noteFile(contentBase, record);
636
- const { body } = parseMarkdownFile(absPath);
637
- if (body && findSqlBlocks(body).length) sources.push({ source: absPath, markdown: body });
703
+ const { body, frontmatter } = parseMarkdownFile(absPath);
704
+ if (body && (findSqlBlocks(body).length || sqlQueriesInMarkdown(body, frontmatter).length))
705
+ sources.push({ source: absPath, markdown: body, frontmatter });
638
706
  }
639
707
  if (!sources.length) return undefined;
640
708
  // A cell links only where the address it would emit resolves, so a table
@@ -664,11 +732,13 @@ export async function prepareTreeSqlTables(contentBase, { config, skipDirectorie
664
732
  } catch {
665
733
  dependencies = [];
666
734
  }
667
- const db = await openNotesDatabase(indexRecords, { dependencies });
735
+ const db = await openNotesDatabase(indexRecords, { dependencies, audience });
668
736
  try {
669
- return await prepareSqlTables(db, sources, {
737
+ const prepared = await prepareSqlTables(db, sources, {
670
738
  linkable: (ref) => addresses.has(renderAddress(ref)),
671
739
  });
740
+ prepared.inline = await prepareInlineSqlExpressions(db, sources);
741
+ return prepared;
672
742
  } finally {
673
743
  await db.close();
674
744
  }
@@ -123,19 +123,8 @@ function lookupRead(index, read, contentPackage) {
123
123
  /**
124
124
  * How an **unresolved** link renders.
125
125
  *
126
- * The author's text is kept, so the sentence still reads — dropping it would
127
- * silently rewrite the prose. It is marked so a reader can tell that something
128
- * was meant to be a link, and an author can find it: the appearance lives in
129
- * `scss/components/_unresolved-link.scss` for Foundry and in the Hugo theme for
130
- * the website, not here.
131
- *
132
- * This is deliberately identical to the pack compiler's own `unresolvedLink`,
133
- * down to the class name and the `title` wording. One authored link renders on
134
- * two surfaces, and the two builds have drifted before over exactly this kind
135
- * of detail — matching markup is what keeps a reader's cue the same in
136
- * a journal and on the page. Duplicated rather than imported only because the
137
- * function is not exported from `@heroiclands/package-build`; hoisting it there
138
- * is.
126
+ * The author's text remains readable. The website uses an HTML span matching
127
+ * the pack compiler's cue; the book uses text that Typst can print.
139
128
  *
140
129
  * The knowledgebase renders with `unsafe = true` (`kb/hugo.toml`), so raw HTML
141
130
  * in generated markdown reaches the page. That makes escaping obligatory: this
@@ -143,9 +132,11 @@ function lookupRead(index, read, contentPackage) {
143
132
  *
144
133
  * @param {string} text - The text to show, from the link's label or target.
145
134
  * @param {string} target - The address that resolved nowhere, for the tooltip.
146
- * @returns {string} An inline HTML span, safe to sit in a markdown table cell.
135
+ * @param {"html"|"book"} output - The destination format.
136
+ * @returns {string} A visible cue in the destination's Markdown.
147
137
  */
148
- function unresolvedLink(text, target) {
138
+ function unresolvedLink(text, target, output) {
139
+ if (output === "book") return `${text} (unresolved link)`;
149
140
  const esc = (v) =>
150
141
  String(v)
151
142
  .replace(/&/g, "&amp;")
@@ -159,27 +150,18 @@ function unresolvedLink(text, target) {
159
150
  }
160
151
 
161
152
  /**
162
- * How a link to a **draft** note renders.
153
+ * How the website marks a link to a **draft** note.
163
154
  *
164
155
  * A note tagged `draft` exists so a link into it is not dead, and nothing more.
165
156
  * Unmarked, a reader follows a promising link into an empty page and an author
166
157
  * cannot see which of their links still owe content.
167
158
  *
168
- * **The wrapper carries the cue and nothing else.** The link itself is
169
- * untouched — Goldmark parses inline markdown inside an inline HTML span, so
170
- * the markdown link still becomes an anchor, and the note is on the site, in
171
- * the packs and in the manifest exactly as any other. Nothing here resembles
172
- * the retired `draft:` field, which moved a note from published to unresolvable
173
- * without saying so.
159
+ * The link itself remains intact inside the HTML span.
174
160
  *
175
161
  * The appearance lives in the Hugo theme for the website and in
176
162
  * `scss/components/_draft-link.scss` for Foundry, not here.
177
163
  *
178
- * **Byte-identical with the pack build's copy** in `wikilinks.mjs`, down to the
179
- * class name and the `title` wording — one authored link renders on two
180
- * surfaces, and the two builds have drifted before over exactly this kind of
181
- * detail. Duplicated rather than imported for the same reason
182
- * {@link unresolvedLink} is; hoisting both is.
164
+ * The website span matches the pack compiler's copy in `wikilinks.mjs`.
183
165
  *
184
166
  * The argument is already-built markup and is deliberately not escaped; the
185
167
  * *authored* text inside it was escaped, or made into a link, by the caller.
@@ -322,7 +304,8 @@ function isPlainMap(value) {
322
304
  *
323
305
  * @param {string} body - The markdown body.
324
306
  * @param {object} ctx - `{ index, assets, collide, contentTypes,
325
- * packages, noIndexPackages, foreign, type, errors, src, file, resolved }`.
307
+ * packages, noIndexPackages, foreign, type, errors, src, file, resolved,
308
+ * output }`. `output` is `"book"` for PDF Markdown and defaults to HTML.
326
309
  * `packages` is every package an address may name, without which the leading
327
310
  * package segment of a canonical address reads as an unknown type;
328
311
  * `noIndexPackages` is every package declared `contentIndex: false`, so a
@@ -373,7 +356,7 @@ export function resolveWebWikilinks(body, ctx) {
373
356
  */
374
357
  const report = (all, finding, text) => {
375
358
  record(all, finding);
376
- return unresolvedLink(text, finding.target);
359
+ return unresolvedLink(text, finding.target, ctx.output ?? "html");
377
360
  };
378
361
 
379
362
  // An embed names a file, so it resolves before anything looks for a link.
@@ -416,7 +399,7 @@ export function resolveWebWikilinks(body, ctx) {
416
399
 
417
400
  // `[[#section-slug|Text]]` — a section of this same page.
418
401
  if (isSamePage({ target, anchor })) {
419
- return `[${label ?? anchor}](#${slugify(anchor)})`;
402
+ return `[${label ?? ctx.captionLabels?.get(anchor) ?? anchor}](#${slugify(anchor)})`;
420
403
  }
421
404
 
422
405
  // The canonical separator has to be resolved, not merely
@@ -455,7 +438,8 @@ export function resolveWebWikilinks(body, ctx) {
455
438
  // Presentation only — the href above is unchanged, and a
456
439
  // `[[#anchor]]` self-link is not marked because the reader is
457
440
  // already on the page it would be telling them about.
458
- return hit.draft ? draftLink(link) : link;
441
+ if (!hit.draft) return link;
442
+ return ctx.output === "book" ? `${link} (draft)` : draftLink(link);
459
443
  }
460
444
 
461
445
  // **An address resolving nowhere is a failure, unconditionally**.
@@ -203,6 +203,7 @@ export const LINK_FINDING_REASONS = Object.freeze(
203
203
  "not-an-asset",
204
204
  "unresolved",
205
205
  "stub",
206
+ "gm",
206
207
  "ambiguous",
207
208
  "unknown-anchor",
208
209
  "no-content-index",
@@ -232,7 +233,7 @@ export function unresolvedAddressMessage(target) {
232
233
  return (
233
234
  `address [[${target}]] resolves to no note — no package publishes ` +
234
235
  `it. Fix the shortcode, or declare the package that does as a ` +
235
- `dependency and run \`content-build deps fetch\``
236
+ `dependency and run \`package-build deps fetch\``
236
237
  );
237
238
  }
238
239
 
@@ -344,6 +345,8 @@ export function linkFindingMessage({ reason, target, packages, anchor, type, stu
344
345
  return unresolvedAddressMessage(target);
345
346
  case "stub":
346
347
  return stubAddressMessage(target, stub);
348
+ case "gm":
349
+ return `address [[${target}]] names a GM-only note from a player-facing note`;
347
350
  case "no-content-index":
348
351
  return (
349
352
  `address [[${target}]] names a package declared \`contentIndex: false\` — ` +
@@ -121,6 +121,8 @@ export function anchorPageId(noteId, anchorSlug) {
121
121
  * `contentIndex: false` — a Foundry dependency only. A link naming one fails
122
122
  * with `no-content-index` rather than resolving, ambiguously, as either a
123
123
  * typo or an undeclared package.
124
+ * @param {Map<string, object>} [opts.referenceTargets] - Local reference targets.
125
+ * @param {Map<string, object>} [opts.foreignReferences] - Foreign reference targets.
124
126
  * @returns {{byShortcode: Map<string, object>, types: Set<string>}} `types` is
125
127
  * every type the tree actually contains, so a qualifier naming no real type
126
128
  * can be told apart from a missing target.
@@ -158,11 +160,9 @@ export function buildWikilinkIndex(
158
160
 
159
161
  uuidByDoc.set(
160
162
  d,
161
- // A note declaring `pack: none` compiles into no document, so it
162
- // has no address in any compendium: a link to it is a page on the
163
- // web and prose in a journal. Recorded as an entry with no UUIDs
164
- // rather than left out, so the address still resolves and a link
165
- // to it is never reported as dead.
163
+ // A note with no single Foundry document has no compendium UUID:
164
+ // a link to it retains its label in a journal. Keep the indexed
165
+ // address so the link resolves without a dead-link finding.
166
166
  d.none ?
167
167
  { uuid: undefined, docUuid: undefined }
168
168
  : {
@@ -375,6 +375,7 @@ function draftLink(inner) {
375
375
  * entry landed in.
376
376
  * @param {{byShortcode: Map, types: Set}} ctx.index - From
377
377
  * {@link buildWikilinkIndex}.
378
+ * @param {Map<string, string>} [ctx.captionLabels] - Numbered caption references.
378
379
  * @returns {{markdown: string, unresolved: Array<{link: string, target: string,
379
380
  * offset: number, reason: string, packages?: string[], anchor?: string}>}}
380
381
  * Each `reason` is one of {@link LINK_FINDING_REASONS}, the vocabulary all
@@ -383,7 +384,7 @@ function draftLink(inner) {
383
384
  * position in `markdown`, which is what lets a caller report the line and
384
385
  * column it sits on.
385
386
  */
386
- export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
387
+ export function convertWikilinks(markdown, { type, id, pack, docPack, index, captionLabels }) {
387
388
  const unresolved = [];
388
389
 
389
390
  // `offset` is the third replacer argument because the pattern has exactly
@@ -512,7 +513,8 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
512
513
  // the document's **current** name stands in and a rename shows at every
513
514
  // citation with no link edited. The knowledgebase build reads
514
515
  // the same authored link the same way.
515
- if (!text) text = doc.name ?? target;
516
+ if (!text)
517
+ text = (target === "" && slug ? captionLabels?.get(slug) : null) ?? doc.name ?? target;
516
518
 
517
519
  // Both addresses were computed when the target was indexed. An item
518
520
  // doc lives in the journals pack under its own derived entry id, and
@@ -526,12 +528,8 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
526
528
  docUuid: compendiumUuid(index.packageId, "doc", itemDocEntryId(doc.id), doc.docPack),
527
529
  };
528
530
  const entryUuid = itemDoc ? addresses.docUuid : addresses.uuid;
529
- // The target is a note that compiles into no document — `pack: none`.
530
- // The address is real and the page exists on the web, so this is not
531
- // a dead link and must not fail the build; but a compendium has
532
- // nothing to open, so the reader gets the prose and no `@UUID`. The
533
- // mirror of what the website does for an address that publishes a
534
- // document and no page.
531
+ // The target has no single Foundry document. Its address is real, but
532
+ // a compendium has nothing to open, so the reader gets the label.
535
533
  if (entryUuid === undefined) return text;
536
534
  const entryId = itemDoc ? itemDocEntryId(doc.id) : doc.id;
537
535
  const isJournal = itemDoc || packForType(doc.type).docType === "JournalEntry";
@@ -542,7 +540,7 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
542
540
  // can never hold one is what made such links dead-end; an
543
541
  // an item's pages are addressed through its `note` Address.
544
542
  // A `#section` the target declares no heading for. Checked here, and
545
- // not only by `content-build links`, because this is the build that
543
+ // not only by `package-build links`, because this is the build that
546
544
  // *emits* the link: `anchorPageId` will hash any slug into a page id,
547
545
  // so an undeclared one compiles to a `@UUID` that dead-ends for the
548
546
  // reader. A foreign anchor has always been checked this way —
@@ -40,7 +40,7 @@
40
40
  * are written bare, exactly as SoHL's are: the `data:` region is the sweep's
41
41
  * migration and neither half has moved to it. Spelling HM3's differently would
42
42
  * make a note feeding both systems author the same fact twice for no gain, and
43
- * would take these rows out of reach of `content-build content-format fields`,
43
+ * would take these rows out of reach of `package-build content-format fields`,
44
44
  * which pairs a `data.<key>` claim with a bare declared `<key>`. Both halves
45
45
  * move together when the sweep lands.
46
46
  *
package/hm3/items.mjs CHANGED
@@ -12,44 +12,14 @@
12
12
  */
13
13
 
14
14
  /**
15
- * **HM3's Item pass** — the two things about compiling a note into an HM3 Item
16
- * that are facts about HM3 rather than about the note format.
17
- *
18
- * Everything else is {@link module:engine/item-compiler}'s, and is the same
19
- * code the SoHL pass runs: which notes are claimed, which subtype each becomes,
20
- * which registry builds it, the authored `hm3.system` passthrough, the schema
21
- * check, and the compendium envelope. A second system is a map and a handful of
22
- * emitted keys, which is the arrangement the mapping tables build towards.
23
- *
24
- * **What HM3's compiler writes on every item: one key, and only when there is
25
- * something to write.** The content format gives an item's `{#appearance}`
26
- * section a home in HM3 — `description` — and SoHL none, since no SoHL Item
27
- * subtype declares such a field. So the section is rendered here and nowhere
28
- * else. Where a note has no such section nothing is emitted at all, which
29
- * matters for `armorlocation`: it is the one HM3 subtype that extends the
30
- * Foundry base directly, declaring neither `description` nor `notes`, so an
31
- * unconditional key would be a finding on every armour-location document in the
32
- * pack.
33
- *
34
- * **And one flag, for the fact HM3's `system` has no field for.** The template
35
- * priority is a shared statement — a note declaring `data.templatePriority` says
36
- * the same thing to both systems — but HM3 declares no `system` field for it, so
37
- * it lands under this system's own flag scope as `flags.hm3.templatePriority`,
38
- * exactly as the Actor pass writes it. Without it, an item note declaring the
39
- * priority compiles into a SoHL item that knows it is a template and an HM3
40
- * item that does not.
41
- *
42
- * **There is no HM3 equivalent of `docHtml`.** SoHL points an item at the
43
- * JournalEntry its prose compiled into, and HM3's data model has nowhere to put
44
- * such a pointer; inventing one would emit a key Foundry discards at load
45
- * without a word. The prose still compiles into its JournalEntry — the journals
46
- * pass claims every doc-carrying type regardless of system — it simply is not
47
- * addressed from the item.
15
+ * HM3's Item compiler uses the shared note-to-Item pass. It puts the pointer
16
+ * to the note's `{#description}` JournalEntryPage in `system.description` when
17
+ * the Item subtype declares that field. `armorlocation` has no description.
18
+ * The shared `data.templatePriority` is recorded in HM3 flags.
48
19
  *
49
20
  * @module
50
21
  */
51
22
 
52
- import { renderSection } from "../engine/anchored-sections.mjs";
53
23
  import { SystemItemCompiler } from "../engine/item-compiler.mjs";
54
24
  import { HM3_DOCUMENT_SUBTYPES } from "./document-subtypes.mjs";
55
25
  import { templateFlags } from "./template-priority.mjs";
@@ -58,7 +28,7 @@ import { templateFlags } from "./template-priority.mjs";
58
28
  * HM3's Item compile pass.
59
29
  *
60
30
  * Declares HM3's note-type → document-subtype map, which decides the notes this
61
- * pass claims and what each becomes, the one `system` key HM3 writes from prose,
31
+ * pass claims and what each becomes, the one `system` key HM3 writes as a page pointer,
62
32
  * and the template-priority flag HM3's data model has no field for. Everything
63
33
  * else is {@link module:engine/item-compiler}'s.
64
34
  */
@@ -74,28 +44,25 @@ export class Hm3Items extends SystemItemCompiler {
74
44
  /**
75
45
  * The `system` keys this pass derives from the note.
76
46
  *
77
- * `description` is the note's `{#appearance}` section, rendered. A note
78
- * authoring it writes into a key the compiler overwrites from prose that
79
- * may say something else.
47
+ * `description` points to the note's `{#description}` page. A note
48
+ * authoring it writes into a key the compiler derives from the shared body.
80
49
  *
81
50
  * @type {readonly {key: string, from: string}[]}
82
51
  */
83
52
  static derivedSystemKeys = Object.freeze([
84
- { key: "description", from: "the note's `{#appearance}` section" },
53
+ { key: "description", from: "the note's `{#description}` page" },
85
54
  ]);
86
55
 
87
56
  /**
88
- * The `system.*` field HM3 writes on an item from the note's prose.
57
+ * The `system.*` field HM3 writes from the shared item-doc pointer.
89
58
  *
90
59
  * @param {object} fm - The note's frontmatter.
91
60
  * @param {object} at - What the pass already knows about this note.
92
- * @param {string} at.markdown - The note body, tables expanded and
93
- * wikilinks resolved.
61
+ * @param {string} at.description - The resolved JournalEntryPage pointer.
94
62
  * @returns {object} The shared `system` fields — `description`, or nothing.
95
63
  */
96
- commonSystem(fm, { markdown }) {
97
- const description = renderSection(markdown, "appearance");
98
- return description ? { description } : {};
64
+ commonSystem(fm, { description }) {
65
+ return description && this.itemSubtype(fm) !== "armorlocation" ? { description } : {};
99
66
  }
100
67
 
101
68
  /**
package/index.mjs CHANGED
@@ -15,20 +15,8 @@
15
15
  * `@heroiclands/package-build` — the shared toolchain for building and shipping
16
16
  * a HeroicLands **Foundry package**, content and all.
17
17
  *
18
- * This package was two: `content-build`, which read `assets/content/**` and
19
- * produced compendium packs, site content and the link manifest, and this one,
20
- * which read `lang/`, `styles/`, `src/` and the manifest template. They were
21
- * split by *input* on the theory that a module would use either — an adventure
22
- * shipping only notes needs no bundler, a variant shipping only behavior needs
23
- * no Markdown pipeline.
24
- *
25
- * **No consumer ever used one alone.** All three installed both, and this
26
- * package depended on the other besides, so the packaging half dragged the
27
- * content half in regardless. What the boundary actually cost was a
28
- * configuration file with two owners, two CLIs with a colliding `manifest`
29
- * command, and a two-repository dance for changes that touched one idea. The
30
- * two halves are now one package; the split survives as the shape of this
31
- * barrel and nothing more.
18
+ * Content and package operations share one configuration and one CLI. The
19
+ * exports here provide library entry points for both kinds of work.
32
20
  *
33
21
  * **Everything exported here is pure.** Functions take source text and return
34
22
  * findings or values; discovery, I/O and reporting stay with the caller. That
package/manifest.mjs CHANGED
@@ -52,7 +52,7 @@ import fs from "node:fs/promises";
52
52
  import fsSync from "node:fs";
53
53
  import path from "node:path";
54
54
 
55
- import { emitDiagnostic, positionOfYamlPath } from "./engine/diagnostics.mjs";
55
+ import { emitDiagnostic, formatDiagnostic, positionOfYamlPath } from "./engine/diagnostics.mjs";
56
56
  import { metadataFileName } from "./engine/metadata-index.mjs";
57
57
 
58
58
  /**
@@ -393,7 +393,7 @@ export function packFolderFindings({ packFolders, packs = [] }) {
393
393
  * package.
394
394
  *
395
395
  * `relationships` is the one manifest block with a second reader.
396
- * `@heroiclands/content-build` consumes it too, and v1.8.0 added
396
+ * `@heroiclands/package-build` consumes it too, and v1.8.0 added
397
397
  * `itemCatalog: true` as an opt-in on a declared dependency: it selects that
398
398
  * package's Item packs as a resolution
399
399
  * source for the actors pass. That is an instruction to the build, not a fact
@@ -408,7 +408,7 @@ export function packFolderFindings({ packFolders, packs = [] }) {
408
408
  * unlikely to be the last.
409
409
  *
410
410
  * A list is enough, and needs no prefix agreed between the two packages,
411
- * because the input is already closed: content-build normalises a relationship
411
+ * because the input is already closed: package-build normalises a relationship
412
412
  * to `id`, `type`, `manifest`, `compatibility` and its own build keys, and
413
413
  * rejects anything else at configuration time. A key that reaches here is one
414
414
  * the toolchain itself put there.
@@ -429,7 +429,7 @@ export const BUILD_ONLY_RELATIONSHIP_KEYS = Object.freeze(["itemCatalog"]);
429
429
  * unread: a key Foundry adds later should not need a release of this package.
430
430
  *
431
431
  * @param {Record<string, unknown>} relationships - The declared block, as
432
- * content-build resolved it.
432
+ * package-build resolved it.
433
433
  * @returns {Record<string, unknown>} It, without the build-only keys.
434
434
  */
435
435
  export function publishedRelationships(relationships) {
@@ -455,6 +455,13 @@ function withoutBuildKeys(entry) {
455
455
  );
456
456
  }
457
457
 
458
+ /** Require the displayed identity Foundry needs to install the package. */
459
+ function requireManifestTitle(config) {
460
+ const title = config.packageBuild?.manifest?.title;
461
+ if (typeof title !== "string" || title.trim() === "")
462
+ throw new Error("`packageBuild.manifest.title` must be a non-empty string.");
463
+ }
464
+
458
465
  /**
459
466
  * Build a Foundry package manifest from the resolved configuration.
460
467
  *
@@ -483,6 +490,7 @@ function withoutBuildKeys(entry) {
483
490
  * @returns {object} The manifest, ready to serialise.
484
491
  */
485
492
  export function buildManifest({ config, packageJson, artifact, flags }) {
493
+ requireManifestTitle(config);
486
494
  // `descriptionHtml` is the authored source of `description` — pulled out
487
495
  // so it never survives the spread below under its own name.
488
496
  const { descriptionHtml, ...declared } = config.packageBuild?.manifest ?? {};
@@ -641,6 +649,25 @@ function reportPackFolders(findings, configFile) {
641
649
  * ship. Nothing is written in that case.
642
650
  */
643
651
  export async function writeManifest({ config, packageJson, artifact, outDir, flags, configFile }) {
652
+ try {
653
+ requireManifestTitle(config);
654
+ } catch (error) {
655
+ let text;
656
+ if (configFile) {
657
+ try {
658
+ text = fsSync.readFileSync(configFile, "utf8");
659
+ } catch {
660
+ text = undefined;
661
+ }
662
+ }
663
+ const message = formatDiagnostic({
664
+ ...(configFile ? { file: configFile } : {}),
665
+ ...(text ? positionOfYamlPath(text, ["packageBuild", "manifest"]) : {}),
666
+ severity: "error",
667
+ message: error.message,
668
+ });
669
+ throw Object.assign(new Error(message), { located: true });
670
+ }
644
671
  const manifest = buildManifest({ config, packageJson, artifact, flags });
645
672
 
646
673
  const errors = reportPackFolders(