@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,32 @@
1
+ /* SPDX-License-Identifier: GPL-3.0-or-later */
2
+
3
+ import { isAddressTuple, parseAddress, renderAddress } from "./address.mjs";
4
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
5
+
6
+ /**
7
+ * Require a being's primary culture to address a culture lore note.
8
+ * @param {object} note
9
+ * @param {{index?: object}} [options]
10
+ */
11
+ export function checkCultureChoice(note, { index } = {}) {
12
+ const value = note.fm?.data?.culture;
13
+ if (value === undefined || value === null || !index?.addressHit) return [];
14
+ const tuple = parseAddress(value, {
15
+ package: index.contentPackage,
16
+ system: "note",
17
+ type: "lore",
18
+ types: index.types,
19
+ packages: index.packages,
20
+ });
21
+ if (tuple.reason || tuple.type !== "lore") return [];
22
+ const target = index.addressHit(renderAddress(tuple));
23
+ if (!target || (target.fm ?? target).subType === "culture") return [];
24
+ return [
25
+ {
26
+ file: note.file,
27
+ ...positionOfFrontmatterPath(note.raw ?? "", ["data", "culture"]),
28
+ severity: "error",
29
+ message: `data.culture ${isAddressTuple(value) ? renderAddress(value) : value} names lore whose subType is not culture`,
30
+ },
31
+ ];
32
+ }
@@ -0,0 +1,47 @@
1
+ /* SPDX-License-Identifier: GPL-3.0-or-later */
2
+
3
+ import { formatCanonicalDate, parseCanonicalDate } from "./calendars.mjs";
4
+ import { addressedCalendarDate } from "./calendar-human.mjs";
5
+ import { calendarEras, eraCovering, formatNoteDate, parseNoteDate } from "./note-dates.mjs";
6
+
7
+ /** Convert a day in an addressed calendar to its canonical spelling. */
8
+ export function dateFromCalendar(reference, value, context) {
9
+ const eras = calendarEras(reference, context);
10
+ const text = String(value ?? "").trim();
11
+ const approximate = text.startsWith("~");
12
+ const named = addressedCalendarDate(approximate ? text.slice(1).trim() : text, eras);
13
+ if (!named) throw new RangeError("datefrom needs a named calendar date precise to the day");
14
+ const authored = `${approximate ? "~" : ""}${named}`;
15
+ const parsed = parseNoteDate(authored, { ...context, allowUnknown: false });
16
+ const error = parsed.findings.find((finding) => finding.severity === "error");
17
+ if (error) throw new RangeError(error.message);
18
+ const date = parsed.date;
19
+ if (!date || date.precision !== "day" || !Number.isSafeInteger(date.canonicalDay))
20
+ throw new RangeError("datefrom needs a calendar date precise to the day");
21
+ if (!eras.includes(context.eras.get(date.qualifier)))
22
+ throw new RangeError(`date belongs to a different calendar than ${reference}`);
23
+ if (eraCovering(date, eras, context.daysPerYear) !== context.eras.get(date.qualifier))
24
+ throw new RangeError(`date falls outside calendar ${reference}`);
25
+ return `${date.approximate ? "~" : ""}${formatCanonicalDate(
26
+ { year: date.canonicalYear, day: date.canonicalDay, seconds: date.seconds },
27
+ context.daysPerYear,
28
+ )}`;
29
+ }
30
+
31
+ /** Convert a canonical day to the date in the covering era of a calendar. */
32
+ export function dateToCalendar(reference, value, context) {
33
+ const text = String(value ?? "");
34
+ const canonical = parseCanonicalDate(
35
+ text.startsWith("~") ? text.slice(1) : text,
36
+ context?.daysPerYear,
37
+ );
38
+ if (!canonical)
39
+ throw new RangeError("dateto needs <year>.<day>[:HHMMSS] within the world's year");
40
+ const parsed = parseNoteDate(value, context).date;
41
+ const era = eraCovering(parsed, calendarEras(reference, context), context.daysPerYear);
42
+ if (!era) throw new RangeError(`canonical date falls outside calendar ${reference}`);
43
+ const printed = formatNoteDate(parsed, era, context.daysPerYear);
44
+ if (!printed || printed.day === null)
45
+ throw new RangeError(`canonical date cannot be expressed in calendar ${reference}`);
46
+ return printed.text;
47
+ }
@@ -0,0 +1,87 @@
1
+ /*
2
+ * SPDX-License-Identifier: GPL-3.0-or-later
3
+ */
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { globSync } from "glob";
8
+ import ts from "typescript";
9
+
10
+ /** All declaration entry points advertised by a package's exports map. */
11
+ export function exportedDeclarationFiles(root) {
12
+ const manifest = path.join(root, "package.json");
13
+ const pkg = JSON.parse(fs.readFileSync(manifest, "utf8"));
14
+ const files = new Set();
15
+ const findings = [];
16
+ for (const entry of Object.values(pkg.exports ?? {})) {
17
+ const declared = typeof entry === "object" && entry !== null ? entry.types : null;
18
+ if (typeof declared !== "string") continue;
19
+ const pattern = path.resolve(root, declared);
20
+ const matches = pattern.includes("*") ? globSync(pattern) : [pattern];
21
+ if (!matches.length || matches.some((file) => !fs.existsSync(file))) {
22
+ findings.push({
23
+ file: manifest,
24
+ severity: "error",
25
+ message: `exported declaration ${declared} does not exist`,
26
+ });
27
+ }
28
+ for (const file of matches) {
29
+ if (fs.existsSync(file)) files.add(file);
30
+ }
31
+ }
32
+ return { files: [...files].sort(), findings };
33
+ }
34
+
35
+ /**
36
+ * Type-check project declarations with library checking enabled.
37
+ * @param {string} project - TypeScript project file.
38
+ * @param {{exports?: boolean}} [options]
39
+ * @returns {Array<{file: string, line?: number, column?: number, severity: "error", message: string}>}
40
+ */
41
+ export function checkDeclarations(project, options = {}) {
42
+ const requestedProject = path.resolve(project);
43
+ const configFile =
44
+ fs.existsSync(requestedProject) ? fs.realpathSync(requestedProject) : requestedProject;
45
+ const root = path.dirname(configFile);
46
+ const findings = [];
47
+ const report = (diagnostic) => {
48
+ if (diagnostic.file?.fileName.split(path.sep).includes("node_modules")) return;
49
+ const position =
50
+ diagnostic.file && diagnostic.start !== undefined ?
51
+ diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start)
52
+ : null;
53
+ findings.push({
54
+ file: diagnostic.file?.fileName ?? configFile,
55
+ ...(position ? { line: position.line + 1, column: position.character + 1 } : {}),
56
+ severity: "error",
57
+ message: `TS${diagnostic.code}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, " ")}`,
58
+ });
59
+ };
60
+ const source = ts.readConfigFile(configFile, ts.sys.readFile);
61
+ if (source.error) {
62
+ report(source.error);
63
+ return findings;
64
+ }
65
+ const parsed = ts.parseJsonConfigFileContent(
66
+ source.config,
67
+ ts.sys,
68
+ root,
69
+ {
70
+ skipLibCheck: false,
71
+ noEmit: true,
72
+ },
73
+ configFile,
74
+ );
75
+ for (const diagnostic of parsed.errors) {
76
+ if (!(options.exports && diagnostic.code === 18003)) report(diagnostic);
77
+ }
78
+ const roots = new Set(parsed.fileNames);
79
+ if (options.exports) {
80
+ const exported = exportedDeclarationFiles(root);
81
+ findings.push(...exported.findings);
82
+ for (const file of exported.files) roots.add(file);
83
+ }
84
+ const program = ts.createProgram([...roots], parsed.options);
85
+ for (const diagnostic of ts.getPreEmitDiagnostics(program)) report(diagnostic);
86
+ return findings;
87
+ }
@@ -0,0 +1,140 @@
1
+ /*
2
+ * SPDX-License-Identifier: GPL-3.0-or-later
3
+ */
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+
8
+ /** @param {string} root */
9
+ function* markdownFiles(root) {
10
+ for (const entry of fs
11
+ .readdirSync(root, { withFileTypes: true })
12
+ .sort((a, b) => a.name.localeCompare(b.name))) {
13
+ const file = path.join(root, entry.name);
14
+ if (entry.isDirectory()) yield* markdownFiles(file);
15
+ else if (entry.isFile() && file.endsWith(".md")) yield file;
16
+ }
17
+ }
18
+
19
+ /** Preserve offsets while excluding literal examples from link scanning. */
20
+ export function maskMarkdownCode(source, spans = true) {
21
+ const pattern =
22
+ spans ?
23
+ /```[\s\S]*?```|~~~[\s\S]*?~~~|``[^`]*``|`[^`]*`/g
24
+ : /```[\s\S]*?```|~~~[\s\S]*?~~~/g;
25
+ return source.replace(pattern, (match) => match.replace(/[^\n]/g, " "));
26
+ }
27
+
28
+ /** GitHub heading slug, retaining separate spaces after punctuation removal. */
29
+ export function docHeadingSlug(value) {
30
+ return value
31
+ .trim()
32
+ .toLowerCase()
33
+ .replace(/[^\w\s-]/g, "")
34
+ .trim()
35
+ .replace(/\s/g, "-");
36
+ }
37
+
38
+ /** @param {string} source */
39
+ export function docAnchors(source) {
40
+ const body = maskMarkdownCode(source, false);
41
+ const anchors = new Set();
42
+ for (const [, heading] of body.matchAll(/^#{1,6}\s+(.+?)\s*#*$/gm)) {
43
+ const explicit = heading.match(/\{#([^}]+)\}\s*$/);
44
+ anchors.add(
45
+ explicit ?
46
+ explicit[1].trim()
47
+ : docHeadingSlug(heading.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")),
48
+ );
49
+ }
50
+ for (const [, anchor] of body.matchAll(/<a\s[^>]*(?:id|name)="([^"]+)"/g)) {
51
+ anchors.add(anchor);
52
+ }
53
+ return anchors;
54
+ }
55
+
56
+ /**
57
+ * Check relative Markdown links beneath a documentation root.
58
+ * @param {string} root
59
+ * @returns {Array<{file: string, line: number, column: number, severity: "error", message: string}>}
60
+ */
61
+ export function checkDocLinks(root) {
62
+ const findings = [];
63
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
64
+ return [{ file: root, severity: "error", message: "documentation root does not exist" }];
65
+ }
66
+ const anchors = new Map();
67
+ for (const file of markdownFiles(root)) {
68
+ const body = maskMarkdownCode(fs.readFileSync(file, "utf8"));
69
+ for (const match of body.matchAll(/\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) {
70
+ const link = match[1];
71
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(link) || link.startsWith("/")) continue;
72
+ const hash = link.indexOf("#");
73
+ const relative = hash < 0 ? link : link.slice(0, hash);
74
+ const anchor = hash < 0 ? "" : link.slice(hash + 1);
75
+ const offset = match.index + 2;
76
+ const location = {
77
+ file,
78
+ line: body.slice(0, match.index).split("\n").length,
79
+ column: offset - body.lastIndexOf("\n", offset),
80
+ severity: /** @type {const} */ ("error"),
81
+ };
82
+ let target;
83
+ try {
84
+ target = relative ? path.resolve(path.dirname(file), decodeURI(relative)) : file;
85
+ } catch {
86
+ findings.push({ ...location, message: `link ${link} has invalid URI encoding` });
87
+ continue;
88
+ }
89
+ if (!fs.existsSync(target)) {
90
+ findings.push({
91
+ ...location,
92
+ message: `link ${link} resolves to ${path.relative(process.cwd(), target)}, which does not exist`,
93
+ });
94
+ } else if (anchor && target.endsWith(".md")) {
95
+ if (!anchors.has(target))
96
+ anchors.set(target, docAnchors(fs.readFileSync(target, "utf8")));
97
+ if (!anchors.get(target).has(anchor)) {
98
+ findings.push({
99
+ ...location,
100
+ message: `link ${link} points at an anchor nobody declares`,
101
+ });
102
+ }
103
+ }
104
+ }
105
+ }
106
+ return findings;
107
+ }
108
+
109
+ /**
110
+ * Check each direct Markdown page in each section against the root README.
111
+ * @param {string} root
112
+ * @returns {Array<{file: string, severity: "error", message: string}>}
113
+ */
114
+ export function checkDocIndex(root) {
115
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
116
+ return [{ file: root, severity: "error", message: "documentation root does not exist" }];
117
+ }
118
+ const index = path.join(root, "README.md");
119
+ if (!fs.existsSync(index))
120
+ return [{ file: index, severity: "error", message: "documentation index does not exist" }];
121
+ const readme = fs.readFileSync(index, "utf8");
122
+ const findings = [];
123
+ for (const section of fs
124
+ .readdirSync(root, { withFileTypes: true })
125
+ .filter((entry) => entry.isDirectory())) {
126
+ const dir = path.join(root, section.name);
127
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
128
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
129
+ const relative = `${section.name}/${entry.name}`;
130
+ if (!readme.includes(relative)) {
131
+ findings.push({
132
+ file: path.join(dir, entry.name),
133
+ severity: /** @type {const} */ ("error"),
134
+ message: `not linked from ${index}`,
135
+ });
136
+ }
137
+ }
138
+ }
139
+ return findings;
140
+ }
@@ -0,0 +1,31 @@
1
+ /* SPDX-License-Identifier: GPL-3.0-or-later */
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+
6
+ import { parseMarkdownFile } from "./helpers.mjs";
7
+
8
+ const LOCAL_MARKDOWN_LINK = /\[([^\]\n]+)\]\(([^\s)]+\.md(?:#[^\s)]*)?)\)/g;
9
+
10
+ /** Keep repository-relative documentation links usable on addressed pages. */
11
+ export function documentationLinksPass({ config, book = false }) {
12
+ const root = config.paths.content;
13
+ const cache = new Map();
14
+ return {
15
+ beforeLinks(text, page) {
16
+ return text.replace(LOCAL_MARKDOWN_LINK, (whole, label, target) => {
17
+ const [fileName, fragment] = target.split("#", 2);
18
+ const file = path.resolve(path.dirname(page.file), fileName);
19
+ if (!file.startsWith(`${root}${path.sep}`) || !fs.existsSync(file)) return whole;
20
+ let address = cache.get(file);
21
+ if (!address) {
22
+ const { frontmatter } = parseMarkdownFile(file);
23
+ if (!frontmatter?.type || !frontmatter?.shortcode) return whole;
24
+ address = `/${config.contentPackage}/${frontmatter.type}-${frontmatter.shortcode}/`;
25
+ cache.set(file, address);
26
+ }
27
+ return `[${label}](${address}${fragment && !book ? `#${fragment}` : ""})`;
28
+ });
29
+ },
30
+ };
31
+ }
@@ -147,7 +147,7 @@ function keyDocument(doc, collection, parentIds, sceneId) {
147
147
  *
148
148
  * @param {object} fm - The map note's frontmatter.
149
149
  * @param {string} markdown - Its converted Markdown body.
150
- * @param {object} [opts] - Pack metadata, derived JournalEntry ID, and asset resolver.
150
+ * @param {{journalEntryId?: string, stats?: object, resolveAddress?: Function}} [opts] - Pack metadata, derived JournalEntry ID, and asset resolver.
151
151
  * @returns {object} The keyed Scene document.
152
152
  */
153
153
  export function buildExportedScene(fm, markdown, { journalEntryId, stats, resolveAddress } = {}) {
@@ -0,0 +1,74 @@
1
+ /* SPDX-License-Identifier: GPL-3.0-or-later */
2
+
3
+ /**
4
+ * Read the shared braced attribute grammar used by body extensions.
5
+ * Callers validate which names and classes their extension understands.
6
+ * @param {string} source - Text inside the braces.
7
+ * @returns {{id: string, classes: string[], values: Record<string, string>, problems: string[]}}
8
+ */
9
+ export function parseExtensionAttributes(source) {
10
+ const text = String(source ?? "");
11
+ const result = { id: "", classes: [], values: {}, problems: [] };
12
+ let at = 0;
13
+ while (at < text.length) {
14
+ while (/\s/.test(text[at] ?? "")) at++;
15
+ if (at >= text.length) break;
16
+ const start = at;
17
+ if (text[at] === "#" || text[at] === ".") {
18
+ const kind = text[at++];
19
+ while (at < text.length && !/\s/.test(text[at])) at++;
20
+ const name = text.slice(start + 1, at);
21
+ if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(name)) {
22
+ result.problems.push(
23
+ `invalid ${kind === "#" ? "id" : "class"} attribute ${text.slice(start, at)}`,
24
+ );
25
+ } else if (kind === "#") {
26
+ if (result.id) result.problems.push("an attribute list has more than one id");
27
+ else result.id = name;
28
+ } else if (result.classes.includes(name)) {
29
+ result.problems.push(`class .${name} is written twice`);
30
+ } else result.classes.push(name);
31
+ continue;
32
+ }
33
+ const key = /^[A-Za-z][A-Za-z0-9_-]*/.exec(text.slice(at))?.[0];
34
+ if (!key || text[at + key.length] !== "=") {
35
+ while (at < text.length && !/\s/.test(text[at])) at++;
36
+ result.problems.push(`${text.slice(start, at)} is not a key=value attribute`);
37
+ continue;
38
+ }
39
+ at += key.length + 1;
40
+ let value = "";
41
+ if (text[at] === '"') {
42
+ at++;
43
+ let closed = false;
44
+ while (at < text.length) {
45
+ if (text[at] === '"') {
46
+ at++;
47
+ closed = true;
48
+ break;
49
+ }
50
+ if (text[at] === "\\" && ["\\", '"'].includes(text[at + 1])) at++;
51
+ value += text[at++];
52
+ }
53
+ if (!closed) result.problems.push(`${key} has an unclosed quoted value`);
54
+ if (at < text.length && !/\s/.test(text[at])) {
55
+ while (at < text.length && !/\s/.test(text[at])) at++;
56
+ result.problems.push(`${key} has text after its quoted value`);
57
+ }
58
+ } else {
59
+ while (at < text.length && !/\s/.test(text[at])) value += text[at++];
60
+ if (/[{},"\[\]]/.test(value)) result.problems.push(`${key} needs a quoted value`);
61
+ }
62
+ if (!value) result.problems.push(`${key} needs a value`);
63
+ if (Object.hasOwn(result.values, key)) result.problems.push(`${key} is written twice`);
64
+ else result.values[key] = value;
65
+ }
66
+ return result;
67
+ }
68
+
69
+ /** A Boolean attribute is spelled exactly `true` or `false`. */
70
+ export function booleanAttribute(value, key) {
71
+ if (value === "true") return true;
72
+ if (value === "false") return false;
73
+ throw new TypeError(`${key} needs the literal value true or false`);
74
+ }
@@ -248,7 +248,7 @@ function workedExample(type, fields) {
248
248
  export function renderItemFieldReference({
249
249
  title = "Item Note Frontmatter",
250
250
  preamble = [],
251
- generatedBy = "the content-build field reference generator",
251
+ generatedBy = "the package-build field reference generator",
252
252
  config = loadPackConfig(),
253
253
  } = {}) {
254
254
  const declared = config.itemFields ?? {};
@@ -161,6 +161,9 @@ export { legacyKeyOf, retiredTopLevelKey, setPath };
161
161
  * which is a Shortcode at both ends and resolves nothing.
162
162
  * @property {any|((fm: object) => any)} [value] - For a field with no `name`:
163
163
  * the constant, or a function deriving it from the frontmatter.
164
+ * @property {(fm: object, out: object) => boolean} [when] - Emit this field
165
+ * only when the note and fields already built meet the condition. The path
166
+ * remains declared for schema checks and authoring validation.
164
167
  * @property {boolean} [omitWhenAbsent] - **The key is left out entirely when
165
168
  * the note does not carry the field**, rather than written from a
166
169
  * declared default.
@@ -460,6 +463,7 @@ export function buildFromFields(fields, { block = "sohl", onLegacyKey, onRetired
460
463
  return function buildDeclaredSystem(fm) {
461
464
  const out = {};
462
465
  for (const field of fields) {
466
+ if (field.when && !field.when(fm, out)) continue;
463
467
  // A runtime-only field is not this builder's to write. It is
464
468
  // declared so that the path is *claimed* — so the verbatim
465
469
  // passthrough leaves it alone and the refusal has a name — not so
@@ -39,7 +39,7 @@
39
39
  * item came from.
40
40
  *
41
41
  * **The network is never touched by a compile.** Fetching is its own command
42
- * (`content-build deps fetch`), and a compile whose cache is cold fails saying
42
+ * (`package-build deps fetch`), and a compile whose cache is cold fails saying
43
43
  * so. A build that silently downloads is not reproducible, fails strangely
44
44
  * offline, and hides a version change behind a passing run.
45
45
  *
@@ -223,7 +223,7 @@ function cachedItemPacks(dir) {
223
223
  * ways of proceeding without it are both wrong: reading every pack resolves an
224
224
  * `hm3` reference against `sohl` documents — the silent-wrong-output failure
225
225
  * this scoping exists to remove — and reading none fails a build that was
226
- * working. Treating it as incomplete makes `content-build deps fetch` refill
226
+ * working. Treating it as incomplete makes `package-build deps fetch` refill
227
227
  * it, which is a command the cold-cache path already tells anyone to run.
228
228
  *
229
229
  * @param {string} dir - The dependency's cache directory.
@@ -481,7 +481,7 @@ export async function fetchCatalogFromPath(config, rel, source) {
481
481
  throw new Error(`${rel.id}: nothing at ${source}`);
482
482
  }
483
483
 
484
- const staging = fs.mkdtempSync(path.join(os.tmpdir(), `content-build-${rel.id}-`));
484
+ const staging = fs.mkdtempSync(path.join(os.tmpdir(), `package-build-${rel.id}-`));
485
485
  try {
486
486
  let root;
487
487
  if (fs.statSync(source).isDirectory()) {
@@ -608,7 +608,7 @@ export async function fetchMetadataFromPath(config, rel, source) {
608
608
  throw new Error(`${rel.id}: nothing at ${source}`);
609
609
  }
610
610
 
611
- const staging = fs.mkdtempSync(path.join(os.tmpdir(), `content-build-meta-${rel.id}-`));
611
+ const staging = fs.mkdtempSync(path.join(os.tmpdir(), `package-build-meta-${rel.id}-`));
612
612
  try {
613
613
  let root = source;
614
614
  if (!fs.statSync(source).isDirectory()) {
@@ -752,7 +752,7 @@ export function foreignItemCatalogDirs(config, system = null) {
752
752
  if (!cached.length) {
753
753
  throw new Error(
754
754
  `${rel.id} declares \`itemCatalog: true\` but has not been ` +
755
- `fetched. Run \`content-build deps fetch\` first.`,
755
+ `fetched. Run \`package-build deps fetch\` first.`,
756
756
  );
757
757
  }
758
758
  // Newest wins if several versions are cached; a fetch always writes
@@ -0,0 +1,15 @@
1
+ /*
2
+ * SPDX-License-Identifier: GPL-3.0-or-later
3
+ */
4
+
5
+ /**
6
+ * Format generated text using the destination file's Prettier configuration.
7
+ * @param {string} text
8
+ * @param {string} filepath
9
+ * @returns {Promise<string>}
10
+ */
11
+ export async function formatGenerated(text, filepath) {
12
+ const prettier = await import("prettier");
13
+ const config = await prettier.resolveConfig(filepath);
14
+ return prettier.format(text, { ...config, filepath });
15
+ }
@@ -59,6 +59,7 @@ import fs from "node:fs";
59
59
  import path from "node:path";
60
60
 
61
61
  import { noteAddressContext } from "./note-addresses.mjs";
62
+ import { isGmNote } from "./note-vocabulary.mjs";
62
63
  import { canonicalKey, packageAddress } from "./content-address.mjs";
63
64
  import { ownDocumentSystem } from "./address.mjs";
64
65
  import { NOTE_SYSTEM } from "./systems.mjs";
@@ -211,6 +212,7 @@ export function entriesForNote(fm, name, address, body, ctx) {
211
212
  routeFm ?
212
213
  packRouter.resolveOrNull(routeFm, docType, system)
213
214
  : packRouter.defaultOf("JournalEntry");
215
+ if (isGmNote(fm) && !packRouter.privateOf?.(pack)) return undefined;
214
216
 
215
217
  // **A system block is what makes a game document**, so a note carrying
216
218
  // none compiles into no Actor and no Item — and an address naming one
@@ -255,7 +257,7 @@ export function entriesForNote(fm, name, address, body, ctx) {
255
257
  // however many system blocks the note carries.
256
258
  const docKey = canonicalKey(contentPackage, NOTE_SYSTEM, fm.type, fm.shortcode);
257
259
  const docEntryId = fm.id ? itemDocEntryId(fm.id) : undefined;
258
- const docUuid = uuidFor("doc", docEntryId);
260
+ const docUuid = uuidFor("doc", docEntryId, isGmNote(fm) ? fm : undefined);
259
261
  return [
260
262
  {
261
263
  key,
@@ -1689,7 +1689,11 @@ export function lintNote(
1689
1689
  `\`${field.name}\``
1690
1690
  : `\`sohl.${field.name}\``;
1691
1691
 
1692
- if (field.required && absent) {
1692
+ if (
1693
+ field.required &&
1694
+ absent &&
1695
+ (!field.when || field.when(fm, { subType: fm.subType ?? fm.sohl?.subType }))
1696
+ ) {
1693
1697
  findings.push({
1694
1698
  file: note.file,
1695
1699
  ...at("type", type),
@@ -36,6 +36,10 @@ import { AFFILIATION_STANDINGS } from "../sohl/affiliation-standings.mjs";
36
36
  /**
37
37
  * Resolves a dotted frontmatter key (e.g., "name.full") into the nested
38
38
  * value. Returns `defaultValue` if any path segment is missing.
39
+ * @param {object|null|undefined} fm - Parsed frontmatter.
40
+ * @param {string} key - A top-level or dotted property path.
41
+ * @param {unknown} [defaultValue] - Value for a missing property.
42
+ * @returns {unknown} The property value or default.
39
43
  */
40
44
  export function getFrontmatter(fm, key, defaultValue = undefined) {
41
45
  if (fm == null || typeof fm !== "object") return defaultValue;
@@ -53,6 +57,10 @@ export function getFrontmatter(fm, key, defaultValue = undefined) {
53
57
  * Reads a key from `fm.sohl` (the vault's nested system-fields block).
54
58
  * Supports dotted notation, e.g. sohlField(fm, "charges.value", 0).
55
59
  * Falls back to top-level `fm[key]` if `sohl` doesn't carry the key.
60
+ * @param {object|null|undefined} fm - Parsed frontmatter.
61
+ * @param {string} key - A top-level or dotted property path.
62
+ * @param {unknown} [defaultValue] - Value for a missing property.
63
+ * @returns {unknown} The system value, top-level value, or default.
56
64
  */
57
65
  export function sohlField(fm, key, defaultValue = undefined) {
58
66
  if (fm == null || typeof fm !== "object") return defaultValue;
@@ -321,6 +329,8 @@ export function requireSubType(fm, ctx) {
321
329
  * - Array of "Label:MaxValue" strings, e.g. ["Ugly:4", "Plain:12"]
322
330
  * - Array of objects, e.g. [{ label, maxValue }]
323
331
  * Returns a normalized array of `{ label, maxValue: number }`.
332
+ * @param {unknown} raw - An authored threshold array.
333
+ * @returns {Array<{label: string, maxValue: number}>} Normalized thresholds.
324
334
  */
325
335
  export function parseValueDesc(raw) {
326
336
  if (!raw || !Array.isArray(raw)) return [];
@@ -430,7 +430,7 @@ export function orderPassesByDependency(packs) {
430
430
  * would produce them is not in it.
431
431
  *
432
432
  * Ordering answers the whole-package build; a run restricted to one pack
433
- * (`content-build package compile <name>`) cannot conjure the passes it left
433
+ * (`package-build package compile <name>`) cannot conjure the passes it left
434
434
  * out. Where their output is already on disk from an earlier run that is fine
435
435
  * — it is how compiling one pack at a time is meant to work — so this reports
436
436
  * only what is genuinely absent, and names the pack that would write it rather
@@ -573,7 +573,7 @@ async function generatePack(
573
573
  // The catalogue of a package this repository depends on but does
574
574
  // not contain, for a repository that authors beings without
575
575
  // holding the items they are assembled from. Cache-only: a cold
576
- // cache throws naming `content-build deps fetch` rather than
576
+ // cache throws naming `package-build deps fetch` rather than
577
577
  // downloading inside a compile. Scoped to this pack's system for the
578
578
  // reason the local half is: both halves answer one lookup, so a
579
579
  // dependency shipping two systems' items would otherwise supply the