@heroiclands/package-build 22.21.0 → 22.23.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 (152) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/assets/styles/content-base.css +162 -0
  3. package/bin/content-commands.mjs +77 -17
  4. package/bin/package-build.mjs +15 -2
  5. package/ci/content-format-reference.mjs +68 -1
  6. package/config.mjs +26 -0
  7. package/content-config.mjs +153 -6
  8. package/docs/api.md +120 -89
  9. package/docs/authoring/assets.md +14 -0
  10. package/docs/authoring/dates-and-calendars.md +41 -3
  11. package/docs/authoring/frontmatter.md +2 -2
  12. package/docs/authoring/links-and-markup.md +325 -35
  13. package/docs/commands.md +12 -10
  14. package/docs/configuration.md +117 -14
  15. package/docs/content-format.md +4 -2
  16. package/docs/guides/book.md +5 -4
  17. package/docs/guides/reference-pages.md +15 -0
  18. package/docs/reference/format-details.md +545 -104
  19. package/docs/reference/note-types.md +30 -13
  20. package/engine/actor-compiler.mjs +23 -0
  21. package/engine/anchored-sections.mjs +35 -4
  22. package/engine/anchors.mjs +26 -30
  23. package/engine/art-fields.mjs +118 -0
  24. package/engine/art-slots.mjs +7 -1
  25. package/engine/asset-index.mjs +160 -10
  26. package/engine/asset-replacement.mjs +93 -0
  27. package/engine/asset-types.mjs +240 -0
  28. package/engine/base-compiler.mjs +139 -22
  29. package/engine/base-styles.mjs +149 -0
  30. package/engine/bundles.mjs +13 -2
  31. package/engine/compendiums.mjs +8 -1
  32. package/engine/content-blocks.mjs +410 -0
  33. package/engine/content-embeds.mjs +19 -0
  34. package/engine/content-figures.mjs +468 -0
  35. package/engine/content-footnotes.mjs +197 -0
  36. package/engine/content-format-check.mjs +15 -12
  37. package/engine/content-format.yaml +1 -1
  38. package/engine/content-images.mjs +126 -25
  39. package/engine/content-index.mjs +38 -12
  40. package/engine/content-links.mjs +14 -6
  41. package/engine/content-tables.mjs +45 -4
  42. package/engine/content-tasklists.mjs +189 -0
  43. package/engine/docs-checks.mjs +4 -4
  44. package/engine/extension-attributes.mjs +48 -2
  45. package/engine/field-reference.mjs +31 -10
  46. package/engine/field-spec.mjs +10 -8
  47. package/engine/foreign-catalog.mjs +143 -18
  48. package/engine/frontmatter-lint.mjs +120 -65
  49. package/engine/frontmatter.mjs +3 -3
  50. package/engine/heading-attributes.mjs +321 -0
  51. package/engine/helpers.mjs +109 -41
  52. package/engine/homepage.mjs +8 -8
  53. package/engine/index.mjs +3 -0
  54. package/engine/infobox-registry.mjs +4 -1
  55. package/engine/infobox-render.mjs +15 -3
  56. package/engine/infobox.mjs +129 -15
  57. package/engine/itinerary-scenes.mjs +4 -10
  58. package/engine/journals.mjs +294 -50
  59. package/engine/lore-events.mjs +197 -0
  60. package/engine/macros.mjs +1 -1
  61. package/engine/markdown-expressions.mjs +434 -59
  62. package/engine/note-dates.mjs +354 -12
  63. package/engine/note-frontmatter.mjs +7 -16
  64. package/engine/note-vocabulary.mjs +183 -42
  65. package/engine/page-lists.mjs +358 -0
  66. package/engine/pathnames.mjs +36 -0
  67. package/engine/pdf-build.mjs +431 -74
  68. package/engine/pdf-images.mjs +64 -0
  69. package/engine/pdf-render.mjs +563 -66
  70. package/engine/rank-ladder.mjs +198 -0
  71. package/engine/reckoning-markers.mjs +1 -1
  72. package/engine/retired-fields.mjs +8 -13
  73. package/engine/scenes.mjs +26 -7
  74. package/engine/site-build.mjs +166 -23
  75. package/engine/site-config.mjs +10 -1
  76. package/engine/site-index.mjs +90 -3
  77. package/engine/site-preview.mjs +20 -7
  78. package/engine/social-ties.mjs +52 -14
  79. package/engine/sql-tables.mjs +66 -9
  80. package/engine/standings.mjs +89 -6
  81. package/engine/system-block.mjs +56 -30
  82. package/engine/web-wikilinks.mjs +48 -1
  83. package/hm3/actors.mjs +8 -5
  84. package/manifest.mjs +43 -24
  85. package/package.json +1 -1
  86. package/packagebuild-metadata.jsonl +13 -13
  87. package/sohl/actors.mjs +22 -99
  88. package/sohl/item-fields.mjs +5 -5
  89. package/sohl/items.mjs +1 -1
  90. package/sohl/note-schemas.mjs +11 -21
  91. package/types/config.d.mts +9 -0
  92. package/types/content-config.d.mts +48 -1
  93. package/types/engine/actor-compiler.d.mts +11 -0
  94. package/types/engine/anchored-sections.d.mts +24 -0
  95. package/types/engine/anchors.d.mts +9 -4
  96. package/types/engine/art-fields.d.mts +64 -0
  97. package/types/engine/asset-index.d.mts +43 -0
  98. package/types/engine/asset-replacement.d.mts +22 -0
  99. package/types/engine/asset-types.d.mts +18 -0
  100. package/types/engine/base-compiler.d.mts +19 -17
  101. package/types/engine/base-styles.d.mts +93 -0
  102. package/types/engine/content-blocks.d.mts +87 -0
  103. package/types/engine/content-embeds.d.mts +16 -0
  104. package/types/engine/content-figures.d.mts +101 -0
  105. package/types/engine/content-footnotes.d.mts +80 -0
  106. package/types/engine/content-format-check.d.mts +8 -6
  107. package/types/engine/content-images.d.mts +78 -12
  108. package/types/engine/content-index.d.mts +8 -2
  109. package/types/engine/content-tables.d.mts +12 -4
  110. package/types/engine/content-tasklists.d.mts +72 -0
  111. package/types/engine/extension-attributes.d.mts +22 -1
  112. package/types/engine/field-reference.d.mts +21 -0
  113. package/types/engine/field-spec.d.mts +20 -16
  114. package/types/engine/foreign-catalog.d.mts +39 -0
  115. package/types/engine/frontmatter.d.mts +1 -1
  116. package/types/engine/heading-attributes.d.mts +179 -0
  117. package/types/engine/helpers.d.mts +29 -10
  118. package/types/engine/homepage.d.mts +7 -7
  119. package/types/engine/index.d.mts +1 -0
  120. package/types/engine/infobox-registry.d.mts +4 -1
  121. package/types/engine/infobox-render.d.mts +6 -1
  122. package/types/engine/infobox.d.mts +6 -0
  123. package/types/engine/journals.d.mts +41 -12
  124. package/types/engine/lore-events.d.mts +14 -0
  125. package/types/engine/markdown-expressions.d.mts +46 -2
  126. package/types/engine/note-dates.d.mts +64 -1
  127. package/types/engine/note-frontmatter.d.mts +1 -4
  128. package/types/engine/note-vocabulary.d.mts +82 -6
  129. package/types/engine/page-lists.d.mts +126 -0
  130. package/types/engine/pathnames.d.mts +26 -0
  131. package/types/engine/pdf-build.d.mts +28 -6
  132. package/types/engine/pdf-images.d.mts +52 -0
  133. package/types/engine/pdf-render.d.mts +84 -2
  134. package/types/engine/rank-ladder.d.mts +32 -0
  135. package/types/engine/retired-fields.d.mts +4 -6
  136. package/types/engine/site-build.d.mts +27 -4
  137. package/types/engine/site-config.d.mts +9 -0
  138. package/types/engine/site-index.d.mts +67 -1
  139. package/types/engine/site-preview.d.mts +1 -1
  140. package/types/engine/sql-tables.d.mts +31 -7
  141. package/types/engine/standings.d.mts +21 -3
  142. package/types/engine/system-block.d.mts +37 -14
  143. package/types/engine/web-wikilinks.d.mts +3 -1
  144. package/types/hm3/actors.d.mts +21 -3
  145. package/types/manifest.d.mts +19 -23
  146. package/types/sohl/actors.d.mts +6 -7
  147. package/engine/content-admonitions.mjs +0 -114
  148. package/engine/content-captions.mjs +0 -181
  149. package/engine/content-secrets.mjs +0 -112
  150. package/types/engine/content-admonitions.d.mts +0 -24
  151. package/types/engine/content-captions.d.mts +0 -45
  152. package/types/engine/content-secrets.d.mts +0 -17
