@heroiclands/package-build 23.12.8 → 24.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (258) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +3 -2
  3. package/assets/preview/gitea.ini +12 -7
  4. package/assets/styles/content-base.css +293 -24
  5. package/bin/content-commands.mjs +370 -213
  6. package/bin/package-build-preview.mjs +2 -2
  7. package/bin/package-build.mjs +3 -2
  8. package/ci/check-banners.mjs +7 -7
  9. package/config.mjs +21 -6
  10. package/content-config.mjs +199 -80
  11. package/docs/api.md +110 -89
  12. package/docs/authoring/links-and-markup.md +469 -97
  13. package/docs/commands.md +259 -78
  14. package/docs/configuration.md +211 -103
  15. package/docs/content-format.md +4 -4
  16. package/docs/diagnostics.md +44 -2
  17. package/docs/getting-started.md +1 -1
  18. package/docs/guides/icons.md +1 -1
  19. package/docs/guides/module-project.md +5 -8
  20. package/docs/guides/release-and-deploy.md +2 -2
  21. package/docs/guides/site.md +112 -23
  22. package/docs/guides/system-project.md +1 -1
  23. package/docs/guides/writing-in-invented-scripts.md +1247 -0
  24. package/docs/index.md +1 -0
  25. package/docs/project-setup.md +28 -30
  26. package/docs/reference/format-details.md +677 -319
  27. package/docs/reference/note-types.md +47 -10
  28. package/docs/reference/theme-pages.md +178 -108
  29. package/docs/source-preview.md +16 -2
  30. package/engine/anchors.mjs +2 -2
  31. package/engine/asset-shipping.mjs +22 -10
  32. package/engine/base-compiler.mjs +62 -104
  33. package/engine/changelog-lint.mjs +1 -1
  34. package/engine/code-fences.mjs +3 -3
  35. package/engine/content-address.mjs +1 -1
  36. package/engine/content-alerts.mjs +8 -4
  37. package/engine/content-blocks.mjs +29 -14
  38. package/engine/content-embeds.mjs +1 -1
  39. package/engine/content-expand.mjs +376 -0
  40. package/engine/content-figures.mjs +18 -11
  41. package/engine/content-format.yaml +8 -0
  42. package/engine/content-glyphs.mjs +213 -0
  43. package/engine/content-images.mjs +6 -7
  44. package/engine/content-index.mjs +1 -1
  45. package/engine/content-links.mjs +3 -3
  46. package/engine/content-lint.mjs +1 -1
  47. package/engine/content-render.mjs +453 -0
  48. package/engine/content-scripts.mjs +376 -43
  49. package/engine/content-spans.mjs +6 -3
  50. package/engine/content-styles.mjs +91 -0
  51. package/engine/content-symbols.mjs +186 -0
  52. package/engine/content-verse.mjs +738 -0
  53. package/engine/core-report.mjs +169 -0
  54. package/engine/core-vocabulary.mjs +208 -0
  55. package/engine/dictionary-html.mjs +119 -0
  56. package/engine/dictionary-rows.mjs +15 -4
  57. package/engine/field-reference.mjs +5 -5
  58. package/engine/first-use.mjs +17 -27
  59. package/engine/frontmatter-lint.mjs +1 -1
  60. package/engine/heading-attributes.mjs +89 -0
  61. package/engine/helpers.mjs +61 -101
  62. package/engine/homepage.mjs +17 -29
  63. package/engine/html-minify.mjs +70 -0
  64. package/engine/index.mjs +8 -5
  65. package/engine/key-case-collisions.mjs +136 -0
  66. package/engine/language-data.mjs +203 -6
  67. package/engine/language-rows.mjs +8 -2
  68. package/engine/language-terms.mjs +186 -16
  69. package/engine/language-words.mjs +12 -0
  70. package/engine/logographic.mjs +255 -0
  71. package/engine/map-lexicon.mjs +262 -47
  72. package/engine/map-places.mjs +5 -4
  73. package/engine/markdown-expressions.mjs +0 -2
  74. package/engine/markdownlint-run.mjs +9 -3
  75. package/engine/markup-validation.mjs +21 -0
  76. package/engine/metre-declaration.mjs +134 -0
  77. package/engine/names.mjs +36 -9
  78. package/engine/note-matter.mjs +66 -0
  79. package/engine/note-vocabulary.mjs +187 -3
  80. package/engine/pdf-build.mjs +69 -133
  81. package/engine/pdf-render.mjs +205 -58
  82. package/engine/post-fields.mjs +157 -0
  83. package/engine/preview-index.mjs +130 -0
  84. package/engine/preview-source.mjs +450 -0
  85. package/engine/project-init.mjs +5 -12
  86. package/engine/prose-config.mjs +3 -3
  87. package/engine/read-stdin.mjs +29 -0
  88. package/engine/run-rows.mjs +2 -2
  89. package/engine/run-words.mjs +6 -112
  90. package/engine/script-emphasis-terms.mjs +38 -0
  91. package/engine/script-emphasis.mjs +295 -0
  92. package/engine/script-font-delivery.mjs +4 -4
  93. package/engine/site-build.mjs +260 -315
  94. package/engine/site-config.mjs +26 -238
  95. package/engine/site-files.mjs +175 -0
  96. package/engine/site-index.mjs +9 -4
  97. package/engine/site-preview.mjs +52 -525
  98. package/engine/site-render.mjs +593 -0
  99. package/engine/site-root.mjs +22 -13
  100. package/engine/site-search.mjs +161 -0
  101. package/engine/site-serve.mjs +273 -0
  102. package/engine/site-theme-banner.mjs +154 -0
  103. package/engine/site-theme-urls.mjs +125 -0
  104. package/engine/site-theme.mjs +926 -0
  105. package/engine/sql-tables.mjs +20 -3
  106. package/engine/text-maps.mjs +57 -10
  107. package/engine/typographer.mjs +248 -0
  108. package/engine/verse-metre.mjs +357 -0
  109. package/engine/wikilink-syntax.mjs +29 -47
  110. package/engine/wikilinks.mjs +758 -279
  111. package/engine/word-forms.mjs +364 -0
  112. package/engine/word-rows.mjs +12 -2
  113. package/engine/word-terms.mjs +38 -4
  114. package/manifest.mjs +3 -4
  115. package/package.json +11 -8
  116. package/{hugo-theme → site-theme}/data/banners.yaml +6 -10
  117. package/site-theme/layouts/base.hbs +31 -0
  118. package/site-theme/layouts/blog-list.hbs +30 -0
  119. package/site-theme/layouts/blog-post.hbs +40 -0
  120. package/site-theme/layouts/home.hbs +35 -0
  121. package/site-theme/layouts/list.hbs +20 -0
  122. package/site-theme/layouts/not-found.hbs +31 -0
  123. package/site-theme/layouts/page.hbs +7 -0
  124. package/site-theme/partials/copy-buttons.hbs +37 -0
  125. package/site-theme/partials/draft-notice.hbs +5 -0
  126. package/site-theme/partials/exturl.hbs +2 -0
  127. package/{hugo-theme/layouts/partials/footer.html → site-theme/partials/footer.hbs} +10 -7
  128. package/site-theme/partials/header.hbs +44 -0
  129. package/site-theme/partials/hero-banner.hbs +11 -0
  130. package/site-theme/partials/infobox-entry.hbs +2 -0
  131. package/site-theme/partials/infobox-section.hbs +31 -0
  132. package/site-theme/partials/infobox-value.hbs +2 -0
  133. package/site-theme/partials/infobox.hbs +18 -0
  134. package/site-theme/partials/list-members.hbs +9 -0
  135. package/site-theme/partials/nav-links.hbs +5 -0
  136. package/site-theme/partials/page-body.hbs +48 -0
  137. package/site-theme/partials/page-list.hbs +14 -0
  138. package/site-theme/partials/related.hbs +24 -0
  139. package/site-theme/partials/search-meta.hbs +6 -0
  140. package/site-theme/partials/search-names.hbs +2 -0
  141. package/site-theme/partials/search.hbs +52 -0
  142. package/site-theme/partials/tags.hbs +4 -0
  143. package/site-theme/partials/toc.hbs +7 -0
  144. package/{hugo-theme → site-theme}/static/css/style.css +106 -14
  145. package/stage.mjs +1 -2
  146. package/types/config.d.mts +8 -4
  147. package/types/content-config.d.mts +15 -15
  148. package/types/engine/asset-shipping.d.mts +5 -2
  149. package/types/engine/base-compiler.d.mts +20 -2
  150. package/types/engine/code-fences.d.mts +3 -3
  151. package/types/engine/content-address.d.mts +1 -1
  152. package/types/engine/content-alerts.d.mts +8 -2
  153. package/types/engine/content-blocks.d.mts +11 -4
  154. package/types/engine/content-expand.d.mts +288 -0
  155. package/types/engine/content-figures.d.mts +11 -3
  156. package/types/engine/content-glyphs.d.mts +85 -0
  157. package/types/engine/content-images.d.mts +6 -7
  158. package/types/engine/content-links.d.mts +1 -1
  159. package/types/engine/content-render.d.mts +199 -0
  160. package/types/engine/content-scripts.d.mts +70 -4
  161. package/types/engine/content-spans.d.mts +1 -1
  162. package/types/engine/content-styles.d.mts +33 -0
  163. package/types/engine/content-symbols.d.mts +35 -0
  164. package/types/engine/content-verse.d.mts +240 -0
  165. package/types/engine/core-report.d.mts +45 -0
  166. package/types/engine/core-vocabulary.d.mts +53 -0
  167. package/types/engine/dictionary-html.d.mts +8 -0
  168. package/types/engine/dictionary-rows.d.mts +10 -0
  169. package/types/engine/field-reference.d.mts +2 -2
  170. package/types/engine/heading-attributes.d.mts +20 -0
  171. package/types/engine/helpers.d.mts +3 -10
  172. package/types/engine/homepage.d.mts +11 -24
  173. package/types/engine/html-minify.d.mts +9 -0
  174. package/types/engine/index.d.mts +2 -1
  175. package/types/engine/key-case-collisions.d.mts +28 -0
  176. package/types/engine/language-data.d.mts +0 -2
  177. package/types/engine/language-terms.d.mts +24 -2
  178. package/types/engine/logographic.d.mts +45 -0
  179. package/types/engine/map-lexicon.d.mts +66 -13
  180. package/types/engine/map-places.d.mts +8 -0
  181. package/types/engine/markdown-expressions.d.mts +0 -2
  182. package/types/engine/metre-declaration.d.mts +39 -0
  183. package/types/engine/names.d.mts +34 -3
  184. package/types/engine/note-matter.d.mts +48 -0
  185. package/types/engine/pdf-render.d.mts +6 -2
  186. package/types/engine/post-fields.d.mts +44 -0
  187. package/types/engine/preview-index.d.mts +72 -0
  188. package/types/engine/preview-source.d.mts +51 -0
  189. package/types/engine/read-stdin.d.mts +11 -0
  190. package/types/engine/run-rows.d.mts +2 -2
  191. package/types/engine/run-words.d.mts +0 -15
  192. package/types/engine/script-emphasis-terms.d.mts +21 -0
  193. package/types/engine/script-emphasis.d.mts +84 -0
  194. package/types/engine/script-font-delivery.d.mts +4 -4
  195. package/types/engine/site-build.d.mts +71 -113
  196. package/types/engine/site-config.d.mts +14 -134
  197. package/types/engine/site-files.d.mts +73 -0
  198. package/types/engine/site-index.d.mts +11 -3
  199. package/types/engine/site-preview.d.mts +15 -19
  200. package/types/engine/site-render.d.mts +171 -0
  201. package/types/engine/site-root.d.mts +12 -4
  202. package/types/engine/site-search.d.mts +54 -0
  203. package/types/engine/site-serve.d.mts +44 -0
  204. package/types/engine/site-theme-banner.d.mts +51 -0
  205. package/types/engine/site-theme-urls.d.mts +60 -0
  206. package/types/engine/site-theme.d.mts +127 -0
  207. package/types/engine/sql-tables.d.mts +4 -1
  208. package/types/engine/text-maps.d.mts +17 -3
  209. package/types/engine/typographer.d.mts +11 -0
  210. package/types/engine/verse-metre.d.mts +131 -0
  211. package/types/engine/wikilink-syntax.d.mts +30 -48
  212. package/types/engine/wikilinks.d.mts +330 -14
  213. package/types/engine/word-forms.d.mts +83 -0
  214. package/types/manifest.d.mts +3 -4
  215. package/types/stage.d.mts +1 -2
  216. package/types/vendor/markdownlint.d.mts +8 -0
  217. package/vendor/markdownlint.mjs +20388 -0
  218. package/engine/content-poetry.mjs +0 -240
  219. package/engine/web-wikilinks.mjs +0 -594
  220. package/hugo-theme/layouts/404.html +0 -85
  221. package/hugo-theme/layouts/_default/baseof.html +0 -67
  222. package/hugo-theme/layouts/_default/list.html +0 -139
  223. package/hugo-theme/layouts/_default/single.html +0 -4
  224. package/hugo-theme/layouts/blog/list.html +0 -54
  225. package/hugo-theme/layouts/blog-post/single.html +0 -36
  226. package/hugo-theme/layouts/index.html +0 -75
  227. package/hugo-theme/layouts/partials/banner-url.html +0 -141
  228. package/hugo-theme/layouts/partials/cdn-url.html +0 -36
  229. package/hugo-theme/layouts/partials/dictionary-lists.html +0 -58
  230. package/hugo-theme/layouts/partials/draft-notice.html +0 -41
  231. package/hugo-theme/layouts/partials/draft-state.html +0 -26
  232. package/hugo-theme/layouts/partials/header.html +0 -35
  233. package/hugo-theme/layouts/partials/hero-banner.html +0 -85
  234. package/hugo-theme/layouts/partials/infobox/entry.html +0 -18
  235. package/hugo-theme/layouts/partials/infobox/items.html +0 -26
  236. package/hugo-theme/layouts/partials/infobox/section.html +0 -56
  237. package/hugo-theme/layouts/partials/infobox/value.html +0 -43
  238. package/hugo-theme/layouts/partials/infobox.html +0 -57
  239. package/hugo-theme/layouts/partials/page-body.html +0 -152
  240. package/hugo-theme/layouts/partials/related.html +0 -138
  241. package/hugo-theme/layouts/partials/search.html +0 -84
  242. package/hugo-theme/layouts/partials/site-url.html +0 -32
  243. package/hugo-theme/layouts/partials/tags.html +0 -30
  244. package/hugo-theme/layouts/partials/toc.html +0 -28
  245. package/hugo-theme/layouts/partials/warn-missing-banner.html +0 -3
  246. package/hugo-theme/layouts/shortcodes/cartouche.html +0 -64
  247. package/hugo-theme/layouts/shortcodes/exturl.html +0 -12
  248. package/hugo-theme/layouts/shortcodes/glyph.html +0 -43
  249. package/hugo-theme/layouts/shortcodes/pagelist.html +0 -63
  250. package/hugo-theme/theme.toml +0 -11
  251. package/types/engine/content-poetry.d.mts +0 -58
  252. package/types/engine/web-wikilinks.d.mts +0 -107
  253. /package/{hugo-theme → site-theme}/data/banner-kinds.yaml +0 -0
  254. /package/{hugo-theme → site-theme}/static/css/sohl-icons.css +0 -0
  255. /package/{hugo-theme → site-theme}/static/fonts/game-icons-kb.woff2 +0 -0
  256. /package/{hugo-theme → site-theme}/static/img/banners/takheperu-hieroglyphs.svg +0 -0
  257. /package/{hugo-theme → site-theme}/static/img/cartouches/takheperu.svg +0 -0
  258. /package/{hugo-theme → site-theme}/static/js/inline-spoilers.js +0 -0
