@vutex/vutex 0.0.14 → 0.0.16
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/licenses/NotoSansMono.OFL.md +93 -0
- package/licenses/NotoSerifSC.OFL.md +94 -0
- package/licenses/Tinos.OFL.md +93 -0
- package/locales/en-US/cli.json +5 -4
- package/package.json +8 -7
- package/readme.md +29 -3
- package/src/cli/commands/package.ts +41 -12
- package/src/cli/commands/render.ts +5 -1
- package/src/index.ts +3 -0
- package/src/node/load.ts +18 -22
- package/src/packages/builtin.ts +129 -0
- package/src/packages/lockfile.ts +33 -1
- package/src/packages/manager.ts +1125 -64
- package/src/packages/manifest.ts +45 -0
- package/src/packages/math_font.ts +63 -0
- package/src/packages/registry.ts +405 -0
- package/src/packages/template.ts +352 -0
- package/src/runtime.ts +387 -37
- package/src/types.ts +167 -15
package/src/packages/manifest.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { join } from "node:path";
|
|
|
3
3
|
|
|
4
4
|
import TOML from "@iarna/toml";
|
|
5
5
|
|
|
6
|
+
import type { PackageSlot } from "../types.js";
|
|
7
|
+
|
|
6
8
|
export type VuTeXManifest = {
|
|
7
9
|
document?: {
|
|
8
10
|
input?: string;
|
|
@@ -11,9 +13,52 @@ export type VuTeXManifest = {
|
|
|
11
13
|
profile?: {
|
|
12
14
|
name?: string;
|
|
13
15
|
};
|
|
16
|
+
registry?: {
|
|
17
|
+
url?: string;
|
|
18
|
+
};
|
|
14
19
|
packages?: Record<string, string>;
|
|
15
20
|
};
|
|
16
21
|
|
|
22
|
+
/** Slots are the keys of `[packages]`, the values are package names. */
|
|
23
|
+
export const PACKAGE_SLOTS: PackageSlot[] = [
|
|
24
|
+
"font",
|
|
25
|
+
"math-font",
|
|
26
|
+
"template",
|
|
27
|
+
"theme",
|
|
28
|
+
"profile",
|
|
29
|
+
"macros",
|
|
30
|
+
"resources",
|
|
31
|
+
"fixture",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export function isPackageSlot(value: string): value is PackageSlot {
|
|
35
|
+
return (PACKAGE_SLOTS as string[]).includes(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const SLOT_PREFIXES: Array<[string[], PackageSlot]> = [
|
|
39
|
+
[["math-font-"], "math-font"],
|
|
40
|
+
[["font-"], "font"],
|
|
41
|
+
[["template-"], "template"],
|
|
42
|
+
[["theme-"], "theme"],
|
|
43
|
+
[["profile-"], "profile"],
|
|
44
|
+
[["macros-", "macro-"], "macros"],
|
|
45
|
+
[["resources-", "resource-"], "resources"],
|
|
46
|
+
[["fixture-"], "fixture"],
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/** Infer the slot from a package name such as `@vutex/font-libertinus`. */
|
|
50
|
+
export function inferSlot(packageName: string): PackageSlot | null {
|
|
51
|
+
const bare = packageName.startsWith("@vutex/")
|
|
52
|
+
? packageName.slice("@vutex/".length)
|
|
53
|
+
: packageName;
|
|
54
|
+
for (const [prefixes, slot] of SLOT_PREFIXES) {
|
|
55
|
+
if (prefixes.some((prefix) => bare.startsWith(prefix))) {
|
|
56
|
+
return slot;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
17
62
|
export async function readManifest(projectRoot: string): Promise<VuTeXManifest | null> {
|
|
18
63
|
const path = join(projectRoot, "vutex.toml");
|
|
19
64
|
try {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { VuTeXDiagnostic } from "../types.js";
|
|
5
|
+
|
|
6
|
+
function invalid(message: string, details: Record<string, unknown>): VuTeXDiagnostic {
|
|
7
|
+
return {
|
|
8
|
+
code: "VUTEX_PACKAGE_MATH_FONT_INVALID",
|
|
9
|
+
severity: "error",
|
|
10
|
+
message,
|
|
11
|
+
stage: "package",
|
|
12
|
+
args: details,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Locate the OpenType MATH face a math-font package declares in `math-font.json`.
|
|
18
|
+
*
|
|
19
|
+
* The file names one font inside the package root and may name the TeX
|
|
20
|
+
* parameter family the face's metrics follow. Whether the bytes actually carry
|
|
21
|
+
* a parsable `MATH` table is the native registry's decision, and whether the
|
|
22
|
+
* parameter family is known is the native list's decision; this reader only
|
|
23
|
+
* resolves the paths and refuses to leave the package.
|
|
24
|
+
*/
|
|
25
|
+
export async function readMathFontFace(
|
|
26
|
+
directory: string,
|
|
27
|
+
): Promise<{ file?: string; texParameters?: string; diagnostic?: VuTeXDiagnostic }> {
|
|
28
|
+
const record = join(directory, "math-font.json");
|
|
29
|
+
let parsed: unknown;
|
|
30
|
+
try {
|
|
31
|
+
parsed = JSON.parse(await readFile(record, "utf8"));
|
|
32
|
+
} catch (error) {
|
|
33
|
+
return {
|
|
34
|
+
diagnostic: invalid(`math font package at ${directory} has no readable math-font.json`, {
|
|
35
|
+
directory,
|
|
36
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (parsed === null || typeof parsed !== "object") {
|
|
41
|
+
return { diagnostic: invalid(`math-font.json at ${directory} is not an object`, { directory }) };
|
|
42
|
+
}
|
|
43
|
+
const declared = (parsed as { file?: unknown }).file;
|
|
44
|
+
if (typeof declared !== "string" || declared.trim().length === 0) {
|
|
45
|
+
return { diagnostic: invalid(`math-font.json at ${directory} names no font file`, { directory }) };
|
|
46
|
+
}
|
|
47
|
+
const texParameters = (parsed as { texParameters?: unknown }).texParameters;
|
|
48
|
+
if (texParameters !== undefined && (typeof texParameters !== "string" || texParameters.trim().length === 0)) {
|
|
49
|
+
return { diagnostic: invalid(`math-font.json at ${directory} declares an empty TeX parameter family`, { directory }) };
|
|
50
|
+
}
|
|
51
|
+
const root = resolve(directory);
|
|
52
|
+
const file = resolve(root, declared);
|
|
53
|
+
const inside = relative(root, file);
|
|
54
|
+
if (isAbsolute(declared) || inside.startsWith("..") || isAbsolute(inside)) {
|
|
55
|
+
return {
|
|
56
|
+
diagnostic: invalid(`math-font.json at ${directory} points outside the package`, {
|
|
57
|
+
directory,
|
|
58
|
+
file: declared,
|
|
59
|
+
}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return { file, texParameters: texParameters === undefined ? undefined : texParameters.trim() };
|
|
63
|
+
}
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
|
|
4
|
+
import type { VuTeXDiagnostic } from "../types.js";
|
|
5
|
+
|
|
6
|
+
/** Numeric part of a version triple. Prerelease tags order by presence only. */
|
|
7
|
+
export type ParsedVersion = { major: number; minor: number; patch: number };
|
|
8
|
+
|
|
9
|
+
export type VersionRange = {
|
|
10
|
+
operator: "^" | "~" | ">=" | ">" | "<=" | "<" | "=" | "*";
|
|
11
|
+
version?: ParsedVersion;
|
|
12
|
+
raw: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** One downloadable file inside a registry package version. */
|
|
16
|
+
export type RegistryFile = {
|
|
17
|
+
/** Path relative to the package root in the local cache. */
|
|
18
|
+
path: string;
|
|
19
|
+
/** Absolute or metadata-relative URL. */
|
|
20
|
+
url: string;
|
|
21
|
+
/** Optional SHA-256 of the bytes, verified after download. */
|
|
22
|
+
sha256?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** A package another package version depends on, addressed by slot. */
|
|
26
|
+
export type RegistryDependency = {
|
|
27
|
+
slot: string;
|
|
28
|
+
name: string;
|
|
29
|
+
/** Version range, `*` when omitted. */
|
|
30
|
+
range?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type RegistryVersion = {
|
|
34
|
+
version: string;
|
|
35
|
+
files: RegistryFile[];
|
|
36
|
+
dependencies: RegistryDependency[];
|
|
37
|
+
/** Optional SHA-256 of the whole extracted tree. */
|
|
38
|
+
artifactHash?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type RegistryMetadata = {
|
|
42
|
+
slot: string;
|
|
43
|
+
name: string;
|
|
44
|
+
versions: RegistryVersion[];
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type RegistrySpec = { name: string; range: string };
|
|
48
|
+
|
|
49
|
+
/** One registry response: status plus the raw body. */
|
|
50
|
+
export type RegistryResponse = { status: number; body: Uint8Array };
|
|
51
|
+
|
|
52
|
+
/** Registry network client, injectable so tests never leave the process. */
|
|
53
|
+
export type RegistryTransport = (url: string) => Promise<RegistryResponse>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `node:http` driven transport with a fresh connection per request.
|
|
57
|
+
*
|
|
58
|
+
* Global `fetch` keeps a dispatcher alive on Windows, which outlives the
|
|
59
|
+
* command and races the process teardown. One short-lived agent per request
|
|
60
|
+
* leaves nothing behind and keeps registry commands deterministic.
|
|
61
|
+
*/
|
|
62
|
+
export function nodeTransport(url: string): Promise<RegistryResponse> {
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const target = new URL(url);
|
|
65
|
+
const client = target.protocol === "https:" ? https : http;
|
|
66
|
+
const request = client.get(target, { agent: false }, (response) => {
|
|
67
|
+
const chunks: Uint8Array[] = [];
|
|
68
|
+
response.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
69
|
+
response.on("end", () => {
|
|
70
|
+
resolve({ status: response.statusCode ?? 0, body: new Uint8Array(Buffer.concat(chunks)) });
|
|
71
|
+
});
|
|
72
|
+
response.on("error", reject);
|
|
73
|
+
});
|
|
74
|
+
request.on("error", reject);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Splitting `name@^1.2.0` from the last `@`, so scoped names stay intact. */
|
|
79
|
+
export function parseRegistrySpec(value: string): RegistrySpec {
|
|
80
|
+
const at = value.lastIndexOf("@");
|
|
81
|
+
if (at > 0) {
|
|
82
|
+
const range = value.slice(at + 1).trim();
|
|
83
|
+
return { name: value.slice(0, at), range: range.length > 0 ? range : "*" };
|
|
84
|
+
}
|
|
85
|
+
return { name: value, range: "*" };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function parseVersion(raw: string): ParsedVersion | undefined {
|
|
89
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.-]+)?$/u.exec(raw.trim());
|
|
90
|
+
if (!match) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function compareVersions(left: ParsedVersion, right: ParsedVersion): number {
|
|
97
|
+
return left.major - right.major || left.minor - right.minor || left.patch - right.patch;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function parseRange(raw: string): VersionRange | undefined {
|
|
101
|
+
const value = raw.trim();
|
|
102
|
+
if (value === "*" || value === "latest" || value.length === 0) {
|
|
103
|
+
return { operator: "*", raw: "*" };
|
|
104
|
+
}
|
|
105
|
+
const match = /^(\^|~|>=|<=|>|<|=)?\s*(v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)$/u.exec(value);
|
|
106
|
+
if (!match) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
const version = parseVersion(match[2]);
|
|
110
|
+
if (!version) {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
return { operator: (match[1] ?? "=") as VersionRange["operator"], version, raw: value };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** npm-style satisfaction for `*`, `^`, `~`, comparison and exact ranges. */
|
|
117
|
+
export function satisfies(version: ParsedVersion, range: VersionRange): boolean {
|
|
118
|
+
if (range.operator === "*") {
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
const base = range.version;
|
|
122
|
+
if (!base) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
const order = compareVersions(version, base);
|
|
126
|
+
switch (range.operator) {
|
|
127
|
+
case "=":
|
|
128
|
+
return order === 0;
|
|
129
|
+
case ">=":
|
|
130
|
+
return order >= 0;
|
|
131
|
+
case ">":
|
|
132
|
+
return order > 0;
|
|
133
|
+
case "<=":
|
|
134
|
+
return order <= 0;
|
|
135
|
+
case "<":
|
|
136
|
+
return order < 0;
|
|
137
|
+
case "~":
|
|
138
|
+
return order >= 0 && version.major === base.major && version.minor === base.minor;
|
|
139
|
+
case "^":
|
|
140
|
+
if (order < 0) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
if (base.major > 0) {
|
|
144
|
+
return version.major === base.major;
|
|
145
|
+
}
|
|
146
|
+
if (base.minor > 0) {
|
|
147
|
+
return version.major === base.major && version.minor === base.minor;
|
|
148
|
+
}
|
|
149
|
+
return compareVersions(version, base) === 0;
|
|
150
|
+
default:
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Check a plain version string against one range string. */
|
|
156
|
+
export function satisfiesRange(version: string, range: string): boolean {
|
|
157
|
+
const parsedVersion = parseVersion(version);
|
|
158
|
+
const parsedRange = parseRange(range);
|
|
159
|
+
return parsedVersion !== undefined && parsedRange !== undefined && satisfies(parsedVersion, parsedRange);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Highest version satisfying every declared range. */
|
|
163
|
+
export function selectVersion(
|
|
164
|
+
metadata: RegistryMetadata,
|
|
165
|
+
ranges: string[],
|
|
166
|
+
): { version?: RegistryVersion; versionRange?: VersionRange; diagnostic?: VuTeXDiagnostic } {
|
|
167
|
+
for (const raw of ranges) {
|
|
168
|
+
if (!parseRange(raw)) {
|
|
169
|
+
return {
|
|
170
|
+
diagnostic: {
|
|
171
|
+
code: "VUTEX_PACKAGE_VERSION_RANGE_INVALID",
|
|
172
|
+
severity: "error",
|
|
173
|
+
message: `invalid version range for ${metadata.name}: ${raw}`,
|
|
174
|
+
stage: "package",
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const parsedRanges = ranges.map((raw) => parseRange(raw) as VersionRange);
|
|
180
|
+
const candidates = [...metadata.versions]
|
|
181
|
+
.map((item) => ({ item, version: parseVersion(item.version) }))
|
|
182
|
+
.filter((entry): entry is { item: RegistryVersion; version: ParsedVersion } => entry.version !== undefined)
|
|
183
|
+
.filter((entry) => parsedRanges.every((range) => satisfies(entry.version, range)))
|
|
184
|
+
.sort((left, right) => compareVersions(right.version, left.version));
|
|
185
|
+
if (candidates.length === 0) {
|
|
186
|
+
return {
|
|
187
|
+
diagnostic: {
|
|
188
|
+
code: "VUTEX_PACKAGE_VERSION_NO_MATCH",
|
|
189
|
+
severity: "error",
|
|
190
|
+
message: `no version of ${metadata.name} satisfies ${ranges.join(", ")}`,
|
|
191
|
+
stage: "package",
|
|
192
|
+
args: { ranges },
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return { version: candidates[0].item };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function registryDiagnostic(code: string, message: string, args?: Record<string, unknown>): VuTeXDiagnostic {
|
|
200
|
+
return { code, severity: "error", message, stage: "package", args };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function encodeSegment(value: string): string {
|
|
204
|
+
return encodeURIComponent(value);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Metadata URL for one package: `<base>/<slot>/<name>.json`. */
|
|
208
|
+
export function metadataUrl(base: string, slot: string, name: string): string {
|
|
209
|
+
const trimmed = base.replace(/\/+$/u, "");
|
|
210
|
+
const segments = name.split("/").map(encodeSegment).join("/");
|
|
211
|
+
return `${trimmed}/${encodeSegment(slot)}/${segments}.json`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function parseFiles(raw: unknown): RegistryFile[] | undefined {
|
|
215
|
+
if (!Array.isArray(raw)) {
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
const files: RegistryFile[] = [];
|
|
219
|
+
for (const entry of raw) {
|
|
220
|
+
if (typeof entry !== "object" || entry === null) {
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
const record = entry as Record<string, unknown>;
|
|
224
|
+
if (typeof record.path !== "string" || typeof record.url !== "string") {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
files.push({
|
|
228
|
+
path: record.path,
|
|
229
|
+
url: record.url,
|
|
230
|
+
...(typeof record.sha256 === "string" ? { sha256: record.sha256 } : {}),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return files;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function parseDependencies(raw: unknown): RegistryDependency[] | undefined {
|
|
237
|
+
if (raw === undefined) {
|
|
238
|
+
return [];
|
|
239
|
+
}
|
|
240
|
+
if (!Array.isArray(raw)) {
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
const dependencies: RegistryDependency[] = [];
|
|
244
|
+
for (const entry of raw) {
|
|
245
|
+
if (typeof entry !== "object" || entry === null) {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
const record = entry as Record<string, unknown>;
|
|
249
|
+
if (typeof record.slot !== "string" || typeof record.name !== "string") {
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
dependencies.push({
|
|
253
|
+
slot: record.slot,
|
|
254
|
+
name: record.name,
|
|
255
|
+
...(typeof record.range === "string" ? { range: record.range } : {}),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return dependencies;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Validate one metadata document. Unknown fields are ignored on purpose. */
|
|
262
|
+
export function parseRegistryMetadata(
|
|
263
|
+
slot: string,
|
|
264
|
+
name: string,
|
|
265
|
+
raw: unknown,
|
|
266
|
+
): { metadata?: RegistryMetadata; diagnostic?: VuTeXDiagnostic } {
|
|
267
|
+
if (typeof raw !== "object" || raw === null) {
|
|
268
|
+
return {
|
|
269
|
+
diagnostic: registryDiagnostic("VUTEX_PACKAGE_METADATA_INVALID", `registry metadata for ${name} is not an object`),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
const record = raw as Record<string, unknown>;
|
|
273
|
+
if (record.slot !== undefined && record.slot !== slot) {
|
|
274
|
+
return {
|
|
275
|
+
diagnostic: registryDiagnostic(
|
|
276
|
+
"VUTEX_PACKAGE_METADATA_INVALID",
|
|
277
|
+
`registry metadata for ${name} declares slot ${String(record.slot)} instead of ${slot}`,
|
|
278
|
+
),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
if (record.name !== undefined && record.name !== name) {
|
|
282
|
+
return {
|
|
283
|
+
diagnostic: registryDiagnostic(
|
|
284
|
+
"VUTEX_PACKAGE_METADATA_INVALID",
|
|
285
|
+
`registry metadata is named ${String(record.name)} instead of ${name}`,
|
|
286
|
+
),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
if (!Array.isArray(record.versions)) {
|
|
290
|
+
return {
|
|
291
|
+
diagnostic: registryDiagnostic("VUTEX_PACKAGE_METADATA_INVALID", `registry metadata for ${name} has no versions`),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
const versions: RegistryVersion[] = [];
|
|
295
|
+
for (const entry of record.versions) {
|
|
296
|
+
if (typeof entry !== "object" || entry === null) {
|
|
297
|
+
return {
|
|
298
|
+
diagnostic: registryDiagnostic("VUTEX_PACKAGE_METADATA_INVALID", `registry metadata for ${name} has a malformed version`),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
const versionRecord = entry as Record<string, unknown>;
|
|
302
|
+
if (typeof versionRecord.version !== "string") {
|
|
303
|
+
return {
|
|
304
|
+
diagnostic: registryDiagnostic("VUTEX_PACKAGE_METADATA_INVALID", `registry metadata for ${name} has a version without a number`),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
const files = parseFiles(versionRecord.files);
|
|
308
|
+
const dependencies = parseDependencies(versionRecord.dependencies);
|
|
309
|
+
if (!files || !dependencies) {
|
|
310
|
+
return {
|
|
311
|
+
diagnostic: registryDiagnostic(
|
|
312
|
+
"VUTEX_PACKAGE_METADATA_INVALID",
|
|
313
|
+
`registry metadata for ${name}@${versionRecord.version} has malformed files or dependencies`,
|
|
314
|
+
),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
versions.push({
|
|
318
|
+
version: versionRecord.version,
|
|
319
|
+
files,
|
|
320
|
+
dependencies,
|
|
321
|
+
...(typeof versionRecord.artifactHash === "string" ? { artifactHash: versionRecord.artifactHash } : {}),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
return { metadata: { slot, name, versions } };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const decoder = new TextDecoder();
|
|
328
|
+
|
|
329
|
+
function statusOk(status: number): boolean {
|
|
330
|
+
return status >= 200 && status < 300;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Fetch and parse one metadata document. Network faults keep the URL. */
|
|
334
|
+
export async function fetchRegistryMetadata(
|
|
335
|
+
transport: RegistryTransport,
|
|
336
|
+
base: string,
|
|
337
|
+
slot: string,
|
|
338
|
+
name: string,
|
|
339
|
+
): Promise<{ metadata?: RegistryMetadata; metadataUrl?: string; diagnostic?: VuTeXDiagnostic }> {
|
|
340
|
+
const url = metadataUrl(base, slot, name);
|
|
341
|
+
let response: RegistryResponse;
|
|
342
|
+
try {
|
|
343
|
+
response = await transport(url);
|
|
344
|
+
} catch (error) {
|
|
345
|
+
return {
|
|
346
|
+
diagnostic: registryDiagnostic(
|
|
347
|
+
"VUTEX_PACKAGE_REGISTRY_UNREACHABLE",
|
|
348
|
+
`cannot reach registry for ${name}: ${error instanceof Error ? error.message : String(error)}`,
|
|
349
|
+
{ url },
|
|
350
|
+
),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
if (!statusOk(response.status)) {
|
|
354
|
+
return {
|
|
355
|
+
diagnostic: registryDiagnostic(
|
|
356
|
+
"VUTEX_PACKAGE_REGISTRY_UNREACHABLE",
|
|
357
|
+
`registry returned ${response.status} for ${name}`,
|
|
358
|
+
{ url, status: response.status },
|
|
359
|
+
),
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
let parsed: unknown;
|
|
363
|
+
try {
|
|
364
|
+
parsed = JSON.parse(decoder.decode(response.body));
|
|
365
|
+
} catch (error) {
|
|
366
|
+
return {
|
|
367
|
+
diagnostic: registryDiagnostic(
|
|
368
|
+
"VUTEX_PACKAGE_METADATA_INVALID",
|
|
369
|
+
`registry metadata for ${name} is not json: ${error instanceof Error ? error.message : String(error)}`,
|
|
370
|
+
{ url },
|
|
371
|
+
),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
const checked = parseRegistryMetadata(slot, name, parsed);
|
|
375
|
+
return { ...checked, metadataUrl: url };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export async function downloadRegistryFile(
|
|
379
|
+
transport: RegistryTransport,
|
|
380
|
+
url: string,
|
|
381
|
+
path: string,
|
|
382
|
+
): Promise<{ bytes?: Uint8Array; diagnostic?: VuTeXDiagnostic }> {
|
|
383
|
+
let response: RegistryResponse;
|
|
384
|
+
try {
|
|
385
|
+
response = await transport(url);
|
|
386
|
+
} catch (error) {
|
|
387
|
+
return {
|
|
388
|
+
diagnostic: registryDiagnostic(
|
|
389
|
+
"VUTEX_PACKAGE_DOWNLOAD_FAILED",
|
|
390
|
+
`cannot download ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
391
|
+
{ url, path },
|
|
392
|
+
),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
if (!statusOk(response.status)) {
|
|
396
|
+
return {
|
|
397
|
+
diagnostic: registryDiagnostic(
|
|
398
|
+
"VUTEX_PACKAGE_DOWNLOAD_FAILED",
|
|
399
|
+
`cannot download ${path}: registry returned ${response.status}`,
|
|
400
|
+
{ url, path, status: response.status },
|
|
401
|
+
),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
return { bytes: response.body };
|
|
405
|
+
}
|