@heroiclands/package-build 17.1.0 → 18.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.
- package/CHANGELOG.md +1773 -0
- package/CONTENT.md +288 -72
- package/README.md +1 -1
- package/bin/content-build.mjs +218 -137
- package/bin/package-build.mjs +43 -0
- package/content-config.mjs +62 -67
- package/docs/content-format.md +768 -58
- package/engine/actor-compiler.mjs +586 -0
- package/engine/address-charset.mjs +11 -8
- package/engine/address-diff.mjs +266 -22
- package/engine/anchored-sections.mjs +83 -0
- package/engine/anchors.mjs +83 -0
- package/engine/base-compiler.mjs +148 -18
- package/engine/bundle-notes.mjs +276 -0
- package/engine/bundles.mjs +307 -0
- package/engine/code-fences.mjs +103 -0
- package/engine/compile-corpus.mjs +89 -0
- package/engine/content-address.mjs +267 -5
- package/engine/content-format-check.mjs +54 -11
- package/engine/content-format.mjs +37 -5
- package/engine/content-index.mjs +394 -111
- package/engine/content-links.mjs +185 -77
- package/engine/content-lint.mjs +232 -14
- package/engine/content-package.mjs +2 -1
- package/engine/content-tables.mjs +99 -3
- package/engine/document-subtypes.mjs +49 -6
- package/engine/field-reference.mjs +1 -1
- package/engine/field-spec.mjs +56 -6
- package/engine/folder-notes.mjs +470 -0
- package/engine/foreign-catalog.mjs +195 -4
- package/engine/{manifest-emit.mjs → foundry-entries.mjs} +78 -81
- package/engine/frontmatter-lint.mjs +245 -19
- package/engine/frontmatter.mjs +88 -8
- package/engine/generate.mjs +304 -13
- package/engine/helpers.mjs +293 -64
- package/engine/ids.mjs +137 -2
- package/engine/index-records.mjs +126 -0
- package/engine/index.mjs +23 -5
- package/engine/item-compiler.mjs +349 -0
- package/engine/item-docs.mjs +7 -1
- package/engine/item-registry.mjs +6 -0
- package/engine/journals.mjs +93 -27
- package/engine/macros.mjs +4 -2
- package/engine/metadata-index.mjs +495 -0
- package/engine/note-claims.mjs +146 -25
- package/engine/note-ids.mjs +112 -0
- package/engine/note-renames.mjs +134 -0
- package/engine/note-schemas.mjs +25 -0
- package/engine/note-vocabulary.mjs +110 -27
- package/engine/pack-config.mjs +4 -0
- package/engine/pack-router.mjs +23 -0
- package/engine/retired-fields.mjs +137 -3
- package/engine/scenes.mjs +66 -34
- package/engine/schema-check.mjs +58 -17
- package/engine/site-build.mjs +71 -12
- package/engine/site-index.mjs +20 -7
- package/engine/sql-tables.mjs +485 -0
- package/engine/subtype-registry.mjs +102 -0
- package/engine/system-block.mjs +96 -12
- package/engine/systems.mjs +201 -0
- package/engine/web-wikilinks.mjs +50 -20
- package/engine/wikilink-syntax.mjs +17 -8
- package/engine/wikilinks.mjs +124 -51
- package/engine/yaml-lint.mjs +282 -0
- package/hm3/actors.mjs +352 -0
- package/hm3/default-item-art.mjs +75 -0
- package/hm3/document-subtypes.mjs +134 -0
- package/hm3/index.mjs +56 -0
- package/hm3/item-builders.mjs +84 -0
- package/hm3/item-fields.mjs +180 -0
- package/hm3/items.mjs +96 -0
- package/hm3/template-priority.mjs +85 -0
- package/labels.mjs +191 -0
- package/manifest.mjs +43 -2
- package/package.json +19 -4
- package/release.mjs +62 -7
- package/sohl/actors.mjs +33 -487
- package/sohl/being-info.mjs +16 -7
- package/sohl/default-item-art.mjs +14 -3
- package/sohl/document-subtypes.mjs +16 -10
- package/sohl/item-builders.mjs +14 -5
- package/sohl/item-fields.mjs +68 -7
- package/sohl/items.mjs +44 -258
- package/sohl/note-schemas.mjs +2 -2
- package/types/content-config.d.mts +21 -42
- package/types/engine/actor-compiler.d.mts +204 -0
- package/types/engine/address-charset.d.mts +11 -8
- package/types/engine/address-diff.d.mts +53 -5
- package/types/engine/anchored-sections.d.mts +21 -0
- package/types/engine/anchors.d.mts +20 -0
- package/types/engine/base-compiler.d.mts +17 -17
- package/types/engine/bundle-notes.d.mts +173 -0
- package/types/engine/bundles.d.mts +60 -0
- package/types/engine/code-fences.d.mts +43 -0
- package/types/engine/compile-corpus.d.mts +32 -0
- package/types/engine/content-address.d.mts +205 -5
- package/types/engine/content-format-check.d.mts +6 -2
- package/types/engine/content-format.d.mts +57 -1
- package/types/engine/content-index.d.mts +64 -80
- package/types/engine/content-links.d.mts +52 -7
- package/types/engine/content-lint.d.mts +10 -1
- package/types/engine/content-package.d.mts +2 -1
- package/types/engine/content-tables.d.mts +21 -39
- package/types/engine/document-subtypes.d.mts +37 -3
- package/types/engine/field-spec.d.mts +76 -5
- package/types/engine/folder-notes.d.mts +159 -0
- package/types/engine/foreign-catalog.d.mts +53 -0
- package/types/engine/{manifest-emit.d.mts → foundry-entries.d.mts} +16 -38
- package/types/engine/frontmatter-lint.d.mts +10 -2
- package/types/engine/frontmatter.d.mts +64 -0
- package/types/engine/generate.d.mts +38 -0
- package/types/engine/helpers.d.mts +94 -30
- package/types/engine/ids.d.mts +96 -0
- package/types/engine/index-records.d.mts +68 -0
- package/types/engine/index.d.mts +9 -3
- package/types/engine/item-compiler.d.mts +131 -0
- package/types/engine/journals.d.mts +47 -9
- package/types/engine/metadata-index.d.mts +226 -0
- package/types/engine/note-claims.d.mts +57 -11
- package/types/engine/note-ids.d.mts +38 -0
- package/types/engine/note-renames.d.mts +102 -0
- package/types/engine/note-vocabulary.d.mts +44 -8
- package/types/engine/retired-fields.d.mts +75 -0
- package/types/engine/scenes.d.mts +3 -2
- package/types/engine/schema-check.d.mts +25 -4
- package/types/engine/site-build.d.mts +4 -4
- package/types/engine/site-index.d.mts +1 -1
- package/types/engine/sql-tables.d.mts +185 -0
- package/types/engine/subtype-registry.d.mts +49 -0
- package/types/engine/system-block.d.mts +40 -1
- package/types/engine/systems.d.mts +106 -0
- package/types/engine/web-wikilinks.d.mts +4 -2
- package/types/engine/wikilink-syntax.d.mts +10 -3
- package/types/engine/wikilinks.d.mts +41 -13
- package/types/engine/yaml-lint.d.mts +107 -0
- package/types/hm3/actors.d.mts +48 -0
- package/types/hm3/default-item-art.d.mts +42 -0
- package/types/hm3/document-subtypes.d.mts +24 -0
- package/types/hm3/index.d.mts +7 -0
- package/types/hm3/item-builders.d.mts +11 -0
- package/types/hm3/item-fields.d.mts +12 -0
- package/types/hm3/items.d.mts +23 -0
- package/types/hm3/template-priority.d.mts +21 -0
- package/types/manifest.d.mts +22 -1
- package/types/release.d.mts +9 -4
- package/types/sohl/actors.d.mts +7 -74
- package/types/sohl/being-info.d.mts +8 -27
- package/types/sohl/default-item-art.d.mts +5 -3
- package/types/sohl/items.d.mts +17 -32
- package/engine/foreign-manifests.mjs +0 -126
- package/engine/kb-manifest.mjs +0 -490
- package/types/engine/foreign-manifests.d.mts +0 -43
- package/types/engine/kb-manifest.d.mts +0 -241
|
@@ -0,0 +1,495 @@
|
|
|
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
|
+
* The published content index — the artifact packages exchange addresses
|
|
16
|
+
* through (#239).
|
|
17
|
+
*
|
|
18
|
+
* **A package publishes its own index; a consumer fetches the ones it depends
|
|
19
|
+
* on.** That is the whole mechanism, and it replaces a vendored link manifest
|
|
20
|
+
* that each repository committed a copy of every other repository's file into.
|
|
21
|
+
* Vendoring failed three ways, and only the last is about staleness:
|
|
22
|
+
*
|
|
23
|
+
* 1. **A copy goes stale silently.** `Song-of-Heroic-Lands-FoundryVTT` carried
|
|
24
|
+
* 2,101 `thalorna` entries whose address was the old name-derived form, so
|
|
25
|
+
* every cross-package link it rendered pointed at a URL the site had stopped
|
|
26
|
+
* publishing. The format version gate saw nothing, because the format had
|
|
27
|
+
* not changed — only the values were wrong.
|
|
28
|
+
* 2. **Mutual vendoring deadlocks.** Both packages vendored each other, so a
|
|
29
|
+
* format bump stopped both builds until the other had already published:
|
|
30
|
+
* neither could go first.
|
|
31
|
+
* 3. **It was a second answer to a settled question.** The content index
|
|
32
|
+
* already carries the canonical key, the name, the anchors, the Foundry
|
|
33
|
+
* `uuid` and the web address. There was nothing in a manifest entry it did
|
|
34
|
+
* not hold.
|
|
35
|
+
*
|
|
36
|
+
* A fetched artifact cannot drift from its producer, and a consumer reads one
|
|
37
|
+
* the producer has already shipped — so the cycle has no way to form.
|
|
38
|
+
*
|
|
39
|
+
* @module
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import fs from "node:fs";
|
|
43
|
+
import path from "node:path";
|
|
44
|
+
|
|
45
|
+
import { formatDiagnostic, positionOfLiteral } from "./diagnostics.mjs";
|
|
46
|
+
import { PACKAGE_BASE, readCanonicalKey, resolvePackageUrl } from "./content-address.mjs";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Written once a fetch completes, so a half-finished cache is never used.
|
|
50
|
+
*
|
|
51
|
+
* The same sentinel the item catalogue uses, and deliberately the same value:
|
|
52
|
+
* the two caches sit side by side under `build/cache`, and one convention read
|
|
53
|
+
* by both is one thing to know.
|
|
54
|
+
*/
|
|
55
|
+
const STAMP = ".complete";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The relationship kinds that are dependencies, and therefore citable.
|
|
59
|
+
*
|
|
60
|
+
* A package may cite what it depends on and nothing else. `recommends` and
|
|
61
|
+
* `conflicts` are declarations *about* other packages rather than dependencies
|
|
62
|
+
* on them, so an address resolved through one would emit a link into a package
|
|
63
|
+
* the consumer does not require — a defect in the citing note, not a lookup to
|
|
64
|
+
* satisfy.
|
|
65
|
+
*
|
|
66
|
+
* @type {readonly string[]}
|
|
67
|
+
*/
|
|
68
|
+
export const METADATA_RELATIONSHIP_KINDS = Object.freeze(["systems", "requires"]);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* What a package's content index is called, wherever it is written or fetched.
|
|
72
|
+
*
|
|
73
|
+
* **The local index and the published artifact are one file.** A package emits
|
|
74
|
+
* this, ships it as a release asset, and advertises it as `flags.metadataUrl`;
|
|
75
|
+
* a consumer fetches that same file into its cache and reads it. Naming it in
|
|
76
|
+
* one function is what keeps the emitter, the release and the fetcher from
|
|
77
|
+
* drifting into three spellings of one artifact.
|
|
78
|
+
*
|
|
79
|
+
* The `-metadata` suffix earns its place: a bare `<package>.jsonl` says nothing
|
|
80
|
+
* about what it holds, and these files land in a cache directory beside other
|
|
81
|
+
* packages' artifacts where the name is all a reader has.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} pkg - The content package name.
|
|
84
|
+
* @returns {string} The file name, e.g. `sohl-metadata.jsonl`.
|
|
85
|
+
*/
|
|
86
|
+
export function metadataFileName(pkg) {
|
|
87
|
+
return `${pkg}-metadata.jsonl`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Every dependency whose published index this build resolves addresses through.
|
|
92
|
+
*
|
|
93
|
+
* **Every declared dependency, not only those supplying an item catalogue.**
|
|
94
|
+
* `itemCatalog: true` says a dependency supplies *items*; citing its
|
|
95
|
+
* *addresses* is a separate edge, and a package may have either without the
|
|
96
|
+
* other. `harn-ensemble` cites no foreign address and carries 324,016 embedded
|
|
97
|
+
* item references (`HeroicLands/harn-ensemble#42`); a package citing addresses
|
|
98
|
+
* and needing no items is the mirror of it. Gating the index on the catalogue
|
|
99
|
+
* flag would serve neither.
|
|
100
|
+
*
|
|
101
|
+
* The declaration is the one already in the emitted `system.json` /
|
|
102
|
+
* `module.json`, so it cannot drift from what Foundry itself installs, and
|
|
103
|
+
* there is no new configuration key to keep in step. Each entry carries the
|
|
104
|
+
* producer's own `manifest` URL, so the fetcher needs no address of its own:
|
|
105
|
+
* it reads that manifest and takes the `flags.metadataUrl` it advertises.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} config - The resolved build configuration.
|
|
108
|
+
* @returns {Array<{id: string, manifest: string, kind: string,
|
|
109
|
+
* verified: string|undefined}>} The dependencies, in declaration order.
|
|
110
|
+
*/
|
|
111
|
+
export function metadataRelationships(config) {
|
|
112
|
+
const out = [];
|
|
113
|
+
for (const kind of METADATA_RELATIONSHIP_KINDS) {
|
|
114
|
+
for (const rel of config?.relationships?.[kind] ?? []) {
|
|
115
|
+
out.push({
|
|
116
|
+
id: rel.id,
|
|
117
|
+
manifest: rel.manifest,
|
|
118
|
+
kind,
|
|
119
|
+
verified: rel.compatibility?.verified,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The cache directory for one dependency's index at one version.
|
|
128
|
+
*
|
|
129
|
+
* Keyed by version so that changing the pinned version is a different cache
|
|
130
|
+
* rather than a silent overwrite, and so a second build costs nothing — the
|
|
131
|
+
* same rule the item catalogue's cache follows, for the same reason.
|
|
132
|
+
*
|
|
133
|
+
* @param {object} config - The resolved build configuration.
|
|
134
|
+
* @param {string} id - The dependency's package id.
|
|
135
|
+
* @param {string} version - Its resolved version.
|
|
136
|
+
* @returns {string} The directory.
|
|
137
|
+
*/
|
|
138
|
+
export function metadataCacheDir(config, id, version) {
|
|
139
|
+
return path.join(config.paths.metadataCache, `${id}@${version}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Whether a dependency's cache is present and complete.
|
|
144
|
+
*
|
|
145
|
+
* @param {string} dir - The dependency's cache directory.
|
|
146
|
+
* @returns {boolean} True when it was fetched to completion.
|
|
147
|
+
*/
|
|
148
|
+
export const isComplete = (dir) => fs.existsSync(path.join(dir, STAMP));
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Mark a dependency's cache complete.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} dir - The dependency's cache directory.
|
|
154
|
+
* @returns {void}
|
|
155
|
+
*/
|
|
156
|
+
export function markComplete(dir) {
|
|
157
|
+
fs.writeFileSync(path.join(dir, STAMP), "");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The fetched index files this build resolves foreign addresses against.
|
|
162
|
+
*
|
|
163
|
+
* **Reads the cache only.** A cold cache is an error naming the command that
|
|
164
|
+
* fills it, rather than a download nobody asked for: a compile that reaches the
|
|
165
|
+
* network is not reproducible and fails strangely offline. That is the item
|
|
166
|
+
* catalogue's rule, and it holds here for the same reason.
|
|
167
|
+
*
|
|
168
|
+
* A half-finished fetch counts as cold. A partial index resolves some addresses
|
|
169
|
+
* and fails others with nothing to distinguish the two, which is worse than
|
|
170
|
+
* resolving none — the failure would read as a typo in whichever note happened
|
|
171
|
+
* to cite the missing half.
|
|
172
|
+
*
|
|
173
|
+
* @param {object} config - The resolved build configuration.
|
|
174
|
+
* @returns {string[]} One index file per declared dependency.
|
|
175
|
+
* @throws {Error} When a declared dependency has not been fetched.
|
|
176
|
+
*/
|
|
177
|
+
export function cachedMetadataFiles(config) {
|
|
178
|
+
return cachedMetadataIndexes(config).map((entry) => entry.file);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The same fetched indexes, each paired with the package that published it.
|
|
183
|
+
*
|
|
184
|
+
* The id is what a SQL content table addresses a dependency's notes by
|
|
185
|
+
* (`FROM sohl.notes`, #246), so the pairing has to survive the lookup —
|
|
186
|
+
* {@link cachedMetadataFiles} drops it, and a caller reconstructing the id from
|
|
187
|
+
* the file name would be parsing a path to recover something the declaration
|
|
188
|
+
* already stated.
|
|
189
|
+
*
|
|
190
|
+
* @param {object} config - The resolved build configuration.
|
|
191
|
+
* @returns {Array<{id: string, file: string}>} One entry per declared
|
|
192
|
+
* dependency.
|
|
193
|
+
* @throws {Error} When a declared dependency has not been fetched.
|
|
194
|
+
*/
|
|
195
|
+
export function cachedMetadataIndexes(config) {
|
|
196
|
+
const files = [];
|
|
197
|
+
const root = config.paths.metadataCache;
|
|
198
|
+
for (const rel of metadataRelationships(config)) {
|
|
199
|
+
const cached =
|
|
200
|
+
fs.existsSync(root) ?
|
|
201
|
+
fs
|
|
202
|
+
.readdirSync(root)
|
|
203
|
+
.filter((name) => name.startsWith(`${rel.id}@`))
|
|
204
|
+
.map((name) => path.join(root, name))
|
|
205
|
+
.filter(isComplete)
|
|
206
|
+
: [];
|
|
207
|
+
if (!cached.length) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`${rel.id} is a declared dependency but its content index has ` +
|
|
210
|
+
`not been fetched. Run \`content-build deps fetch\` first.`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
files.push({ id: rel.id, file: newestIndex(cached) });
|
|
214
|
+
}
|
|
215
|
+
return files;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The newest cached version among several version-keyed cache directories.
|
|
220
|
+
*
|
|
221
|
+
* Versions are compared **numerically per segment**, not as strings: a plain
|
|
222
|
+
* sort puts `0.8.10` before `0.8.2`, so a build that had cached both would
|
|
223
|
+
* silently resolve against the older one. A fetch always writes the currently
|
|
224
|
+
* declared version, so several present at once means an earlier pin was left
|
|
225
|
+
* behind rather than that a choice is genuinely open.
|
|
226
|
+
*
|
|
227
|
+
* **Both version-keyed caches under `build/cache` choose this way** — the
|
|
228
|
+
* content index here and the item catalogue in
|
|
229
|
+
* {@link module:engine/foreign-catalog} — so the comparison lives in one place
|
|
230
|
+
* rather than being written once per cache. Two copies would be two chances to
|
|
231
|
+
* get it wrong, and the wrong answer is invisible: every cached version is a
|
|
232
|
+
* complete, stamped, perfectly valid artifact, so picking the older one reports
|
|
233
|
+
* nothing and simply resolves against stale data (#272).
|
|
234
|
+
*
|
|
235
|
+
* @param {string[]} dirs - Complete cache directories, named `<id>@<version>`.
|
|
236
|
+
* @returns {string} The newest one.
|
|
237
|
+
*/
|
|
238
|
+
export function newestVersionDir(dirs) {
|
|
239
|
+
const rank = (dir) =>
|
|
240
|
+
path
|
|
241
|
+
.basename(dir)
|
|
242
|
+
.split("@")
|
|
243
|
+
.slice(1)
|
|
244
|
+
.join("@")
|
|
245
|
+
.split(/[.-]/)
|
|
246
|
+
.map((part) => (/^\d+$/.test(part) ? Number(part) : part));
|
|
247
|
+
const sorted = [...dirs].sort((a, b) => {
|
|
248
|
+
const x = rank(a);
|
|
249
|
+
const y = rank(b);
|
|
250
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
251
|
+
const p = x[i];
|
|
252
|
+
const q = y[i];
|
|
253
|
+
if (p === q) continue;
|
|
254
|
+
if (p === undefined) return -1;
|
|
255
|
+
if (q === undefined) return 1;
|
|
256
|
+
return typeof p === "number" && typeof q === "number" ?
|
|
257
|
+
p - q
|
|
258
|
+
: String(p).localeCompare(String(q));
|
|
259
|
+
}
|
|
260
|
+
return 0;
|
|
261
|
+
});
|
|
262
|
+
return sorted[sorted.length - 1];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The index file of the newest cached version among several.
|
|
267
|
+
*
|
|
268
|
+
* @param {string[]} dirs - Complete cache directories, named `<id>@<version>`.
|
|
269
|
+
* @returns {string} The newest one's index file.
|
|
270
|
+
*/
|
|
271
|
+
function newestIndex(dirs) {
|
|
272
|
+
const dir = newestVersionDir(dirs);
|
|
273
|
+
const entries = fs.readdirSync(dir).filter((name) => name.endsWith(".jsonl"));
|
|
274
|
+
if (!entries.length) {
|
|
275
|
+
throw new Error(`${dir} was fetched but holds no index file`);
|
|
276
|
+
}
|
|
277
|
+
return path.join(dir, entries[0]);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Resolve every foreign address this build can cite, from the fetched indexes.
|
|
282
|
+
*
|
|
283
|
+
* The replacement for the vendored link manifest, and deliberately the same
|
|
284
|
+
* return shape — a `Map` from canonical address to `{ url, name, uuid, … }` —
|
|
285
|
+
* so a foreign entry and a local one stay interchangeable at the point of use.
|
|
286
|
+
* What changed is where the data comes from: a file the producer published,
|
|
287
|
+
* not a copy a consumer committed.
|
|
288
|
+
*
|
|
289
|
+
* **A package this build publishes is skipped**, however it got into the cache.
|
|
290
|
+
* A build is authoritative in its own addresses, and reading them back from a
|
|
291
|
+
* fetched artifact would let a stale copy overrule the tree that is being
|
|
292
|
+
* compiled right now. It is also what stops a cycle forming: the mutual
|
|
293
|
+
* vendoring this replaces deadlocked because each package had to read the
|
|
294
|
+
* other's file before it could publish its own.
|
|
295
|
+
*
|
|
296
|
+
* **Nothing here can be stale.** The version gate the manifest needed existed
|
|
297
|
+
* because a vendored copy could sit at any age; a fetched index is pinned to
|
|
298
|
+
* the version the relationship declares, so `stale` now reports only what is
|
|
299
|
+
* genuinely unusable — an unreadable file, or a package with pages and no base
|
|
300
|
+
* to serve them from.
|
|
301
|
+
*
|
|
302
|
+
* @param {object} config - The resolved build configuration.
|
|
303
|
+
* @param {Iterable<string>} localPackages - Packages this build publishes.
|
|
304
|
+
* @param {Record<string, string>} [bases] - Where each package is served.
|
|
305
|
+
* @returns {{index: Map<string, object>, packages: Set<string>,
|
|
306
|
+
* stale: Array<{package: string, reason: string}>}} The resolved addresses,
|
|
307
|
+
* which packages contributed, and what could not be read.
|
|
308
|
+
*/
|
|
309
|
+
export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE) {
|
|
310
|
+
const local = new Set(localPackages);
|
|
311
|
+
const index = new Map();
|
|
312
|
+
const packages = new Set();
|
|
313
|
+
const stale = [];
|
|
314
|
+
|
|
315
|
+
for (const file of cachedMetadataFiles(config)) {
|
|
316
|
+
let records;
|
|
317
|
+
try {
|
|
318
|
+
records = fs
|
|
319
|
+
.readFileSync(file, "utf8")
|
|
320
|
+
.split("\n")
|
|
321
|
+
.filter((line) => line.trim())
|
|
322
|
+
.map((line) => JSON.parse(line));
|
|
323
|
+
} catch (err) {
|
|
324
|
+
stale.push({ package: packageOfCache(file), reason: `unreadable: ${err.message}` });
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const pkg = records[0]?.package ?? packageOfCache(file);
|
|
329
|
+
if (local.has(pkg)) continue;
|
|
330
|
+
|
|
331
|
+
// A base is only needed to resolve a page *URL*, so a pack-only
|
|
332
|
+
// dependency — Foundry addresses and no site, which `kethira` is by
|
|
333
|
+
// licensing rather than by accident — needs none. Demanding one would
|
|
334
|
+
// make its documents uncitable from anywhere, which is a worse answer
|
|
335
|
+
// than citing them by UUID and rendering the prose unlinked.
|
|
336
|
+
//
|
|
337
|
+
// So an absent base degrades rather than fails: every entry keeps its
|
|
338
|
+
// `uuid` and simply has no `url`, exactly as a consumer must already
|
|
339
|
+
// tolerate for an entry that compiles into no document.
|
|
340
|
+
const base = bases?.[pkg];
|
|
341
|
+
const web = typeof base === "string" && base.length > 0;
|
|
342
|
+
|
|
343
|
+
for (const record of records) {
|
|
344
|
+
const key = record?.address?.canonical;
|
|
345
|
+
if (!key) continue;
|
|
346
|
+
const parts = readCanonicalKey(key);
|
|
347
|
+
if (!parts) continue;
|
|
348
|
+
// First writer wins, so two packages claiming one address cannot
|
|
349
|
+
// make the build depend on the order the cache was read in.
|
|
350
|
+
if (index.has(key)) continue;
|
|
351
|
+
const foundry = record.foundry?.[parts.system];
|
|
352
|
+
index.set(key, {
|
|
353
|
+
name: record.name?.full ?? record.name,
|
|
354
|
+
// Absent where the package publishes no page for it. A consumer
|
|
355
|
+
// must tolerate that rather than invent an href, exactly as it
|
|
356
|
+
// already tolerates an entry with no `uuid`.
|
|
357
|
+
url:
|
|
358
|
+
web && record.address.slug ?
|
|
359
|
+
resolvePackageUrl(`${record.address.slug}/`, base)
|
|
360
|
+
: undefined,
|
|
361
|
+
uuid: foundry?.uuid,
|
|
362
|
+
doc: record.documentation ?? undefined,
|
|
363
|
+
anchors: foundry?.anchors,
|
|
364
|
+
type: parts.type,
|
|
365
|
+
package: pkg,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
packages.add(pkg);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return { index, packages, stale };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Which package a cached index belongs to, read from its directory name.
|
|
376
|
+
*
|
|
377
|
+
* Used only to name a package in a diagnostic when its records could not be
|
|
378
|
+
* read — the authoritative answer is the `package` field the records carry.
|
|
379
|
+
*
|
|
380
|
+
* @param {string} file - The cached index file.
|
|
381
|
+
* @returns {string} The dependency id.
|
|
382
|
+
*/
|
|
383
|
+
function packageOfCache(file) {
|
|
384
|
+
return path.basename(path.dirname(file)).split("@")[0];
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Where a dependency's fetched index sits, for naming it in a diagnostic.
|
|
389
|
+
*
|
|
390
|
+
* Best effort: the newest complete cache for that package, or the directory it
|
|
391
|
+
* would occupy. A finding has to name *a* file even when the cache is in the
|
|
392
|
+
* state the finding is about.
|
|
393
|
+
*
|
|
394
|
+
* @param {object} config - The resolved build configuration.
|
|
395
|
+
* @param {string} pkg - The dependency's package id.
|
|
396
|
+
* @returns {string} A path to name in a diagnostic.
|
|
397
|
+
*/
|
|
398
|
+
export function cachedIndexPath(config, pkg) {
|
|
399
|
+
const root = config?.paths?.metadataCache ?? "build/cache/metadata";
|
|
400
|
+
try {
|
|
401
|
+
const dirs = fs
|
|
402
|
+
.readdirSync(root)
|
|
403
|
+
.filter((name) => name.startsWith(`${pkg}@`))
|
|
404
|
+
.map((name) => path.join(root, name))
|
|
405
|
+
.filter(isComplete);
|
|
406
|
+
if (dirs.length) {
|
|
407
|
+
const dir = dirs.sort()[dirs.length - 1];
|
|
408
|
+
const file = fs.readdirSync(dir).find((n) => n.endsWith(".jsonl"));
|
|
409
|
+
if (file) return path.join(dir, file);
|
|
410
|
+
}
|
|
411
|
+
} catch {
|
|
412
|
+
// The cache is missing or unreadable, which is often the very thing
|
|
413
|
+
// being reported. The conventional path is all that can be named.
|
|
414
|
+
}
|
|
415
|
+
return path.join(root, `${pkg}@<version>`, metadataFileName(pkg));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Whether a fetched index can still be *addressed*, as distinct from read.
|
|
420
|
+
*
|
|
421
|
+
* A consumer resolves cross-package links by canonical key, so it needs both
|
|
422
|
+
* sides to agree on the key's shape. When they drift the lookup cannot match on
|
|
423
|
+
* *any* input — and because a miss is indistinguishable from a typo, the
|
|
424
|
+
* symptom is a pile of dead addresses blamed on the notes that cite them rather
|
|
425
|
+
* than on the index at fault. A package whose every key is unreadable is
|
|
426
|
+
* therefore reported against the index, once, instead of once per citing note.
|
|
427
|
+
*
|
|
428
|
+
* The realistic cause is a version skew: a dependency released before the
|
|
429
|
+
* address grammar gained its `<system>` segment (#59) ships three-segment keys.
|
|
430
|
+
* Re-fetching after that dependency releases is the fix.
|
|
431
|
+
*
|
|
432
|
+
* @param {Map<string, object>} foreignIndex - The resolved foreign index.
|
|
433
|
+
* @returns {Array<{package: string, entries: number, sampleKey: string}>} One
|
|
434
|
+
* finding per drifted package, in the order the index first names each.
|
|
435
|
+
*/
|
|
436
|
+
export function unaddressableForeignPackages(foreignIndex) {
|
|
437
|
+
const byPackage = new Map();
|
|
438
|
+
for (const [key, value] of foreignIndex ?? new Map()) {
|
|
439
|
+
// The package is read from the entry rather than the key, since the key
|
|
440
|
+
// is the very thing under suspicion — deriving it from a shape that may
|
|
441
|
+
// not parse would report the finding against `undefined`.
|
|
442
|
+
const pkg = value?.package;
|
|
443
|
+
if (!pkg) continue;
|
|
444
|
+
const seen = byPackage.get(pkg) ?? { entries: 0, readable: 0, sampleKey: key };
|
|
445
|
+
seen.entries += 1;
|
|
446
|
+
if (readCanonicalKey(key)) seen.readable += 1;
|
|
447
|
+
byPackage.set(pkg, seen);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const findings = [];
|
|
451
|
+
for (const [pkg, seen] of byPackage) {
|
|
452
|
+
if (seen.entries > 0 && seen.readable === 0) {
|
|
453
|
+
findings.push({ package: pkg, entries: seen.entries, sampleKey: seen.sampleKey });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return findings;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* One finding, in the standard `file:line:column: severity: message` form.
|
|
461
|
+
*
|
|
462
|
+
* The position is recovered by locating the offending key in the index text:
|
|
463
|
+
* the finding is about a literal the reader can see in the file, so its
|
|
464
|
+
* position is implicit rather than absent. When the file cannot be read, or the
|
|
465
|
+
* key is not in it, the locator degrades to the file alone — a dropped field,
|
|
466
|
+
* never a guessed `1:1` that would send the reader to the top of a large file
|
|
467
|
+
* for a finding that is not there.
|
|
468
|
+
*
|
|
469
|
+
* @param {{package: string, entries: number, sampleKey: string}} finding - One
|
|
470
|
+
* finding from {@link unaddressableForeignPackages}.
|
|
471
|
+
* @param {object} config - The resolved build configuration.
|
|
472
|
+
* @returns {string} The formatted diagnostic, path first on the line.
|
|
473
|
+
*/
|
|
474
|
+
export function formatUnaddressableFinding(finding, config) {
|
|
475
|
+
const file = cachedIndexPath(config, finding.package);
|
|
476
|
+
let at = {};
|
|
477
|
+
try {
|
|
478
|
+
at = positionOfLiteral(fs.readFileSync(file, "utf8"), `"${finding.sampleKey}"`);
|
|
479
|
+
} catch {
|
|
480
|
+
// Unreadable here is not itself the finding — the loader already
|
|
481
|
+
// reports that. The file is simply all that is known about where this
|
|
482
|
+
// one is.
|
|
483
|
+
}
|
|
484
|
+
return formatDiagnostic({
|
|
485
|
+
file,
|
|
486
|
+
...at,
|
|
487
|
+
severity: "error",
|
|
488
|
+
message:
|
|
489
|
+
"no key in this content index is a canonical " +
|
|
490
|
+
`\`package-system-type-shortcode\` address (${finding.entries} ` +
|
|
491
|
+
`${finding.entries === 1 ? "entry" : "entries"}, none addressable; ` +
|
|
492
|
+
`first is \`${finding.sampleKey}\`) — every cross-package link to ` +
|
|
493
|
+
`${finding.package} would resolve to nothing, silently`,
|
|
494
|
+
});
|
|
495
|
+
}
|