@@ -0,0 +1,134 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * **A metre is declared once, as an entry of a `doc` note's `data.metres`**:
16
+ * its `name`, the `beats` of a line and the `kind` of count. This module holds
17
+ * the declaration and its field check, and imports nothing that reads the note
18
+ * vocabulary, which declares the field with them; reading the metres of an
19
+ * index and counting a verse against them is {@link module:engine/verse-metre}.
20
+ *
21
+ * @module
22
+ */
23
+
24
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
25
+
26
+ /** Whether a value is a plain map. */
27
+ const isMap = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
28
+
29
+ /** A list value, or an empty one. */
30
+ const listOf = (value) => (Array.isArray(value) ? value : []);
31
+
32
+ /** What a metre's `kind` may be. */
33
+ export const METRE_KINDS = Object.freeze(["accentual"]);
34
+
35
+ /** What a metre's `name` may be. */
36
+ export const METRE_NAME = /^[a-z0-9-]+$/;
37
+
38
+ /**
39
+ * One declared metre.
40
+ *
41
+ * @typedef {object} Metre
42
+ * @property {string} name - What `scan=` names it by.
43
+ * @property {number} beats - The beats of a line.
44
+ * @property {string} kind - How they are counted: `accentual`.
45
+ */
46
+
47
+ /** The notes declaring each metre name, read once per lint index. */
48
+ const declaredNames = new WeakMap();
49
+
50
+ /**
51
+ * Each metre name the index's notes declare, and the notes declaring it.
52
+ *
53
+ * @param {{notes?: Iterable<{file: string, fm?: Record<string, any>}>}} index - The lint index.
54
+ * @returns {Map<string, string[]>} Name → the files that declare it.
55
+ */
56
+ function namesIn(index) {
57
+ const known = declaredNames.get(index);
58
+ if (known) return known;
59
+ const names = new Map();
60
+ for (const note of index.notes ?? [])
61
+ for (const entry of listOf(note?.fm?.data?.metres)) {
62
+ if (!isMap(entry) || typeof entry.name !== "string") continue;
63
+ if (!names.has(entry.name)) names.set(entry.name, new Set());
64
+ names.get(entry.name).add(note.file);
65
+ }
66
+ const out = new Map([...names].map(([name, files]) => [name, [...files]]));
67
+ declaredNames.set(index, out);
68
+ return out;
69
+ }
70
+
71
+ /**
72
+ * Check a note's `data.metres`: each name's form, that no other entry declares
73
+ * it, and that a beat count is a whole number above zero. The data-key check
74
+ * owns every key, its kind and the closed `kind` list.
75
+ *
76
+ * @param {{file: string, raw?: string, fm?: Record<string, any>}} note - The note.
77
+ * @param {{index?: {notes?: Iterable<object>}}} [context] - The lint index.
78
+ * @returns {object[]} The findings, each at its key.
79
+ */
80
+ export function checkMetresField(note, { index } = {}) {
81
+ const list = note?.fm?.data?.metres;
82
+ if (!Array.isArray(list)) return [];
83
+ const raw = note.raw ?? "";
84
+ const finding = (keyPath, message) => ({
85
+ file: note.file,
86
+ ...positionOfFrontmatterPath(raw, ["data", "metres", ...keyPath]),
87
+ severity: "error",
88
+ message,
89
+ });
90
+ const findings = [];
91
+ const seen = new Set();
92
+ list.forEach((entry, at) => {
93
+ if (!isMap(entry)) return;
94
+ const { name, beats } = entry;
95
+ if (typeof name === "string") {
96
+ const label = `data.metres[${at}].name`;
97
+ if (!METRE_NAME.test(name))
98
+ findings.push(
99
+ finding(
100
+ [at, "name"],
101
+ `\`${label}\` is \`${name}\`; a metre's name is lowercase letters, digits and \`-\``,
102
+ ),
103
+ );
104
+ else if (seen.has(name))
105
+ findings.push(
106
+ finding(
107
+ [at, "name"],
108
+ `\`${label}\` is \`${name}\`, which this note declares twice; a metre's name is unique`,
109
+ ),
110
+ );
111
+ else {
112
+ const others = (index ? (namesIn(index).get(name) ?? []) : []).filter(
113
+ (file) => file !== note.file,
114
+ );
115
+ if (others.length)
116
+ findings.push(
117
+ finding(
118
+ [at, "name"],
119
+ `\`${label}\` is \`${name}\`, which ${others.join(", ")} also declares; a metre's name is unique`,
120
+ ),
121
+ );
122
+ }
123
+ seen.add(name);
124
+ }
125
+ if (beats !== undefined && Number.isFinite(Number(beats)) && !(Number(beats) >= 1))
126
+ findings.push(
127
+ finding(
128
+ [at, "beats"],
129
+ `\`data.metres[${at}].beats\` is \`${String(beats)}\`; a metre has one or more beats`,
130
+ ),
131
+ );
132
+ });
133
+ return findings;
134
+ }
package/engine/names.mjs CHANGED
@@ -33,6 +33,8 @@ const compare = (a, b) =>
33
33
  * @property {string} full - Declared full name.