@@ -0,0 +1,189 @@
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 list item opened with `[ ]` or `[x]`, reported.
16
+ *
17
+ * **The format has no checkbox.** A list is written in plain markdown, and
18
+ * `[ ]` or `[x]` right after the marker is read as the start of the item's own
19
+ * text on every surface — stating what is done belongs in the words, not in a
20
+ * bracket.
21
+ *
22
+ * ## What it does not look at
23
+ *
24
+ * - **Fenced blocks and code spans.** A checklist shown as a markup example is
25
+ * prose *about* the two characters, not an authored list — this module's own
26
+ * documentation is written out of such examples. Which runs count as code is
27
+ * {@link module:engine/code-fences.codeRegions}' rule rather than a second
28
+ * copy of it.
29
+ * - **A bracket anywhere else in a line.** Only the marker that opens a list
30
+ * item is read this way; the same two characters in the middle of a sentence
31
+ * are prose.
32
+ *
33
+ * ## A warning, like the checks around it
34
+ *
35
+ * Nothing here is miscompiled — every surface sets the marker as the item's
36
+ * own text, and all three agree. The finding is for the author: the two
37
+ * characters read as an interactive control only on one surface, and nowhere
38
+ * else, so a list reaching for that meaning should say what is done in words
39
+ * instead.
40
+ *
41
+ * @module
42
+ */
43
+
44
+ import fs from "node:fs";
45
+ import path from "node:path";
46
+
47
+ import { matchAllOutsideCode } from "./code-fences.mjs";
48
+ import { positionInBody } from "./diagnostics.mjs";
49
+
50
+ /**
51
+ * A list item's marker, immediately followed by `[ ]` or `[x]` and a space —
52
+ * bulleted (`-`, `*`, `+`) or ordered (`1.` or `1)`), at the start of a line.
53
+ *
54
+ * The leading indentation is a lookbehind rather than part of the match, so a
55
+ * nested item's finding points at the marker itself, not at the line's first
56
+ * column.
57
+ *
58
+ * @type {RegExp}
59
+ */
60
+ export const TASK_LIST_MARKER = /(?<=^[ \t]*)(?:[-*+]|\d+[.)])[ \t]+\[[ xX]\][ \t]/gm;
61
+
62
+ /**
63
+ * What a note opening a list item with `[ ]` or `[x]` is told.
64
+ *
65
+ * @param {string} marker - The matched marker, as written.
66
+ * @returns {string} The message, unpunctuated at the end as a finding is.
67
+ */
68
+ export function taskListMessage(marker) {
69
+ return (
70
+ `\`${marker.trim()}\` opens a list item with no checkbox to become — ` +
71
+ "write a checklist as an ordinary list, stating what is done in the " +
72
+ "item's own words. Inside a fence or a code span it is an example, and " +
73
+ "not reported"
74
+ );
75
+ }
76
+
77
+ /**
78
+ * Every list item opened with `[ ]` or `[x]` in one note's body.
79
+ *
80
+ * @param {string} body - The note's markdown, without its frontmatter.
81
+ * @param {string} file - The note's path, for the finding.
82
+ * @param {object} [opts]
83
+ * @param {number} [opts.bodyLine=1] - The 1-based file line the body starts on.
84
+ * @param {number} [opts.bodyColumn=1] - The 1-based file column it starts at.
85
+ * @returns {Array<{file: string, line: number, column: number,
86
+ * severity: "warning", message: string}>} One finding per item, in source
87
+ * order.
88
+ */
89
+ export function checkTaskLists(body, file, { bodyLine = 1, bodyColumn = 1 } = {}) {
90
+ const text = String(body ?? "");
91
+ if (!text) return [];
92
+
93
+ return matchAllOutsideCode(text, TASK_LIST_MARKER).map((match) => ({
94
+ file,
95
+ ...positionInBody(text, /** @type {number} */ (match.index), { bodyLine, bodyColumn }),
96
+ severity: /** @type {"warning"} */ ("warning"),
97
+ message: taskListMessage(match[0]),
98
+ }));
99
+ }
100
+
101
+ /**
102
+ * Walk a content tree and report every list item opened with `[ ]` or `[x]`.
103
+ *
104
+ * The frontmatter fence is taken off first, so what is scanned is the body and
105
+ * the positions are still the file's. A file with no frontmatter is scanned
106
+ * whole: it is not a note, but a stray `.md` in the tree carrying the marker is
107
+ * the same problem for the same reason.
108
+ *
109
+ * A finding names its file **relative to the working directory**, which is
110
+ * where a reader is standing and what `formatDiagnostic` emits. The content
111
+ * root is where the walk starts, not what a path is measured from.
112
+ *
113
+ * @param {string} contentBase - Root of the content tree.
114
+ * @param {object} [opts]
115
+ * @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore
116
+ * in addition to the dot-directories always skipped.
117
+ * @returns {{findings: Array<{file: string, line: number, column: number,
118
+ * severity: "warning", message: string}>, files: number}} The findings, and
119
+ * how many files were read.
120
+ */
121
+ export function lintContentTaskLists(contentBase, { skipDirectories = [] } = {}) {
122
+ const skip = new Set(skipDirectories);
123
+ /** @type {Array<{file: string, line: number, column: number, severity: "warning", message: string}>} */
124
+ const findings = [];
125
+ let files = 0;
126
+
127
+ /** @param {string} dir - Directory to descend into. */
128
+ const walk = (dir) => {
129
+ /** @type {import("node:fs").Dirent[]} */
130
+ let entries;
131
+ try {
132
+ entries = fs.readdirSync(dir, { withFileTypes: true });
133
+ } catch {
134
+ return;
135
+ }
136
+ for (const entry of entries) {
137
+ if (entry.name.startsWith(".") || skip.has(entry.name)) continue;
138
+ const full = path.join(dir, entry.name);
139
+ if (entry.isDirectory()) {
140
+ walk(full);
141
+ continue;
142
+ }
143
+ if (!/\.(md|markdown)$/i.test(entry.name)) continue;
144
+ let content;
145
+ try {
146
+ content = fs.readFileSync(full, "utf8");
147
+ } catch {
148
+ continue;
149
+ }
150
+ files += 1;
151
+ findings.push(
152
+ ...checkTaskLists(...bodyOf(content, path.relative(process.cwd(), full))),
153
+ );
154
+ }
155
+ };
156
+
157
+ walk(contentBase);
158
+ return { findings, files };
159
+ }
160
+
161
+ /**
162
+ * A file's body, its path, and where the body starts in the file.
163
+ *
164
+ * The same split {@link module:engine/helpers.parseMarkdownFile} makes, without
165
+ * parsing the YAML: this check has no use for the frontmatter's *values*, and
166
+ * reading them would make an unparseable note silently unscanned.
167
+ *
168
+ * @param {string} content - The whole file.
169
+ * @param {string} file - Its path, for the finding.
170
+ * @returns {[string, string, {bodyLine: number, bodyColumn: number}]} The
171
+ * arguments {@link checkTaskLists} takes.
172
+ */
173
+ function bodyOf(content, file) {
174
+ const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
175
+ if (!match) return [content, file, { bodyLine: 1, bodyColumn: 1 }];
176
+
177
+ const raw = match[2];
178
+ const body = raw.trim();
179
+ const bodyStart = content.length - raw.length + (raw.length - raw.trimStart().length);
180
+ const before = content.slice(0, bodyStart);
181
+ return [
182
+ body,
183
+ file,
184
+ {
185
+ bodyLine: before.split("\n").length,
186
+ bodyColumn: bodyStart - before.lastIndexOf("\n"),
187
+ },
188
+ ];
189
+ }
@@ -5,6 +5,8 @@
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
7
 
