@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
@@ -0,0 +1,114 @@
1
+ /* SPDX-License-Identifier: GPL-3.0-or-later */
2
+
3
+ import MarkdownIt from "markdown-it";
4
+ import footnotePlugin from "markdown-it-footnote";
5
+ import deflistPlugin from "markdown-it-deflist";
6
+ import { parseExtensionAttributes } from "./extension-attributes.mjs";
7
+
8
+ const parser = new MarkdownIt({ html: true }).use(footnotePlugin).use(deflistPlugin);
9
+ const OPEN = /^:::(info|warn)(?:\s+(.*))?\s*$/;
10
+ const CLOSE = /^:::\s*$/;
11
+ const STYLES = Object.freeze({
12
+ info: { label: "Info", icon: "ℹ", color: "#2f6f9f", background: "#eef6fb" },
13
+ warn: { label: "Warning", icon: "⚠", color: "#9a6700", background: "#fff5db" },
14
+ });
15
+
16
+ /** Find inline info and warning blocks without interpreting code examples. */
17
+ export function scanAdmonitions(source) {
18
+ const lines = String(source ?? "").split("\n");
19
+ const blocks = [];
20
+ const errors = [];
21
+ let opening = null;
22
+ let codeFence = null;
23
+ for (let i = 0; i < lines.length; i++) {
24
+ const line = lines[i];
25
+ const fence = /^ {0,3}(`{3,}|~{3,})/.exec(line);
26
+ if (codeFence) {
27
+ if (fence && fence[1][0] === codeFence[0] && fence[1].length >= codeFence.length)
28
+ codeFence = null;
29
+ continue;
30
+ }
31
+ if (fence) {
32
+ codeFence = fence[1];
33
+ continue;
34
+ }
35
+ const open = OPEN.exec(line);
36
+ if (open) {
37
+ if (opening) {
38
+ errors.push({
39
+ line: i + 1,
40
+ column: 1,
41
+ message: "nested info and warning blocks are not supported",
42
+ });
43
+ continue;
44
+ }
45
+ const raw = open[2]?.trim() ?? "";
46
+ let id = "";
47
+ if (raw) {
48
+ if (!/^\{[^}\n]*\}$/.test(raw))
49
+ errors.push({
50
+ line: i + 1,
51
+ column: 1,
52
+ message: "block attributes need {#id} syntax",
53
+ });
54
+ else {
55
+ const attrs = parseExtensionAttributes(raw.slice(1, -1));
56
+ id = attrs.id;
57
+ for (const message of attrs.problems)
58
+ errors.push({ line: i + 1, column: 1, message });
59
+ if (attrs.classes.length || Object.keys(attrs.values).length)
60
+ errors.push({
61
+ line: i + 1,
62
+ column: 1,
63
+ message: "info and warn accept only an id",
64
+ });
65
+ }
66
+ }
67
+ opening = { start: i, kind: open[1], id };
68
+ continue;
69
+ }
70
+ if (CLOSE.test(line) && opening) {
71
+ const body = lines
72
+ .slice(opening.start + 1, i)
73
+ .join("\n")
74
+ .trim();
75
+ if (!body)
76
+ errors.push({
77
+ line: opening.start + 1,
78
+ column: 1,
79
+ message: `${opening.kind} block is empty`,
80
+ });
81
+ blocks.push({ ...opening, end: i, body });
82
+ opening = null;
83
+ }
84
+ }
85
+ if (opening)
86
+ errors.push({
87
+ line: opening.start + 1,
88
+ column: 1,
89
+ message: `${opening.kind} block needs a closing ::: line`,
90
+ });
91
+ return { blocks, errors };
92
+ }
93
+
94
+ /** Render info and warning blocks as styled, labelled HTML. */
95
+ export function renderAdmonitions(source, renderMarkdown = parser.render.bind(parser)) {
96
+ const { blocks, errors } = scanAdmonitions(source);
97
+ if (errors.length) return { markdown: source, errors };
98
+ const lines = String(source ?? "").split("\n");
99
+ const output = [];
100
+ let cursor = 0;
101
+ for (const block of blocks) {
102
+ const style = STYLES[block.kind];
103
+ output.push(...lines.slice(cursor, block.start));
104
+ output.push(
105
+ `<aside${block.id ? ` id="${block.id}"` : ""} class="sohl-admonition sohl-admonition-${block.kind}" style="border-left: 3px solid ${style.color}; background: ${style.background}; padding: 0.5em 0.8em; margin: 0.75em 0;">`,
106
+ `<p><strong><span aria-hidden="true">${style.icon}</span> ${style.label}</strong></p>`,
107
+ renderMarkdown(block.body).trim(),
108
+ "</aside>",
109
+ );
110
+ cursor = block.end + 1;
111
+ }
112
+ output.push(...lines.slice(cursor));
113
+ return { markdown: output.join("\n"), errors: [] };
114
+ }
@@ -0,0 +1,181 @@
1
+ /*
2
+ * SPDX-License-Identifier: GPL-3.0-or-later
3
+ */
4
+
5
+ import MarkdownIt from "markdown-it";
6
+ import footnotePlugin from "markdown-it-footnote";
7
+ import deflistPlugin from "markdown-it-deflist";
8
+
9
+ import { slugify } from "./content-slug.mjs";
10
+ import { parseExtensionAttributes } from "./extension-attributes.mjs";
11
+
12
+ const parser = new MarkdownIt({ html: true }).use(footnotePlugin).use(deflistPlugin);
13
+ const OPEN = /^:::caption\s+(\{[^}\n]*\})\s*$/;
14
+ const CAPTION_LINE = /^:::caption\b/;
15
+ const CLOSE = /^:::\s*$/;
16
+
17
+ /** A caption's automatically determined kind. */
18
+ export const CAPTION_NAMES = Object.freeze({
19
+ code: "Code",
20
+ table: "Table",
21
+ figure: "Figure",
22
+ prose: "Prose",
23
+ });
24
+
25
+ /** Number a sequence of captions with counters shared across documents. */
26
+ export function numberCaptions(captions, counts) {
27
+ return captions.map((caption) => {
28
+ const number = (counts[caption.kind] ?? 0) + 1;
29
+ counts[caption.kind] = number;
30
+ return { ...caption, number, label: `${CAPTION_NAMES[caption.kind]} ${number}` };
31
+ });
32
+ }
33
+
34
+ /**
35
+ * Read caption directives and the Markdown block immediately after each one.
36
+ * SQL fences are expanded before this pass, so their output is a table.
37
+ *
38
+ * @param {string} source - A note body with expanded tables.
39
+ * @returns {{captions: Array<{id: string, text: string, kind: string, number: number,
40
+ * label: string, line: number, blockStart: number, blockEnd: number,
41
+ * directiveEnd: number}>, errors: Array<{line: number, column: number, message: string}>}}
42
+ */
43
+ export function scanCaptions(source) {
44
+ const lines = String(source ?? "").split("\n");
45
+ const captions = [];
46
+ const errors = [];
47
+ const counts = { code: 0, table: 0, figure: 0, prose: 0 };
48
+ const ids = new Set();
49
+ const headingIds = [];
50
+ let codeFence = null;
51
+
52
+ for (let i = 0; i < lines.length; i++) {
53
+ const line = lines[i];
54
+ const fence = /^ {0,3}(`{3,}|~{3,})/.exec(line);
55
+ if (codeFence) {
56
+ if (fence && fence[1][0] === codeFence[0] && fence[1].length >= codeFence.length)
57
+ codeFence = null;
58
+ continue;
59
+ }
60
+ if (fence) {
61
+ codeFence = fence[1];
62
+ continue;
63
+ }
64
+ const heading = /^\s*#{1,6}\s+.+?\{#([^}]+)\}\s*$/.exec(line);
65
+ if (heading) headingIds.push({ id: slugify(heading[1].trim()), line: i + 1 });
66
+ if (!CAPTION_LINE.test(line)) continue;
67
+ const open = OPEN.exec(line);
68
+ if (!open) {
69
+ errors.push({ line: i + 1, column: 1, message: "caption needs {#anchor} attributes" });
70
+ continue;
71
+ }
72
+ const attributes = parseExtensionAttributes(open[1].slice(1, -1));
73
+ if (
74
+ attributes.problems.length ||
75
+ !attributes.id ||
76
+ attributes.classes.length ||
77
+ Object.keys(attributes.values).length
78
+ ) {
79
+ errors.push({
80
+ line: i + 1,
81
+ column: 1,
82
+ message: attributes.problems[0] ?? "caption accepts only {#anchor}",
83
+ });
84
+ continue;
85
+ }
86
+ const id = attributes.id;
87
+ const start = i;
88
+ let close = i + 1;
89
+ while (close < lines.length && !CLOSE.test(lines[close])) close++;
90
+ if (close === lines.length) {
91
+ errors.push({ line: start + 1, column: 1, message: "caption needs a closing :::" });
92
+ continue;
93
+ }
94
+ const caption = lines
95
+ .slice(i + 1, close)
96
+ .join("\n")
97
+ .trim();
98
+ if (!caption) errors.push({ line: start + 1, column: 1, message: "caption text is empty" });
99
+ if (ids.has(slugify(id)))
100
+ errors.push({ line: start + 1, column: 1, message: `duplicate caption id "${id}"` });
101
+ ids.add(slugify(id));
102
+
103
+ let next = close + 1;
104
+ while (next < lines.length && !lines[next].trim()) next++;
105
+ if (next === lines.length || CAPTION_LINE.test(lines[next])) {
106
+ errors.push({ line: start + 1, column: 1, message: "caption needs a following block" });
107
+ i = close;
108
+ continue;
109
+ }
110
+ const tokens = parser.parse(lines.slice(next).join("\n"), {});
111
+ const first = tokens.find((token) => token.level === 0 && token.map);
112
+ if (!first) {
113
+ errors.push({ line: start + 1, column: 1, message: "caption needs a following block" });
114
+ i = close;
115
+ continue;
116
+ }
117
+ const blockEnd = next + first.map[1];
118
+ const block = lines.slice(next, blockEnd).join("\n");
119
+ const kind =
120
+ first.type === "table_open" ? "table"
121
+ : first.type === "fence" && /^\s*sql\b/i.test(first.info ?? "") ? "table"
122
+ : first.type === "fence" || first.type === "code_block" ? "code"
123
+ : /^(?:!\[|!\[\[|<figure\b|<img\b)/.test(block.trim()) ? "figure"
124
+ : "prose";
125
+ const number = ++counts[kind];
126
+ captions.push({
127
+ id,
128
+ text: caption,
129
+ kind,
130
+ number,
131
+ label: `${CAPTION_NAMES[kind]} ${number}`,
132
+ line: start + 1,
133
+ blockStart: next,
134
+ blockEnd,
135
+ directiveEnd: close + 1,
136
+ });
137
+ i = close;
138
+ }
139
+ for (const heading of headingIds) {
140
+ if (ids.has(heading.id))
141
+ errors.push({
142
+ line: heading.line,
143
+ column: 1,
144
+ message: `heading and caption declare the same anchor "${heading.id}"`,
145
+ });
146
+ }
147
+ return { captions, errors };
148
+ }
149
+
150
+ /** Render captioned blocks as HTML, leaving other Markdown untouched. */
151
+ export function renderCaptionBlocks(source, renderMarkdown = parser.render.bind(parser), numbers) {
152
+ const { captions, errors } = scanCaptions(source);
153
+ if (errors.length) return { markdown: source, errors };
154
+ const byId = numbers ? new Map(numbers.map((entry) => [entry.id, entry])) : new Map();
155
+ const lines = String(source ?? "").split("\n");
156
+ const output = [];
157
+ let cursor = 0;
158
+ const escape = (value) =>
159
+ String(value)
160
+ .replace(/&/g, "&amp;")
161
+ .replace(/</g, "&lt;")
162
+ .replace(/>/g, "&gt;")
163
+ .replace(/"/g, "&quot;");
164
+ for (const caption of captions) {
165
+ output.push(...lines.slice(cursor, caption.line - 1));
166
+ const numbered = byId.get(caption.id) ?? caption;
167
+ let html = renderMarkdown(lines.slice(caption.blockStart, caption.blockEnd).join("\n"));
168
+ if (caption.kind === "figure")
169
+ html = html.replace(/<figcaption\b[^>]*>[\s\S]*?<\/figcaption>/i, "");
170
+ const captionHtml = parser.renderInline(caption.text);
171
+ output.push(
172
+ `<div id="${escape(slugify(caption.id))}" class="content-caption content-caption-${caption.kind}">`,
173
+ html.trim(),
174
+ `<p class="content-caption-label">${escape(numbered.label)}: ${captionHtml}</p>`,
175
+ "</div>",
176
+ );
177
+ cursor = caption.blockEnd;
178
+ }
179
+ output.push(...lines.slice(cursor));
180
+ return { markdown: output.join("\n"), errors: [] };
181
+ }
@@ -23,8 +23,8 @@
23
23
  *
24
24
  * ## An embed resolves to an ordinary image, and that is the whole design
25
25
  *
26
- * `![[thorn|Thorn]]{float: top-left}` becomes
27
- * `![Thorn](thalorna/assets/images/beings/thorn.webp){float: top-left}` in the
26
+ * `![[thorn|Thorn]]{float=top-left}` becomes
27
+ * `![Thorn](thalorna/assets/images/beings/thorn.webp){float=top-left}` in the
28
28
  * source text, before any surface renders it. Everything downstream is machinery
29
29
  * that already exists: {@link module:engine/content-images.imagePlugin} draws the
30
30
  * figure for a Foundry journal and for the book's Typst walk,
@@ -0,0 +1,34 @@
1
+ /* SPDX-License-Identifier: GPL-3.0-or-later */
2
+
3
+ /**
4
+ * Separate top-level footnote definitions from prose while retaining line numbers.
5
+ * Definitions belong to the note and can be used by any rendered page.
6
+ * @param {string} source - Authored Markdown.
7
+ * @returns {{markdown: string, definitions: string}} Body with blanked definitions and their source.
8
+ */
9
+ export function separateFootnotes(source) {
10
+ const lines = String(source ?? "").split("\n");
11
+ const body = [...lines];
12
+ const definitions = [];
13
+ let fence = null;
14
+ for (let i = 0; i < lines.length; i++) {
15
+ const marker = /^ {0,3}(`{3,}|~{3,})/.exec(lines[i]);
16
+ if (fence) {
17
+ if (marker && marker[1][0] === fence[0] && marker[1].length >= fence.length)
18
+ fence = null;
19
+ continue;
20
+ }
21
+ if (marker) {
22
+ fence = marker[1];
23
+ continue;
24
+ }
25
+ if (!/^\[\^[^\]\s]+\]:/.test(lines[i])) continue;
26
+ const start = i;
27
+ i++;
28
+ while (i < lines.length && (!lines[i].trim() || /^(?: {4}|\t)/.test(lines[i]))) i++;
29
+ definitions.push(lines.slice(start, i).join("\n").trimEnd());
30
+ for (let at = start; at < i; at++) body[at] = "";
31
+ i--;
32
+ }
33
+ return { markdown: body.join("\n"), definitions: definitions.join("\n\n") };
34
+ }
@@ -141,7 +141,7 @@ export function declaredPaths(artifact) {
141
141
  * mappings" instead of "on a `weapongear`". Reading `on a \`the shared
142
142
  * mappings\`` would be the alternative, and a diagnostic is prose.
143
143
  *
144
- * @param {object} finding - `{system, systemVersion, noteType, source, target, shared}`.
144
+ * @param {{system: string, systemVersion: string, noteType: string, source: string, target: string, shared: boolean}} finding - The unresolved mapping.
145
145
  * @returns {string} The message.
146
146
  */
147
147
  export function undeclaredTargetMessage({
@@ -468,7 +468,7 @@ function under(path, prefix) {
468
468
  /**
469
469
  * What an author is told when the specification and the declaration disagree.
470
470
  *
471
- * @param {object} finding - `{noteType, source, target, name, to}`.
471
+ * @param {{noteType: string, source: string, target: string, name: string, to: string}} finding - The differing mapping.
472
472
  * @returns {string} The message.
473
473
  */
474
474
  export function fieldDriftMessage({ noteType, source, target, name, to }) {
@@ -12,68 +12,12 @@
12
12
  */
13
13
 
14
14
  /**
15
- * Reading `docs/content-format.md` as data.
15
+ * Reads the structured note-format contract shipped with this package.
16
16
  *
17
- * The content format — three frontmatter regions, a note vocabulary with its
18
- * own `type` and `subType`, and a declared map from each note type onto each
19
- * system's document fields — is prose, because that is the only form in which
20
- * the *reasons* survive. But two of the things it states are checkable, and
21
- * were checked by throwaway scripts while it was being drafted:
22
- *
23
- * - **every `system.*` target it names** must exist in the naming system's
24
- * published `schema.json`, or the specification and the system disagree; and
25
- * - **every authored note** should carry only the keys the format declares for
26
- * its type, which during the migration is a progress bar as much as a
27
- * check.
28
- *
29
- * Both need the document as data, and this is the module that supplies it.
30
- *
31
- * **It reads the document's own tables rather than a transcription of them.**
32
- * A hardcoded list of targets and per-type vocabularies would be a second copy
33
- * of the specification, free to drift from the first the moment either is
34
- * edited — which is exactly the failure the checks exist to prevent, moved one
35
- * level up. So the parser knows the *shape* of the tables the document uses and
36
- * nothing about their contents: no type name, no field name and no system name
37
- * is written here. Editing the document changes what the checks assert.
38
- *
39
- * **Three table shapes carry everything.**
40
- *
41
- * | table | recognised by its first header cell | yields |
42
- * | --- | --- | --- |
43
- * | the per-type vocabulary | `` `data` property `` | the keys that type's `data:` block may carry |
44
- * | the per-type mapping | `shared source` | one claim per `system.*` cell |
45
- * | a closed vocabulary | `` `<name>` value `` | the values `<name>` admits |
46
- *
47
- * A mapping table's remaining header cells name the systems (`→ sohl`,
48
- * `→ hm3`), so the system vocabulary comes from the document too.
49
- *
50
- * **A closed vocabulary is a table because it is a list with consequences.**
51
- * Where the format admits a fixed set of names and refuses the rest, each name
52
- * takes a row beside what it means, which is the only place a reader can
53
- * compare them. The header names the vocabulary rather than the parser, so a
54
- * second one costs a table and nothing here.
55
- *
56
- * **The other half of a type's vocabulary is a bullet list, not a table.** A
57
- * type's `subType` values are stated as `**subType**:` followed by one bullet
58
- * per value, `- <value>` or `- <value>: <definition>`, and that is read here
59
- * for the same reason the tables are: so the specification and
60
- * `note-vocabulary.mjs` cannot disagree about which genres exist. The
61
- * one shape is enforced rather than guessed at — the document wrote them five
62
- * ways, and a reader that accepted every spelling would accept the sixth by
63
- * reading the section as declaring nothing, which is the drift it exists to
64
- * catch. An unrecognised shape throws.
65
- *
66
- * **A mapping table before the first `### type:` heading is the shared one.**
67
- * The document states the rows every type maps identically once, at the top,
68
- * and omits them from all sixteen per-type tables — so a parser that only ever
69
- * looked inside a type's section could not see them, and the eight rows they
70
- * cover were checked by nothing. Position is the whole distinction:
71
- * there is no marker to read and none is wanted, since the document's own
72
- * argument for stating them once is that they belong to no type in particular. A cell that
73
- * names no field — `NA`, `**see above**`, a `flags.*` path — is not a claim,
74
- * and is skipped rather than reported: the check is about `system.*` targets,
75
- * and a column reading NA is the document saying this type produces no document
76
- * there.
17
+ * Types, subtype values, field paths, system mappings, and closed vocabularies
18
+ * have located declarations. The guides can change their headings and tables
19
+ * without changing validation. A caller may supply a Markdown specification
20
+ * explicitly through `--spec`; the bundled contract is YAML.
77
21
  *
78
22
  * @module
79
23
  */
@@ -81,35 +25,34 @@
81
25
  import fs from "node:fs";
82
26
  import path from "node:path";
83
27
  import { fileURLToPath } from "node:url";
28
+ import YAML from "yaml";
84
29
 
85
30
  /**
86
- * The specification this package ships.
31
+ * The structured format contract this package ships.
87
32
  *
88
33
  * Resolved from this module rather than from the working directory: a consumer
89
- * runs `content-build content-format` inside its own repository, and the
90
- * document it should be checked against is the one that came with the toolchain
34
+ * runs `package-build content-format` inside its own repository, and the
35
+ * contract it should be checked against is the one that came with the toolchain
91
36
  * version it resolved — the same rule `--version` follows.
92
37
  *
93
38
  * @type {string}
94
39
  */
95
40
  export const CONTENT_FORMAT_PATH = path.join(
96
41
  path.dirname(fileURLToPath(import.meta.url)),
97
- "..",
98
- "docs",
99
- "content-format.md",
42
+ "content-format.yaml",
100
43
  );
101
44
 
102
45
  /**
103
46
  * What one note type's section declares.
104
47
  *
105
48
  * @typedef {object} TypeSpec
106
- * @property {string} name - The note type, as the `### type:` heading spells it.
107
- * @property {number} line - 1-based line of that heading.
49
+ * @property {string} name - The note type as declared in the contract.
50
+ * @property {number} line - 1-based line of that declaration.
108
51
  * @property {Set<string>} dataKeys - The head segment of each declared `data`
109
52
  * property — what a note actually writes. `appearance.eye_color` is authored
110
53
  * as `appearance`, so that is the key recorded.
111
54
  * @property {Set<string>} dataPaths - The declared paths, whole.
112
- * @property {string[]} subTypes - The `subType` values the section enumerates,
55
+ * @property {string[]} subTypes - The declared `subType` values,
113
56
  * in document order — empty when it states none, which is the ordinary case
114
57
  * for a type that has no `subType` at all.
115
58
  */
@@ -426,11 +369,122 @@ export function parseContentFormat(text, { file = CONTENT_FORMAT_PATH } = {}) {
426
369
  }
427
370
 
428
371
  /**
429
- * Read and parse the specification from disk.
372
+ * Read and parse the bundled contract or a caller-supplied specification.
430
373
  *
431
- * @param {string} [file] - The document. Defaults to {@link CONTENT_FORMAT_PATH}.
374
+ * @param {string} [file] - The contract. Defaults to {@link CONTENT_FORMAT_PATH}.
432
375
  * @returns {ContentFormat} What it declares.
433
376
  */
434
377
  export function loadContentFormat(file = CONTENT_FORMAT_PATH) {
435
- return parseContentFormat(fs.readFileSync(file, "utf8"), { file });
378
+ const source = fs.readFileSync(file, "utf8");
379
+ return file.endsWith(".md") ?
380
+ parseContentFormat(source, { file })
381
+ : parseStructuredContentFormat(source, { file });
382
+ }
383
+
384
+ /**
385
+ * Read the machine contract without depending on the presentation of a guide.
386
+ * A Markdown file remains accepted by `--spec` for callers carrying a custom
387
+ * specification; the shipped contract is YAML.
388
+ *
389
+ * @param {string} text - YAML content.
390
+ * @param {object} [opts]
391
+ * @param {string} [opts.file] - Location reported in diagnostics.
392
+ * @returns {ContentFormat} Types, mappings, and closed vocabularies.
393
+ */
394
+ export function parseStructuredContentFormat(text, { file = CONTENT_FORMAT_PATH } = {}) {
395
+ const lines = new YAML.LineCounter();
396
+ const document = YAML.parseDocument(text, { lineCounter: lines, uniqueKeys: true });
397
+ if (document.errors.length) {
398
+ const fault = document.errors[0];
399
+ const position = fault.pos?.[0] === undefined ? null : lines.linePos(fault.pos[0]);
400
+ throw new Error(
401
+ `${file}${position ? `:${position.line}:${position.col}` : ""}: error: ${fault.message}`,
402
+ );
403
+ }
404
+ const root = document.toJS();
405
+ const mapping = (value) => value && typeof value === "object" && !Array.isArray(value);
406
+ if (
407
+ root?.version !== 1 ||
408
+ !mapping(root.types) ||
409
+ !Array.isArray(root.claims) ||
410
+ !mapping(root.vocabularies)
411
+ ) {
412
+ throw new Error(
413
+ `${file}: error: expected format contract version 1 with types, claims, and vocabularies`,
414
+ );
415
+ }
416
+ const at = (node) => (node?.range?.[0] === undefined ? null : lines.linePos(node.range[0]));
417
+ const errorAt = (node, message) => {
418
+ const position = at(node);
419
+ return new Error(
420
+ `${file}${position ? `:${position.line}:${position.col}` : ""}: error: ${message}`,
421
+ );
422
+ };
423
+ const types = new Map();
424
+ for (const [name, spec] of Object.entries(root.types)) {
425
+ const node = document.getIn(["types", name], true);
426
+ if (!Array.isArray(spec.data) || !Array.isArray(spec.subTypes)) {
427
+ throw errorAt(node, `type ${name} needs data and subTypes lists`);
428
+ }
429
+ for (const [index, field] of spec.data.entries()) {
430
+ if (typeof field !== "string" || !field.trim()) {
431
+ throw errorAt(
432
+ document.getIn(["types", name, "data", index], true),
433
+ `type ${name} has an empty or non-string data field`,
434
+ );
435
+ }
436
+ }
437
+ for (const [index, subType] of spec.subTypes.entries()) {
438
+ if (typeof subType !== "string" || !subType.trim()) {
439
+ throw errorAt(
440
+ document.getIn(["types", name, "subTypes", index], true),
441
+ `type ${name} has an empty or non-string subType`,
442
+ );
443
+ }
444
+ }
445
+ types.set(name, {
446
+ name,
447
+ line: at(node)?.line,
448
+ dataKeys: new Set(spec.data.map((field) => field.split(".")[0])),
449
+ dataPaths: new Set(spec.data),
450
+ subTypes: spec.subTypes,
451
+ });
452
+ }
453
+ const claims = root.claims.map((claim, index) => {
454
+ const node = document.getIn(["claims", index], true);
455
+ const position = at(node);
456
+ if (
457
+ !mapping(claim) ||
458
+ ["noteType", "system", "source", "target"].some(
459
+ (key) => typeof claim[key] !== "string" || !claim[key].trim(),
460
+ )
461
+ ) {
462
+ throw errorAt(node, "a mapping needs noteType, system, source, and target");
463
+ }
464
+ return {
465
+ ...claim,
466
+ ...(position ? { line: position.line, column: position.col } : {}),
467
+ };
468
+ });
469
+ const vocabularies = new Map();
470
+ for (const [name, values] of Object.entries(root.vocabularies)) {
471
+ const node = document.getIn(["vocabularies", name], true);
472
+ if (!Array.isArray(values)) {
473
+ throw errorAt(node, `vocabulary ${name} needs a values list`);
474
+ }
475
+ for (const [index, value] of values.entries()) {
476
+ if (typeof value !== "string" || !value.trim()) {
477
+ throw errorAt(
478
+ document.getIn(["vocabularies", name, index], true),
479
+ `vocabulary ${name} has an empty or non-string value`,
480
+ );
481
+ }
482
+ }
483
+ vocabularies.set(name, {
484
+ name,
485
+ line: at(node)?.line,
486
+ values,
487
+ });
488
+ }
489
+ return { file, types, claims, vocabularies };
436
490
  }