34
34
  * @property {true} indexed - The identity belongs to the indexed catalog.
35
35
  * @property {boolean} [fullUse] - The label is a full-name form rather than an alias.
36
+ * @property {string} [type] - The note's type, absent on an ambiguous label identity.
37
+ * @property {string|null} [subType] - The note's subtype, or null.
36
38
  * @property {NameMatch[]} [candidates] - Indexed identities sharing the label.
37
39
  * @property {string} [destination] - Canonical Address used by a resolved link.
38
40
  */
@@ -40,10 +42,18 @@ const compare = (a, b) =>
40
42
  /**
41
43
  * The prepared name and declared-alias lookup.
42
44
  * @typedef {object} NameCatalog
43
- * @property {(label: string, preferred?: {key: string}) => NameMatch|undefined} recognize - Match a whole visible label.
45
+ * @property {(label: string, preferred?: {key: string}, kind?: NameKind) => NameMatch|undefined} recognize - Match a whole visible label, among notes of `kind` when it supplies a type.
44
46
  * @property {(target: string) => NameMatch|undefined} resolve - Resolve an Address target without its anchor to its indexed identity.
45
47
  */
46
48
 
49
+ /**
50
+ * What a typed `.name` span says the named thing is. A null type matches any note;
51
+ * a type matches only notes of that type, and a subtype only notes of that subtype.
52
+ * @typedef {object} NameKind
53
+ * @property {string|null} type - Supplied type, or null.
54
+ * @property {string|null} subType - Supplied subtype, or null.
55
+ */
56
+
47
57
  /**
48
58
  * One captured marked-name occurrence.
49
59
  * @typedef {object} MarkedName
@@ -150,10 +160,16 @@ export function createNameCatalog({ records, index }) {
150
160
  const names = new Map(),
151
161
  byFile = new Map(),
152
162
  entries = new Map();
153
- const addName = (key, full, aliases = []) => {
154
- const entry = entries.get(key) ?? { key, full, indexed: true };
163
+ const addName = (key, full, aliases, type, subType) => {
164
+ const entry = entries.get(key) ?? {
165
+ key,
166
+ full,
167
+ indexed: true,
168
+ type,
169
+ subType: typeof subType === "string" && subType ? subType : null,
170
+ };
155
171
  entries.set(key, entry);
156
- for (const text of [full, ...aliases]) {
172
+ for (const text of [full, ...(aliases ?? [])]) {
157
173
  if (typeof text !== "string" || !text.trim()) continue;
158
174
  for (const spelling of nameForms(text)) {
159
175
  if (!names.has(spelling)) names.set(spelling, new Map());
@@ -173,7 +189,13 @@ export function createNameCatalog({ records, index }) {
173
189
  const full = record.name?.full;
174
190
  if (!parts || parts.type === FOLDER_TYPE || typeof full !== "string") continue;
175
191
  const page = canonicalKey(parts.package, "note", parts.type, parts.shortcode);
176
- const entry = addName(index.resolve(page) ? page : canonical, full, record.name.aliases);
192
+ const entry = addName(
193
+ index.resolve(page) ? page : canonical,
194
+ full,
195
+ record.name.aliases,
196
+ parts.type,
197
+ record.subType,
198
+ );
177
199
  entries.set(canonical, entry);
178
200
  if (index.resolve(page)) entries.set(page, entry);
179
201
  if (record.file?.path) byFile.set(record.file.path, entry);
@@ -189,11 +211,16 @@ export function createNameCatalog({ records, index }) {
189
211
  continue;
190
212
  const page = canonicalKey(parts.package, "note", parts.type, parts.shortcode);
191
213
  const key = index.foreign.index.has(page) ? page : canonical;
192
- entries.set(canonical, addName(key, hit.name, hit.aliases));
214
+ entries.set(canonical, addName(key, hit.name, hit.aliases, parts.type, hit.subType));
193
215
  }
194
216
  return {
195
- recognize(label, preferred) {
196
- const candidates = [...(names.get(normalizeName(label))?.values() ?? [])];
217
+ recognize(label, preferred, kind) {
218
+ const candidates = [...(names.get(normalizeName(label))?.values() ?? [])].filter(
219
+ (entry) =>
220
+ !kind?.type ||
221
+ (entry.type === kind.type &&
222
+ (kind.subType == null || entry.subType === kind.subType)),
223
+ );
197
224
  const target =
198
225
  candidates.find((entry) => entry.key === preferred?.key) ??
199
226
  (candidates.length === 1 ? candidates[0] : undefined);
@@ -268,7 +295,7 @@ export function collectUnwrittenNames({ records, catalog }) {
268
295
  const kinds = new Map();
269
296
  for (const record of records.filter(isNoteRecord)) {
270
297
  for (const occurrence of record.names ?? []) {
271
- if (catalog.recognize(occurrence.name)) continue;
298
+ if (catalog.recognize(occurrence.name, undefined, occurrence)) continue;
272
299
  const type = occurrence.type,
273
300
  subType = occurrence.subType,
274
301
  kind = JSON.stringify([type, subType]);
@@ -0,0 +1,66 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * **A page's frontmatter, read and written.** Reading splits and parses a note
16
+ * exactly as the content index does ({@link splitFrontmatter} and `yaml`), so a
17
+ * value reads the same on the site and in the preview as in a compendium pack.
18
+ * Writing serializes with `js-yaml`, which quotes every string a YAML 1.1
19
+ * reader would take for a boolean, a number or a timestamp, so each
20
+ * value reads back as the string or number it was.
21
+ *
22
+ * @module
23
+ */
24
+
25
+ import YAML from "yaml";
26
+ import { dump } from "js-yaml";
27
+ import { splitFrontmatter } from "./diagnostics.mjs";
28
+
29
+ /**
30
+ * A page split at its frontmatter.
31
+ *
32
+ * @typedef {object} NoteMatter
33
+ * @property {unknown} data - The parsed frontmatter; an empty map where the
34
+ * page has none or the block is empty.
35
+ * @property {string} content - The body after the closing fence and its line
36
+ * break, or the whole text where there is no frontmatter.
37
+ * @property {string} matter - The YAML source between the fences; empty where
38
+ * there is no frontmatter.
39
+ */
40
+
41
+ /**
42
+ * Split a page's frontmatter from its body and parse it.
43
+ *
44
+ * @param {string} source - The complete Markdown source.
45
+ * @returns {NoteMatter} The parsed frontmatter, the body and the YAML source.
46
+ * @throws {Error} The `yaml` parse error, where the frontmatter is not YAML.
47
+ */
48
+ export function readMatter(source) {
49
+ const fence = splitFrontmatter(source);
50
+ if (!fence) return { data: {}, content: source, matter: "" };
51
+ return { data: YAML.parse(fence.yaml) ?? {}, content: fence.body, matter: fence.yaml };
52
+ }
53
+
54
+ /**
55
+ * A page with `data` as its frontmatter above `content`.
56
+ *
57
+ * @param {string} content - The body.
58
+ * @param {Record<string, unknown>} data - The frontmatter.
59
+ * @returns {string} The fenced YAML, then the body closed on a line break; the
60
+ * body alone where `data` serializes to an empty map.
61
+ */
62
+ export function writeMatter(content, data) {
63
+ const yaml = dump(data).trim();
64
+ const body = content.endsWith("\n") ? content : `${content}\n`;
65
+ return yaml === "{}" ? body : `---\n${yaml}\n---\n${body}`;
66
+ }
@@ -112,6 +112,12 @@ import { checkNoteEvents } from "./note-events.mjs";
112
112
  import { AFFILIATION_STANDINGS } from "../sohl/affiliation-standings.mjs";