8
+ import { splitHeadingAttributes } from "./heading-attributes.mjs";
9
+
8
10
  /** @param {string} root */
9
11
  function* markdownFiles(root) {
10
12
  for (const entry of fs
@@ -40,11 +42,9 @@ export function docAnchors(source) {
40
42
  const body = maskMarkdownCode(source, false);
41
43
  const anchors = new Set();
42
44
  for (const [, heading] of body.matchAll(/^#{1,6}\s+(.+?)\s*#*$/gm)) {
43
- const explicit = heading.match(/\{#([^}]+)\}\s*$/);
45
+ const explicit = splitHeadingAttributes(heading);
44
46
  anchors.add(
45
- explicit ?
46
- explicit[1].trim()
47
- : docHeadingSlug(heading.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")),
47
+ explicit.id || docHeadingSlug(explicit.text.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")),
48
48
  );
49
49
  }
50
50
  for (const [, anchor] of body.matchAll(/<a\s[^>]*(?:id|name)="([^"]+)"/g)) {
@@ -1,12 +1,24 @@
1
1
  /* SPDX-License-Identifier: GPL-3.0-or-later */
2
2
 
3
+ /**
4
+ * A name an id, a class or an attribute key may carry.
5
+ *
6
+ * One letter first, then letters, digits, `_` and `-`: a CSS class and an
7
+ * attribute key are written this way wherever they appear.
8
+ */
9
+ const NAME = /^[A-Za-z][A-Za-z0-9_-]*$/;
10
+
3
11
  /**
4
12
  * Read the shared braced attribute grammar used by body extensions.
5
13
  * Callers validate which names and classes their extension understands.
6
14
  * @param {string} source - Text inside the braces.
15
+ * @param {object} [options={}]
16
+ * @param {RegExp} [options.idPattern=NAME] - What an `#id` may be named. A
17
+ * construct whose id is published as an HTML `id` admits a digit first and
18
+ * passes a looser pattern; the default is the pattern every other name takes.
7
19
  * @returns {{id: string, classes: string[], values: Record<string, string>, problems: string[]}}
8
20
  */
9
- export function parseExtensionAttributes(source) {
21
+ export function parseExtensionAttributes(source, { idPattern = NAME } = {}) {
10
22
  const text = String(source ?? "");
11
23
  const result = { id: "", classes: [], values: {}, problems: [] };
12
24
  let at = 0;
@@ -18,7 +30,7 @@ export function parseExtensionAttributes(source) {
18
30
  const kind = text[at++];
19
31
  while (at < text.length && !/\s/.test(text[at])) at++;
20
32
  const name = text.slice(start + 1, at);
21
- if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(name)) {
33
+ if (!(kind === "#" ? idPattern : NAME).test(name)) {
22
34
  result.problems.push(
23
35
  `invalid ${kind === "#" ? "id" : "class"} attribute ${text.slice(start, at)}`,
24
36
  );
@@ -72,3 +84,37 @@ export function booleanAttribute(value, key) {
72
84
  if (value === "false") return false;
73
85
  throw new TypeError(`${key} needs the literal value true or false`);
74
86
  }
87
+
88
+ /**
89
+ * Attributes a construct's own markup owns, so an author sets them through
90
+ * `#id` and `.class` rather than through a key.
91
+ */
92
+ const OWNED = Object.freeze(["id", "class"]);
93
+
94
+ /**
95
+ * The attribute keys no construct writes onto an element.
96
+ *
97
+ * `id=` and `class=` are refused in favour of `#id` and `.class`, which the
98
+ * grammar already states; a key beginning `on` is an event handler, and nothing
99
+ * authored in a note becomes code on a published page.
100
+ *
101
+ * Shared by every construct that writes an author's attributes onto an element,
102
+ * because a refusal that held on one and not the next would be the hole the
103
+ * other construct is checked for.
104
+ *
105
+ * @param {Record<string, string>} values - The `key=value` attributes parsed.
106
+ * @returns {string[]} One message per refused key, in the order written.
107
+ */
108
+ export function refusedAttributes(values) {
109
+ const problems = [];
110
+ for (const key of Object.keys(values ?? {})) {
111
+ if (OWNED.includes(key.toLowerCase())) {
112
+ problems.push(
113
+ `set ${key} with ${key.toLowerCase() === "id" ? "#id" : ".class"} rather than ${key}=`,
114
+ );
115
+ } else if (/^on/i.test(key)) {
116
+ problems.push(`${key} is an event handler and is not written`);
117
+ }
118
+ }
119
+ return problems;
120
+ }
@@ -123,16 +123,41 @@ function fieldTable(fields) {
123
123
  return padTable(rows);
124
124
  }
125
125
 
126
+ /**
127
+ * The one line explaining why a single field's shared top-level fallback is
128
+ * off.
129
+ *
130
+ * **The field's reason stands on its own** — a complete sentence, or more
131
+ * than one — rather than completing a clause the frame starts. A field that
132
+ * collides with a top-level spelling meaning something else can say so
133
+ * ("`weight` is the item's own, independent of the note's."); a field whose
134
+ * top-level spelling is refused outright by the closed vocabulary can say
135
+ * that instead, honestly, which "there it means …" cannot: a refused key
136
+ * carries no meaning to report.
137
+ *
138
+ * Exported so a guard can walk every field declaring `topLevelMeans` across
139
+ * every registry and check that each one composes into a grammatical
140
+ * sentence, without reimplementing the frame it is checking.
141
+ *
142
+ * @param {import("./field-spec.mjs").FieldSpec} field - A field declaring
143
+ * `topLevelMeans`.
144
+ * @returns {string} The composed markdown line.
145
+ */
146
+ export function topLevelExemptionLine(field) {
147
+ return `**\`${field.name}\` is not read from the note's top level.** ${field.topLevelMeans}`;
148
+ }
149
+
126
150
  /**
127
151
  * The fields of one type that are **not** filled from the note's top level.
128
152
  *
129
153
  * A field ordinarily falls back to the top-level property spelled like its
130
154
  * name, so an author who writes `weight: 3` at the top of a note reasonably
131
- * expects it to reach the document. Where that spelling means something else at
132
- * the note level the fallback is off, and an author has no way to tell from the
133
- * table — the field is there, the value is written, and the document ships the
134
- * default. So the reason each such field declares is rendered beside its table
135
- * rather than left in the source.
155
+ * expects it to reach the document. Where that spelling is wrong at the note
156
+ * level — it means something else there, or the closed top-level vocabulary
157
+ * refuses it outright — the fallback is off, and an author has no way to tell
158
+ * from the table — the field is there, the value is written, and the document
159
+ * ships the default. So the reason each such field declares is rendered
160
+ * beside its table rather than left in the source.
136
161
  *
137
162
  * Below the table, not inside it: the reason is a sentence or two, and
138
163
  * {@link padTable} pads every column to its widest cell, so a cell holding it
@@ -144,11 +169,7 @@ function fieldTable(fields) {
144
169
  function sharedExemptions(fields) {
145
170
  const exempt = authoredFields(fields).filter((field) => field.topLevelMeans);
146
171
  if (!exempt.length) return [];
147
- return exempt.flatMap((field) => [
148
- `**\`${field.name}\` is not read from the note's top level.** There it means ` +
149
- `${field.topLevelMeans}`,
150
- "",
151
- ]);
172
+ return exempt.flatMap((field) => [topLevelExemptionLine(field), ""]);
152
173
  }
153
174
 
154
175
  /**
@@ -84,19 +84,21 @@ export { legacyKeyOf, retiredTopLevelKey, setPath };
84
84
  * retiring one — so a field can move into `data:` while the corpus catches
85
85
  * up, instead of on a flag day across four repositories. A declaration that
86
86
  * names one is mid-sweep by construction; see {@link readsLegacyKey}.
87
- * @property {string} [topLevelMeans] - **What the note's top-level key of this
88
- * name means instead** — declared only where it means something else, and
89
- * stating it removes the shared top-level position from this field's
90
- * resolution order.
87
+ * @property {string} [topLevelMeans] - **Why the note's top-level key of this
88
+ * name is not this field's source** — declared wherever that key's spelling
89
+ * means something else at the note level, or the closed top-level
90
+ * vocabulary refuses it outright, and stating either removes the shared
91
+ * top-level position from this field's resolution order.
91
92
  *
92
93
  * A field's `name` doubles as its identity and as the shared property it
93
94
  * draws from, which is right wherever the two levels state the same quantity
94
95
  * — `data.weight` is the weight, whoever reads it. It is wrong wherever a
95
- * spelling collides across the two vocabularies. An `affiliation` item's
96
+ * spelling collides across the two vocabularies, or names a key the closed
97
+ * top-level vocabulary does not admit at all. An `affiliation` item's
96
98
  * `system.title` is the style of address an office carries; a note's
97
- * top-level `title` is the note's own heading. Nothing relates them, and
98
- * before this key one silently fed the other, stringifying an authored
99
- * `title: null` into fifteen documents.
99
+ * top-level `title` is refused outright. Nothing relates them, and before
100
+ * this key one silently fed the other, stringifying an authored `title:
101
+ * null` into fifteen documents.
100
102
  *
101
103
  * **The value is the reason**, not a flag with a comment beside it. A boolean
102
104
  * would record the decision and lose the case for it, and the next person
@@ -38,6 +38,12 @@
38
38
  * local pack's output — the resolution logic needs no knowledge of where an
39
39
  * item came from.
40
40
  *
41
+ * `assetArchive: true` is the sibling flag, for a dependency whose bytes are
42
+ * wanted and whose items are not: an asset-replacement source such as
43
+ * `thalornaaltart` ships no Item packs at all. It unpacks the same release
44
+ * archive without extracting anything from it, so a relationship declaring
45
+ * both flags on one entry is fetched once rather than twice.
46
+ *
41
47
  * **The network is never touched by a compile.** Fetching is its own command
42
48
  * (`package-build deps fetch`), and a compile whose cache is cold fails saying
43
49
  * so. A build that silently downloads is not reproducible, fails strangely
@@ -73,17 +79,22 @@ import {
73
79
  const STAMP = ".complete";
74
80
 
75
81
  /**
76
- * Every declared relationship that opted into supplying an item catalogue.
82
+ * Every declared relationship carrying a given flag, in declaration order.
83
+ *
84
+ * Shared by {@link itemCatalogRelationships} and
85
+ * {@link assetArchiveRelationships}: both read the same shape out of the same
86
+ * walk, filtered on a different key.
77
87
  *
78
88
  * @param {object} config - The resolved build configuration.
89
+ * @param {"itemCatalog"|"assetArchive"} flag - Which boolean to filter on.
79
90
  * @returns {Array<{id: string, manifest: string, kind: string, verified: string|undefined}>}
80
- * The opted-in relationships, in declaration order.
91
+ * The opted-in relationships.
81
92
  */
82
- export function itemCatalogRelationships(config) {
93
+ function flaggedRelationships(config, flag) {
83
94
  const out = [];
84
95
  for (const [kind, entries] of Object.entries(config.relationships ?? {})) {
85
96
  for (const rel of entries ?? []) {
86
- if (rel.itemCatalog) {
97
+ if (rel[flag]) {
87
98
  out.push({
88
99
  id: rel.id,
89
100
  manifest: rel.manifest,
@@ -96,6 +107,29 @@ export function itemCatalogRelationships(config) {
96
107
  return out;
97
108
  }
98
109
 
110
+ /**
111
+ * Every declared relationship that opted into supplying an item catalogue.
112
+ *
113
+ * @param {object} config - The resolved build configuration.
114
+ * @returns {Array<{id: string, manifest: string, kind: string, verified: string|undefined}>}
115
+ * The opted-in relationships, in declaration order.
116
+ */
117
+ export function itemCatalogRelationships(config) {
118
+ return flaggedRelationships(config, "itemCatalog");
119
+ }
120
+
121
+ /**
122
+ * Every declared relationship that opted into supplying its release archive
123
+ * for asset bytes alone, with no item catalogue built from it.
124
+ *
125
+ * @param {object} config - The resolved build configuration.
126
+ * @returns {Array<{id: string, manifest: string, kind: string, verified: string|undefined}>}
127
+ * The opted-in relationships, in declaration order.
128
+ */
129
+ export function assetArchiveRelationships(config) {
130
+ return flaggedRelationships(config, "assetArchive");
131
+ }
132
+
99
133
  /**
100
134
  * The cache directory for one dependency at one version.
101
135
  *
@@ -366,15 +400,21 @@ export function pinnedManifestUrl(url, verified) {
366
400
  }
367
401
 
368
402
  /**
369
- * Fetch one dependency and extract its Item packs.
370
- *
371
- * Idempotent: a complete cache for the resolved version is left alone.
372
- *
373
- * @param {object} config - The resolved build configuration.
374
- * @param {{id: string, manifest: string}} rel - The declared relationship.
375
- * @returns {Promise<string>} The dependency's cache directory.
403
+ * Resolve a dependency's manifest, pinned to `compatibility.verified` the way
404
+ * both fetch paths require.
405
+ *
406
+ * Shared by {@link fetchCatalog} and {@link fetchAssetArchive}: both read the
407
+ * same manifest before deciding what to unpack from it.
408
+ *
409
+ * @param {{id: string, manifest: string, verified?: string}} rel - The
410
+ * declared relationship.
411
+ * @param {string} subject - What floats when no version is verified —
412
+ * "catalogue" or "archive" — named in the warning a reader would otherwise
413
+ * get no word for.
414
+ * @returns {Promise<{manifest: object, version: string}>} The manifest and
415
+ * its resolved version.
376
416
  */
377
- export async function fetchCatalog(config, rel) {
417
+ async function resolveManifest(rel, subject) {
378
418
  const { url, pinned } = pinnedManifestUrl(rel.manifest, rel.verified);
379
419
  const manifest = await fetchManifest(url);
380
420
  const version = manifest.version;
@@ -391,10 +431,24 @@ export async function fetchCatalog(config, rel) {
391
431
  }
392
432
  if (!rel.verified) {
393
433
  log.warn(
394
- `${rel.id}: no \`compatibility.verified\`, so its catalogue floats ` +
434
+ `${rel.id}: no \`compatibility.verified\`, so its ${subject} floats ` +
395
435
  `with whatever ${url} currently serves`,
396
436
  );
397
437
  }
438
+ return { manifest, version };
439
+ }
440
+
441
+ /**
442
+ * Fetch one dependency and extract its Item packs.
443
+ *
444
+ * Idempotent: a complete cache for the resolved version is left alone.
445
+ *
446
+ * @param {object} config - The resolved build configuration.
447
+ * @param {{id: string, manifest: string}} rel - The declared relationship.
448
+ * @returns {Promise<string>} The dependency's cache directory.
449
+ */
450
+ export async function fetchCatalog(config, rel) {
451
+ const { manifest, version } = await resolveManifest(rel, "catalogue");
398
452
  const dir = catalogDir(config, rel.id, version);
399
453
  if (isComplete(dir)) {
400
454
  log.info(`${rel.id}@${version}: already cached`);
@@ -420,6 +474,61 @@ export async function fetchCatalog(config, rel) {
420
474
  return dir;
421
475
  }
422
476
 
477
+ /**
478
+ * Whether an asset-archive-only cache is present and complete.
479
+ *
480
+ * No item catalogue is built on this path, so completeness never asks for
481
+ * `item-packs.json` — an asset-only dependency, such as `thalornaaltart`,
482
+ * ships none.
483
+ *
484
+ * @param {string} dir - The dependency's cache directory.
485
+ * @returns {boolean} True when the archive was fetched to completion.
486
+ */
487
+ const isArchiveComplete = (dir) => fs.existsSync(path.join(dir, STAMP));
488
+
489
+ /**
490
+ * Fetch one dependency's release archive for its asset bytes alone, building
491
+ * no item catalogue from it.
492
+ *
493
+ * Idempotent: a complete cache for the resolved version is left alone. Where a
494
+ * relationship also declares `itemCatalog: true`, {@link fetchCatalog} already
495
+ * unpacked this same archive into this same cache directory, and
496
+ * {@link fetchAllCatalogs} does not call this function for it — the two never
497
+ * download the same bytes twice.
498
+ *
499
+ * @param {object} config - The resolved build configuration.
500
+ * @param {{id: string, manifest: string, verified?: string}} rel - The
501
+ * declared relationship.
502
+ * @returns {Promise<string>} The dependency's cache directory.
503
+ */
504
+ export async function fetchAssetArchive(config, rel) {
505
+ const { manifest, version } = await resolveManifest(rel, "archive");
506
+ const dir = catalogDir(config, rel.id, version);
507
+ if (isArchiveComplete(dir)) {
508
+ log.info(`${rel.id}@${version}: already cached`);
509
+ return dir;
510
+ }
511
+
512
+ const download = manifest.download;
513
+ if (!download) {
514
+ throw new Error(`${rel.id}@${version}: its manifest declares no \`download\``);
515
+ }
516
+
517
+ // Rebuild from empty: a previous run may have died partway, and a stale
518
+ // half-tree is worse than no tree.
519
+ fs.rmSync(dir, { recursive: true, force: true });
520
+ const raw = path.join(dir, "package");
521
+ fs.mkdirSync(raw, { recursive: true });
522
+
523
+ log.info(`${rel.id}@${version}: downloading ${download}`);
524
+ await downloadAndUnzip(download, raw);
525
+
526
+ cacheSchemaArtifact(raw, dir);
527
+ // Last, so a fetch that died partway is never mistaken for a complete one.
528
+ fs.writeFileSync(path.join(dir, STAMP), `${version}\n`);
529
+ return dir;
530
+ }
531
+
423
532
  /**
424
533
  * Locate a declared pack inside an unpacked archive.
425
534
  *
@@ -700,17 +809,33 @@ export async function fetchAllMetadata(config) {
700
809
  /**
701
810
  * Fetch every opted-in dependency. The `deps fetch` command.
702
811
  *
812
+ * A relationship declaring both `itemCatalog: true` and `assetArchive: true`
813
+ * is fetched once: {@link fetchCatalog} already unpacks the archive on its
814
+ * way to extracting the item catalogue, so this skips the archive-only fetch
815
+ * for any id {@link fetchCatalog} already handled.
816
+ *
703
817
  * @param {object} config - The resolved build configuration.
704
818
  * @returns {Promise<number>} How many dependencies were fetched.
705
819
  */
706
820
  export async function fetchAllCatalogs(config) {
707
- const rels = itemCatalogRelationships(config);
708
- if (!rels.length) {
709
- log.info("No relationship declares `itemCatalog: true`; nothing to fetch.");
821
+ const itemRels = itemCatalogRelationships(config);
822
+ const archiveRels = assetArchiveRelationships(config);
823
+ if (!itemRels.length && !archiveRels.length) {
824
+ log.info(
825
+ "No relationship declares `itemCatalog: true` or `assetArchive: true`; " +
826
+ "nothing to fetch.",
827
+ );
710
828
  return 0;
711
829
  }
712
- for (const rel of rels) await fetchCatalog(config, rel);
713
- return rels.length;
830
+ for (const rel of itemRels) await fetchCatalog(config, rel);
831
+ const alreadyFetched = new Set(itemRels.map((rel) => rel.id));
832
+ let archiveFetched = 0;
833
+ for (const rel of archiveRels) {
834
+ if (alreadyFetched.has(rel.id)) continue;
835
+ await fetchAssetArchive(config, rel);
836
+ archiveFetched++;
837
+ }
838
+ return itemRels.length + archiveFetched;
714
839
  }
715
840
 
716
841
  /**