113
113
  import { EVENT_ENTRY } from "./note-event-terms.mjs";
114
114
  import { CULTURE_FIELD, checkCultureSubType } from "./culture-choice.mjs";
115
+ import {
116
+ POST_AUTHOR_FIELD,
117
+ POST_DATE_FIELD,
118
+ POST_SUBTYPE,
119
+ checkPostFields,
120
+ } from "./post-fields.mjs";
115
121
  import { checkCultureParents } from "./culture-parents.mjs";
116
122
  import {
117
123
  checkAppearanceProfile,
@@ -154,6 +160,10 @@ import { MARKET_CLASSES, checkMarket } from "./market-class.mjs";
154
160
  import { positionOfFrontmatterPath } from "./diagnostics.mjs";
155
161
  import { checkSettlementTables } from "./settlement-tables.mjs";
156
162
  import { checkMapField } from "./text-maps.mjs";
163
+ import { checkConceptList, checkCoreFlag } from "./core-vocabulary.mjs";
164
+ import { METRE_KINDS, checkMetresField } from "./metre-declaration.mjs";
165
+ import { checkLanguageEmphasis, checkScriptField } from "./script-emphasis.mjs";
166
+ import { EMPHASIS_CONVENTIONS, LANGUAGE_EMPHASES } from "./script-emphasis-terms.mjs";
157
167
 
158
168
  /**
159
169
  * One `data:` key a note type may carry.
@@ -655,6 +665,67 @@ const ABBREVIATION_FIELD = Object.freeze({
655
665
  "note only. See [a language's words](format-details.md#a-languages-words).",
656
666
  });
657
667
 
668
+ /**
669
+ * A language's `data.core`: whether it must cover the core vocabulary.
670
+ * {@link checkCoreFlag} checks where it sits.
671
+ *
672
+ * @type {DataFieldSpec}
673
+ */
674
+ const CORE_FIELD = Object.freeze({
675
+ name: "core",
676
+ kind: "boolean",
677
+ shape: "`true` or `false`",
678
+ check: checkCoreFlag,
679
+ describe:
680
+ "Whether the language must cover the core vocabulary: `true` lists it in the " +
681
+ "`package-build core` report. A language skill note only. See " +
682
+ "[the core vocabulary](format-details.md#the-core-vocabulary).",
683
+ });
684
+
685
+ /**
686
+ * A `doc` note's `data.concepts`: the core vocabulary's concept list.
687
+ * {@link checkConceptList} checks what an entry means.
688
+ *
689
+ * @type {DataFieldSpec}
690
+ */
691
+ const CONCEPTS_FIELD = Object.freeze({
692
+ name: "concepts",
693
+ kind: "list",
694
+ shape: "list of `{ id, gloss, group }`",
695
+ check: checkConceptList,
696
+ entries: Object.freeze({
697
+ kind: "map",
698
+ shape: "a map — `{ id, gloss, group }`",
699
+ fields: Object.freeze([
700
+ Object.freeze({
701
+ name: "id",
702
+ kind: "string",
703
+ required: true,
704
+ shape: "lowercase letters, digits and `-`",
705
+ describe:
706
+ "The concept's id, which a word's `concept` names. Unique in the package.",
707
+ }),
708
+ Object.freeze({
709
+ name: "gloss",
710
+ kind: "string",
711
+ required: true,
712
+ shape: "a short gloss, such as `a smell; a scent`",
713
+ describe: "What the concept means, in brief.",
714
+ }),
715
+ Object.freeze({
716
+ name: "group",
717
+ kind: "string",
718
+ required: true,
719
+ shape: "a group name, such as `senses`",
720
+ describe: "The group the report lists the concept under.",
721
+ }),
722
+ ]),
723
+ }),
724
+ describe:
725
+ "The concepts of a core vocabulary, in the order the report lists them. See " +
726
+ "[the core vocabulary](format-details.md#the-core-vocabulary).",
727
+ });
728
+
658
729
  /**
659
730
  * A culture's or an affiliation's `data.glossary`: the terms that belong to
660
731
  * its people or members rather than to a language's grammar.
@@ -2096,8 +2167,64 @@ export const NOTE_VOCABULARY = Object.freeze({
2096
2167
  NAMES_FIELD,
2097
2168
  FAMILY_FIELD,
2098
2169
  ABBREVIATION_FIELD,
2170
+ CORE_FIELD,
2099
2171
  ...WORD_DATA_FIELDS,
2100
2172
  ...LANGUAGE_DATA_FIELDS,
2173
+ {
2174
+ name: "emphasis",
2175
+ kind: "string",
2176
+ shape: "`words`, `rubric` or `overline`",
2177
+ allowed: LANGUAGE_EMPHASES,
2178
+ check: checkLanguageEmphasis,
2179
+ describe:
2180
+ "How the language marks a stressed word, over its script's `emphasis`: " +
2181
+ "`rubric` or `overline` sets `*…*` and `**…**` so in a run of the language, " +
2182
+ "and `words` marks it with a word, so either is a finding. Only a skill of " +
2183
+ "`subType: language` states it.",
2184
+ },
2185
+ {
2186
+ name: "script",
2187
+ kind: "map",
2188
+ shape: "a map — `{ fonts, emphasis, strongEmphasis? }`",
2189
+ check: checkScriptField,
2190
+ fields: Object.freeze([
2191
+ {
2192
+ name: "fonts",
2193
+ kind: "list",
2194
+ shape: "list of installed faces' slugs",
2195
+ required: true,
2196
+ entries: Object.freeze({
2197
+ kind: "string",
2198
+ shape: "an installed face's slug",
2199
+ }),
2200
+ describe:
2201
+ "The faces the script is written in, by the slug `font=` names them by. " +
2202
+ "A face belongs to one script.",
2203
+ },
2204
+ {
2205
+ name: "emphasis",
2206
+ kind: "string",
2207
+ shape: "`rubric` or `overline`",
2208
+ allowed: EMPHASIS_CONVENTIONS,
2209
+ required: true,
2210
+ describe:
2211
+ "How the script marks a stressed word: `rubric` sets it in red, " +
2212
+ "`overline` draws a line above it. It sets `*…*` and `**…**` inside a " +
2213
+ "run in one of its faces, unless the run's language states its own " +
2214
+ "`emphasis`.",
2215
+ },
2216
+ {
2217
+ name: "strongEmphasis",
2218
+ kind: "string",
2219
+ shape: "`rubric` or `overline`",
2220
+ allowed: EMPHASIS_CONVENTIONS,
2221
+ describe: "The convention `**…**` takes instead of `emphasis`'s.",
2222
+ },
2223
+ ]),
2224
+ describe:
2225
+ "A script note's faces and how it marks emphasis. Only a skill of " +
2226
+ "`subType: script` states it.",
2227
+ },
2101
2228
  ]),
2102
2229
  }),
2103
2230
 
@@ -2176,14 +2303,20 @@ export const NOTE_VOCABULARY = Object.freeze({
2176
2303
  "howto",
2177
2304
  "concept",
2178
2305
  "settingguide",
2306
+ POST_SUBTYPE,
2179
2307
  ]),
2180
- // A setting guide introduces one culture and names it; no other genre
2181
- // states one.
2308
+ // A setting guide introduces one culture and names it, and a post
2309
+ // states the day it was published and who wrote it; no other genre
2310
+ // states either.
2182
2311
  check: (note, context) => [
2183
2312
  ...checkCitedPopulations(note, context),
2184
2313
  ...checkCultureSubType(note, context),
2314
+ ...checkPostFields(note),
2185
2315
  ],
2186
2316
  data: Object.freeze([
2317
+ CONCEPTS_FIELD,
2318
+ POST_DATE_FIELD,
2319
+ POST_AUTHOR_FIELD,
2187
2320
  {
2188
2321
  ...CULTURE_FIELD,
2189
2322
  describe:
@@ -2193,7 +2326,7 @@ export const NOTE_VOCABULARY = Object.freeze({
2193
2326
  {
2194
2327
  name: "map",
2195
2328
  kind: "map",
2196
- shape: "a map — `{ name, vowels?, fonts, rows }`",
2329
+ shape: "a map — `{ name, logographic?, vowels?, fonts, rows }`",
2197
2330
  check: checkMapField,
2198
2331
  fields: Object.freeze([
2199
2332
  {
@@ -2204,6 +2337,15 @@ export const NOTE_VOCABULARY = Object.freeze({
2204
2337
  "What `map=` names the map by: lowercase letters, digits and `-`, " +
2205
2338
  "unique in the package.",
2206
2339
  },
2340
+ {
2341
+ name: "logographic",
2342
+ kind: "boolean",
2343
+ shape: "`true`",
2344
+ describe:
2345
+ "The script writes meanings: a mapped run's words are written by " +
2346
+ "the language's lexicon alone, and the rows write only the text " +
2347
+ "between them.",
2348
+ },
2207
2349
  {
2208
2350
  name: "vowels",
2209
2351
  ...TEXT,
@@ -2261,6 +2403,48 @@ export const NOTE_VOCABULARY = Object.freeze({
2261
2403
  "before it is set. See " +
2262
2404
  "[mapping notes](format-details.md#mapping-notes).",
2263
2405
  },
2406
+ {
2407
+ name: "metres",
2408
+ kind: "list",
2409
+ shape: "list of `{ name, beats, kind }`",
2410
+ check: checkMetresField,
2411
+ entries: Object.freeze({
2412
+ kind: "map",
2413
+ shape: "a map — `{ name, beats, kind }`",
2414
+ fields: Object.freeze([
2415
+ {
2416
+ name: "name",
2417
+ ...TEXT,
2418
+ required: true,
2419
+ describe:
2420
+ "What a verse fence's `scan=` names the metre by: lowercase " +
2421
+ "letters, digits and `-`, unique in the package.",
2422
+ },
2423
+ {
2424
+ name: "beats",
2425
+ kind: "integer",
2426
+ shape: "a whole number above zero",
2427
+ required: true,
2428
+ describe: "The beats of one line.",
2429
+ },
2430
+ {
2431
+ name: "kind",
2432
+ kind: "string",
2433
+ allowed: METRE_KINDS,
2434
+ shape: `one of ${METRE_KINDS.join(", ")}`,
2435
+ required: true,
2436
+ describe:
2437
+ "How the beats are counted: `accentual`, one beat for each " +
2438
+ "stressed syllable the language's stress rule and the beat " +
2439
+ "convention give.",
2440
+ },
2441
+ ]),
2442
+ }),
2443
+ describe:
2444
+ "The metres a verse fence's `scan=` names. A fence that states one is " +
2445
+ "warned at each line that cannot have its beats. See " +
2446
+ "[metres](../authoring/links-and-markup.md#metre).",
2447
+ },
2264
2448
  ]),
2265
2449
  }),
2266
2450