@deepseek-ai/dsh-plugin-detail 0.0.0-stage → 0.2.2-alpha.2
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/LICENSE +21 -0
- package/README.i18n.yaml +46 -0
- package/README.md +100 -2
- package/README.zh.md +101 -0
- package/lib/index.js +420 -0
- package/lib/types/document.d.ts +58 -0
- package/lib/types/document.js +130 -0
- package/lib/types/files.d.ts +126 -0
- package/lib/types/files.js +242 -0
- package/lib/types/index.d.ts +10 -0
- package/lib/types/index.js +9 -0
- package/lib/types/response.d.ts +17 -0
- package/lib/types/response.js +28 -0
- package/lib/types/types.d.ts +11 -0
- package/lib/types/types.js +3 -0
- package/package.json +37 -3
package/lib/index.js
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { lstat, readFile, readdir, realpath } from "node:fs/promises";
|
|
2
|
+
import { join, posix, relative, sep } from "node:path";
|
|
3
|
+
//#region lib/types/files.js
|
|
4
|
+
/**
|
|
5
|
+
* Read-only file sources for plugin detail documents: a local package directory and published npm package versions on
|
|
6
|
+
* public file CDNs, with bounded HTTP reads. Sources keep no state between calls.
|
|
7
|
+
*/
|
|
8
|
+
/** A read that must not fall back: the source exists but refused, failed, or exceeded a limit. */
|
|
9
|
+
var DetailFailure = class extends Error {
|
|
10
|
+
reason;
|
|
11
|
+
/**
|
|
12
|
+
* @param reason - the failure kind a reader reports.
|
|
13
|
+
* @param message - the diagnostic, naming the source.
|
|
14
|
+
* @param options - the underlying error.
|
|
15
|
+
*/
|
|
16
|
+
constructor(reason, message, options) {
|
|
17
|
+
super(message, options);
|
|
18
|
+
this.reason = reason;
|
|
19
|
+
this.name = "DetailFailure";
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
/** README names tried in order where a source cannot list a directory. */
|
|
23
|
+
const README_NAMES = [
|
|
24
|
+
"README.md",
|
|
25
|
+
"readme.md",
|
|
26
|
+
"Readme.md",
|
|
27
|
+
"README.markdown",
|
|
28
|
+
"README"
|
|
29
|
+
];
|
|
30
|
+
const README_PATTERN = /^readme(?:\.md|\.markdown)?$/iu;
|
|
31
|
+
/**
|
|
32
|
+
* Join a directory and a file name into a root-relative path.
|
|
33
|
+
* @param dir - directory relative to the root, `''` for the root.
|
|
34
|
+
* @param name - path relative to `dir`.
|
|
35
|
+
* @returns the joined path.
|
|
36
|
+
*/
|
|
37
|
+
function joinPath(dir, name) {
|
|
38
|
+
return dir === "" ? name : `${dir}/${name}`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Pick the README entry of a directory listing by the order of {@link README_NAMES}, then by name.
|
|
42
|
+
* @param names - file names of the directory.
|
|
43
|
+
* @returns the README name, or undefined.
|
|
44
|
+
*/
|
|
45
|
+
function readmeName(names) {
|
|
46
|
+
const rank = (name) => {
|
|
47
|
+
const index = README_NAMES.indexOf(name);
|
|
48
|
+
return index === -1 ? README_NAMES.length : index;
|
|
49
|
+
};
|
|
50
|
+
return names.filter((name) => README_PATTERN.test(name)).sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : 1))[0];
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Read the first README of `names` that exists, for sources that cannot list a directory.
|
|
54
|
+
* @param read - reads one file.
|
|
55
|
+
* @param dir - directory relative to the root; `''` for the root.
|
|
56
|
+
* @param maxBytes - largest accepted size.
|
|
57
|
+
* @param signal - cancellation of the reads.
|
|
58
|
+
* @param names - the names to try, in order.
|
|
59
|
+
* @returns the file, or undefined when none exists.
|
|
60
|
+
* @throws {DetailFailure} what `read` throws.
|
|
61
|
+
*/
|
|
62
|
+
async function readmeByName(read, dir, maxBytes, signal, names = README_NAMES) {
|
|
63
|
+
for (const name of names) {
|
|
64
|
+
const path = joinPath(dir, name);
|
|
65
|
+
const bytes = await read(path, maxBytes, signal);
|
|
66
|
+
if (bytes !== void 0) return {
|
|
67
|
+
path,
|
|
68
|
+
bytes
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function tooLarge(label, maxBytes) {
|
|
73
|
+
return new DetailFailure("too-large", `${label} exceeds ${String(maxBytes)} bytes`);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Files of a local package directory. A path whose real location leaves the directory reads as absent.
|
|
77
|
+
* @param root - absolute package directory.
|
|
78
|
+
* @param revision - the installed version, so a reinstalled package reads as different files.
|
|
79
|
+
* @returns the files.
|
|
80
|
+
*/
|
|
81
|
+
function localFiles(root, revision) {
|
|
82
|
+
const files = {
|
|
83
|
+
key: `local:${root}@${revision}`,
|
|
84
|
+
async read(path, maxBytes) {
|
|
85
|
+
let file;
|
|
86
|
+
try {
|
|
87
|
+
const base = await realpath(root);
|
|
88
|
+
file = await realpath(join(base, ...path.split("/")));
|
|
89
|
+
const inside = relative(base, file);
|
|
90
|
+
if (inside === "" || inside === ".." || inside.startsWith(`..${sep}`)) return void 0;
|
|
91
|
+
const info = await lstat(file);
|
|
92
|
+
if (!info.isFile()) return void 0;
|
|
93
|
+
if (info.size > maxBytes) throw tooLarge(path, maxBytes);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error instanceof DetailFailure) throw error;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const bytes = await readFile(file);
|
|
99
|
+
/* v8 ignore next -- the file grew between its size check and its read; no test controls that timing */
|
|
100
|
+
if (bytes.byteLength > maxBytes) throw tooLarge(path, maxBytes);
|
|
101
|
+
return bytes;
|
|
102
|
+
},
|
|
103
|
+
async readme(dir, maxBytes, signal) {
|
|
104
|
+
let names;
|
|
105
|
+
try {
|
|
106
|
+
names = (await readdir(join(root, ...dir === "" ? [] : dir.split("/")), { withFileTypes: true })).filter((entry) => entry.isFile() || entry.isSymbolicLink()).map((entry) => entry.name);
|
|
107
|
+
} catch (_missing) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const name = readmeName(names);
|
|
111
|
+
if (name === void 0) return void 0;
|
|
112
|
+
const path = joinPath(dir, name);
|
|
113
|
+
const bytes = await files.read(path, maxBytes, signal);
|
|
114
|
+
return bytes === void 0 ? void 0 : {
|
|
115
|
+
path,
|
|
116
|
+
bytes
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
return files;
|
|
121
|
+
}
|
|
122
|
+
/** Redirects followed within one origin, such as a renamed repository's raw files; a redirect to another origin is not followed. */
|
|
123
|
+
const MAX_REDIRECTS = 3;
|
|
124
|
+
/**
|
|
125
|
+
* GET a URL, following redirects within its origin, and read the body up to `maxBytes`.
|
|
126
|
+
* @param url - the request URL.
|
|
127
|
+
* @param maxBytes - largest accepted body.
|
|
128
|
+
* @param signal - cancellation of the read.
|
|
129
|
+
* @param options - deadline and service lifetime.
|
|
130
|
+
* @returns the body, or the status of a non-2xx response, including a redirect to another origin.
|
|
131
|
+
* @throws {DetailFailure} `network` when no response arrives or the body fails, `too-large` over `maxBytes`.
|
|
132
|
+
*/
|
|
133
|
+
async function fetchBody(url, maxBytes, signal, options) {
|
|
134
|
+
const combined = AbortSignal.any([
|
|
135
|
+
signal,
|
|
136
|
+
options.lifetime,
|
|
137
|
+
AbortSignal.timeout(options.timeoutMs)
|
|
138
|
+
]);
|
|
139
|
+
let response;
|
|
140
|
+
let target = url;
|
|
141
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
142
|
+
try {
|
|
143
|
+
response = await fetch(target, {
|
|
144
|
+
signal: combined,
|
|
145
|
+
redirect: "manual"
|
|
146
|
+
});
|
|
147
|
+
} catch (error) {
|
|
148
|
+
signal.throwIfAborted();
|
|
149
|
+
options.lifetime.throwIfAborted();
|
|
150
|
+
throw new DetailFailure("network", `Could not fetch ${target}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
151
|
+
}
|
|
152
|
+
const location = response.status >= 300 && response.status < 400 ? response.headers.get("location") : null;
|
|
153
|
+
if (location === null || !URL.canParse(location, target) || new URL(location, target).origin !== new URL(url).origin) break;
|
|
154
|
+
await response.body?.cancel();
|
|
155
|
+
target = new URL(location, target).href;
|
|
156
|
+
}
|
|
157
|
+
const answer = response;
|
|
158
|
+
if (!answer.ok) {
|
|
159
|
+
await answer.body?.cancel();
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
status: answer.status
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (Number(answer.headers.get("content-length") ?? 0) > maxBytes) {
|
|
166
|
+
await answer.body?.cancel();
|
|
167
|
+
throw tooLarge(url, maxBytes);
|
|
168
|
+
}
|
|
169
|
+
const chunks = [];
|
|
170
|
+
let size = 0;
|
|
171
|
+
try {
|
|
172
|
+
for await (const chunk of answer.body ?? []) {
|
|
173
|
+
size += chunk.byteLength;
|
|
174
|
+
if (size > maxBytes) throw tooLarge(url, maxBytes);
|
|
175
|
+
chunks.push(chunk);
|
|
176
|
+
}
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (error instanceof DetailFailure) throw error;
|
|
179
|
+
throw new DetailFailure("network", `Could not read ${url}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
ok: true,
|
|
183
|
+
bytes: Buffer.concat(chunks)
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/** Every npm file CDN, in the default order. */
|
|
187
|
+
const NPM_FILE_SOURCES = [
|
|
188
|
+
"jsdelivr",
|
|
189
|
+
"unpkg",
|
|
190
|
+
"npmmirror"
|
|
191
|
+
];
|
|
192
|
+
/**
|
|
193
|
+
* Order the configured npm file CDNs by a caller's preference: the preferred ones that are configured, in the preferred
|
|
194
|
+
* order, then the other configured ones in configured order. A preferred CDN that is not configured is not asked.
|
|
195
|
+
* @param configured - the configured CDNs in order.
|
|
196
|
+
* @param preferred - CDNs to ask first, such as those a Client measured as fastest; names other than CDNs are ignored.
|
|
197
|
+
* @returns the CDNs to ask, first to last.
|
|
198
|
+
*/
|
|
199
|
+
function npmFileSourceOrder(configured, preferred = []) {
|
|
200
|
+
const first = configured.filter((source) => preferred.includes(source)).sort((a, b) => preferred.indexOf(a) - preferred.indexOf(b));
|
|
201
|
+
return [...first, ...configured.filter((source) => !first.includes(source))];
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Percent-encode each segment of a `/`-separated path for a URL.
|
|
205
|
+
* @param path - the path.
|
|
206
|
+
* @returns the encoded path.
|
|
207
|
+
*/
|
|
208
|
+
function encodePath(path) {
|
|
209
|
+
return path.split("/").map(encodeURIComponent).join("/");
|
|
210
|
+
}
|
|
211
|
+
const NPM_FILE_URLS = {
|
|
212
|
+
jsdelivr: (name, version, path) => `https://cdn.jsdelivr.net/npm/${name}@${version}/${encodePath(path)}`,
|
|
213
|
+
unpkg: (name, version, path) => `https://unpkg.com/${name}@${version}/${encodePath(path)}`,
|
|
214
|
+
npmmirror: (name, version, path) => `https://registry.npmmirror.com/${name}/${version}/files/${encodePath(path)}`
|
|
215
|
+
};
|
|
216
|
+
/**
|
|
217
|
+
* Files of one published npm package version, read from public file CDNs in order. A 404 from a CDN means the package
|
|
218
|
+
* does not contain the file; a network failure or another status asks the next CDN.
|
|
219
|
+
* @param name - package name.
|
|
220
|
+
* @param version - exact version.
|
|
221
|
+
* @param sources - the CDNs to ask, first to last; never empty.
|
|
222
|
+
* @param options - deadline and service lifetime.
|
|
223
|
+
* @returns the files.
|
|
224
|
+
*/
|
|
225
|
+
function npmFiles(name, version, sources, options) {
|
|
226
|
+
const files = {
|
|
227
|
+
key: `npm:${name}@${version}`,
|
|
228
|
+
async read(path, maxBytes, signal) {
|
|
229
|
+
const failures = [];
|
|
230
|
+
for (const source of sources) {
|
|
231
|
+
const url = NPM_FILE_URLS[source](name, version, path);
|
|
232
|
+
try {
|
|
233
|
+
const body = await fetchBody(url, maxBytes, signal, options);
|
|
234
|
+
if (body.ok) return body.bytes;
|
|
235
|
+
if (body.status === 404) return void 0;
|
|
236
|
+
failures.push(`${url}: HTTP ${String(body.status)}`);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (!(error instanceof DetailFailure) || error.reason !== "network") throw error;
|
|
239
|
+
failures.push(error.message);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
throw new DetailFailure("network", failures.join("; "));
|
|
243
|
+
},
|
|
244
|
+
readme: (dir, maxBytes, signal) => readmeByName((path, max, readSignal) => files.read(path, max, readSignal), dir, maxBytes, signal)
|
|
245
|
+
};
|
|
246
|
+
return files;
|
|
247
|
+
}
|
|
248
|
+
//#endregion
|
|
249
|
+
//#region lib/types/document.js
|
|
250
|
+
/**
|
|
251
|
+
* Plugin detail document selection: the Markdown file a package's locale `meta.detail` names in a display language,
|
|
252
|
+
* otherwise the package's README; and the images beside a selected document.
|
|
253
|
+
*/
|
|
254
|
+
const LANGUAGE_ID = /^[a-z]{2,8}(?:-[a-z0-9]{1,8})*$/u;
|
|
255
|
+
/**
|
|
256
|
+
* The languages a document is chosen in: the language, each shorter prefix, then English.
|
|
257
|
+
* @param language - a BCP 47 language id such as `zh-CN`.
|
|
258
|
+
* @returns lower-case language ids, most specific first.
|
|
259
|
+
* @throws when `language` is not a language id.
|
|
260
|
+
*/
|
|
261
|
+
function languageChain(language) {
|
|
262
|
+
const id = language.toLowerCase();
|
|
263
|
+
if (!LANGUAGE_ID.test(id)) throw new Error(`Not a language id: ${language}`);
|
|
264
|
+
const parts = id.split("-");
|
|
265
|
+
const chain = parts.map((_part, index) => parts.slice(0, parts.length - index).join("-"));
|
|
266
|
+
return [...new Set([...chain, "en"])];
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* A locale `meta.detail` value: a `/`-separated Markdown path relative to the package root that cannot leave it.
|
|
270
|
+
* @param value - the declared `meta.detail` value, or `undefined` when the locale file omits it.
|
|
271
|
+
* @param label - the locale file path used in a failure message.
|
|
272
|
+
* @returns the validated path, or `undefined` when the value is absent.
|
|
273
|
+
* @throws {DetailFailure} `invalid` for another value.
|
|
274
|
+
*/
|
|
275
|
+
function detailPath(value, label) {
|
|
276
|
+
if (value === void 0) return void 0;
|
|
277
|
+
const invalid = (rule) => new DetailFailure("invalid", `${label} ${rule}`);
|
|
278
|
+
if (typeof value !== "string" || value.trim() === "") throw invalid("must be a non-empty string");
|
|
279
|
+
if (value.includes("\\") || /^[A-Za-z][A-Za-z\d+.-]*:/u.test(value) || value.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) throw invalid("must be a relative path inside the package");
|
|
280
|
+
if (!/\.(?:md|markdown)$/iu.test(value)) throw invalid("must name a Markdown file");
|
|
281
|
+
return value;
|
|
282
|
+
}
|
|
283
|
+
/** `meta.detail` of the first `locale/<language>.json` along the chain that declares one. */
|
|
284
|
+
async function localeDetail(files, dir, languages, maxBytes, signal) {
|
|
285
|
+
for (const language of languages) {
|
|
286
|
+
const names = [...new Set([language, language.replace(/^([a-z]{2,3})-([a-z]{2})$/u, (_match, base, region) => `${base}-${region.toUpperCase()}`)])];
|
|
287
|
+
for (const name of names) {
|
|
288
|
+
const path = joinPath(dir, `locale/${name}.json`);
|
|
289
|
+
const bytes = await files.read(path, maxBytes, signal);
|
|
290
|
+
if (bytes === void 0) continue;
|
|
291
|
+
let meta;
|
|
292
|
+
try {
|
|
293
|
+
meta = JSON.parse(new TextDecoder().decode(bytes))?.meta;
|
|
294
|
+
} catch (error) {
|
|
295
|
+
throw new DetailFailure("invalid", `${path}: ${error.message}`, { cause: error });
|
|
296
|
+
}
|
|
297
|
+
const detail = detailPath(typeof meta === "object" && meta !== null ? meta.detail : void 0, `${path}: meta.detail`);
|
|
298
|
+
if (detail !== void 0) return detail;
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Decode a Markdown document: UTF-8 without a byte order mark, without a leading YAML front matter block, which is file
|
|
305
|
+
* metadata rather than description text.
|
|
306
|
+
* @param bytes - the file's bytes.
|
|
307
|
+
* @returns the Markdown text.
|
|
308
|
+
*/
|
|
309
|
+
function markdownOf(bytes) {
|
|
310
|
+
return new TextDecoder().decode(bytes).replace(/^\uFEFF/u, "").replace(/^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/u, "");
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Read a package's detail document: the Markdown file that `meta.detail` in its `locale/<language>.json` names for the
|
|
314
|
+
* first language of the chain that declares one, otherwise its README.
|
|
315
|
+
* @param files - the package's files.
|
|
316
|
+
* @param dir - package directory inside `files`, `''` for its root.
|
|
317
|
+
* @param languages - the chain from {@link languageChain}.
|
|
318
|
+
* @param maxBytes - largest accepted locale file and document.
|
|
319
|
+
* @param signal - cancellation of the reads.
|
|
320
|
+
* @returns the document, or undefined when the package has neither.
|
|
321
|
+
* @throws {DetailFailure} `invalid` for a malformed locale file or `meta.detail` or a named file that does not exist, and
|
|
322
|
+
* the failures of `files`.
|
|
323
|
+
*/
|
|
324
|
+
async function readPackageDocument(files, dir, languages, maxBytes, signal) {
|
|
325
|
+
const detail = await localeDetail(files, dir, languages, maxBytes, signal);
|
|
326
|
+
if (detail !== void 0) {
|
|
327
|
+
const path = joinPath(dir, detail);
|
|
328
|
+
const bytes = await files.read(path, maxBytes, signal);
|
|
329
|
+
if (bytes === void 0) throw new DetailFailure("invalid", `${path}, named by meta.detail, does not exist`);
|
|
330
|
+
return {
|
|
331
|
+
origin: "locale",
|
|
332
|
+
path,
|
|
333
|
+
markdown: markdownOf(bytes)
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
const readme = await files.readme(dir, maxBytes, signal);
|
|
337
|
+
return readme === void 0 ? void 0 : {
|
|
338
|
+
origin: "readme",
|
|
339
|
+
path: readme.path,
|
|
340
|
+
markdown: markdownOf(readme.bytes)
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
/** Image file types served beside a document, by lower-case extension. */
|
|
344
|
+
const IMAGE_TYPES = new Map([
|
|
345
|
+
[".png", "image/png"],
|
|
346
|
+
[".jpg", "image/jpeg"],
|
|
347
|
+
[".jpeg", "image/jpeg"],
|
|
348
|
+
[".gif", "image/gif"],
|
|
349
|
+
[".webp", "image/webp"],
|
|
350
|
+
[".avif", "image/avif"],
|
|
351
|
+
[".svg", "image/svg+xml"]
|
|
352
|
+
]);
|
|
353
|
+
/**
|
|
354
|
+
* Resolve an image destination a document writes.
|
|
355
|
+
* @param document - `/`-separated path of the document inside its files.
|
|
356
|
+
* @param authored - the destination as written: relative to the document, or to the root of its files when it starts
|
|
357
|
+
* with `/`; a query or fragment is ignored.
|
|
358
|
+
* @returns the image's path inside the files and its media type, or undefined for a malformed destination, a path that
|
|
359
|
+
* leaves the root, or a type other than PNG, JPEG, GIF, WebP, AVIF, and SVG.
|
|
360
|
+
*/
|
|
361
|
+
function detailImage(document, authored) {
|
|
362
|
+
let decoded;
|
|
363
|
+
try {
|
|
364
|
+
decoded = decodeURIComponent(authored.replace(/[?#].*$/su, ""));
|
|
365
|
+
} catch (_malformed) {
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (decoded.includes("\\") || decoded.includes("\0")) return void 0;
|
|
369
|
+
const path = posix.normalize(decoded.startsWith("/") ? decoded.slice(1) : posix.join(posix.dirname(document), decoded));
|
|
370
|
+
if (path === "." || path.startsWith("../") || path === ".." || posix.isAbsolute(path)) return void 0;
|
|
371
|
+
const mediaType = IMAGE_TYPES.get(posix.extname(path).toLowerCase());
|
|
372
|
+
return mediaType === void 0 ? void 0 : {
|
|
373
|
+
path,
|
|
374
|
+
mediaType
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
//#endregion
|
|
378
|
+
//#region lib/types/response.js
|
|
379
|
+
/** HTTP status of each detail failure. */
|
|
380
|
+
const FAILURE_STATUS = {
|
|
381
|
+
"too-large": 413,
|
|
382
|
+
"network": 502,
|
|
383
|
+
"denied": 403,
|
|
384
|
+
"git-unavailable": 503,
|
|
385
|
+
"invalid": 400
|
|
386
|
+
};
|
|
387
|
+
/**
|
|
388
|
+
* The response of an image route: the image, 404 when there is none, or the failure's status with its reason as the
|
|
389
|
+
* body. Every response carries `X-Content-Type-Options: nosniff` and a sandbox content security policy, because an SVG
|
|
390
|
+
* opened directly would otherwise run scripts on the authenticated API origin; only an image gets `cacheControl`.
|
|
391
|
+
* @param outcome - the image, undefined when none exists, or why it cannot be read; `invalid` also answers a malformed request.
|
|
392
|
+
* @param cacheControl - the `Cache-Control` value of an image.
|
|
393
|
+
* @returns the response.
|
|
394
|
+
*/
|
|
395
|
+
function detailImageResponse(outcome, cacheControl) {
|
|
396
|
+
const [status, body, type] = typeof outcome === "object" ? [
|
|
397
|
+
200,
|
|
398
|
+
new Uint8Array(outcome.bytes),
|
|
399
|
+
outcome.mediaType
|
|
400
|
+
] : outcome === void 0 ? [
|
|
401
|
+
404,
|
|
402
|
+
"not found",
|
|
403
|
+
void 0
|
|
404
|
+
] : [
|
|
405
|
+
FAILURE_STATUS[outcome],
|
|
406
|
+
outcome,
|
|
407
|
+
void 0
|
|
408
|
+
];
|
|
409
|
+
return new Response(body, {
|
|
410
|
+
status,
|
|
411
|
+
headers: {
|
|
412
|
+
"Content-Type": type ?? "text/plain; charset=utf-8",
|
|
413
|
+
"Cache-Control": status === 200 ? cacheControl : "no-store",
|
|
414
|
+
"X-Content-Type-Options": "nosniff",
|
|
415
|
+
"Content-Security-Policy": "sandbox; default-src 'none'; style-src 'unsafe-inline'"
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
//#endregion
|
|
420
|
+
export { DetailFailure, NPM_FILE_SOURCES, README_NAMES, detailImage, detailImageResponse, encodePath, fetchBody, joinPath, languageChain, localFiles, markdownOf, npmFileSourceOrder, npmFiles, readPackageDocument, readmeByName, readmeName };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type DetailFiles } from './files.ts';
|
|
2
|
+
/**
|
|
3
|
+
* The languages a document is chosen in: the language, each shorter prefix, then English.
|
|
4
|
+
* @param language - a BCP 47 language id such as `zh-CN`.
|
|
5
|
+
* @returns lower-case language ids, most specific first.
|
|
6
|
+
* @throws when `language` is not a language id.
|
|
7
|
+
*/
|
|
8
|
+
export declare function languageChain(language: string): string[];
|
|
9
|
+
/**
|
|
10
|
+
* A locale `meta.detail` value: a `/`-separated Markdown path relative to the package root that cannot leave it.
|
|
11
|
+
* @param value - the declared `meta.detail` value, or `undefined` when the locale file omits it.
|
|
12
|
+
* @param label - the locale file path used in a failure message.
|
|
13
|
+
* @returns the validated path, or `undefined` when the value is absent.
|
|
14
|
+
* @throws {DetailFailure} `invalid` for another value.
|
|
15
|
+
*/
|
|
16
|
+
export declare function detailPath(value: unknown, label: string): string | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* Decode a Markdown document: UTF-8 without a byte order mark, without a leading YAML front matter block, which is file
|
|
19
|
+
* metadata rather than description text.
|
|
20
|
+
* @param bytes - the file's bytes.
|
|
21
|
+
* @returns the Markdown text.
|
|
22
|
+
*/
|
|
23
|
+
export declare function markdownOf(bytes: Uint8Array): string;
|
|
24
|
+
/** A package's detail document. */
|
|
25
|
+
export interface PackageDocument {
|
|
26
|
+
/** `locale` for the file `meta.detail` names, `readme` for the package's README. */
|
|
27
|
+
readonly origin: 'locale' | 'readme';
|
|
28
|
+
/** `/`-separated path of the document inside the files. */
|
|
29
|
+
readonly path: string;
|
|
30
|
+
/** The document, decoded by {@link markdownOf}. */
|
|
31
|
+
readonly markdown: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Read a package's detail document: the Markdown file that `meta.detail` in its `locale/<language>.json` names for the
|
|
35
|
+
* first language of the chain that declares one, otherwise its README.
|
|
36
|
+
* @param files - the package's files.
|
|
37
|
+
* @param dir - package directory inside `files`, `''` for its root.
|
|
38
|
+
* @param languages - the chain from {@link languageChain}.
|
|
39
|
+
* @param maxBytes - largest accepted locale file and document.
|
|
40
|
+
* @param signal - cancellation of the reads.
|
|
41
|
+
* @returns the document, or undefined when the package has neither.
|
|
42
|
+
* @throws {DetailFailure} `invalid` for a malformed locale file or `meta.detail` or a named file that does not exist, and
|
|
43
|
+
* the failures of `files`.
|
|
44
|
+
*/
|
|
45
|
+
export declare function readPackageDocument(files: DetailFiles, dir: string, languages: readonly string[], maxBytes: number, signal: AbortSignal): Promise<PackageDocument | undefined>;
|
|
46
|
+
/**
|
|
47
|
+
* Resolve an image destination a document writes.
|
|
48
|
+
* @param document - `/`-separated path of the document inside its files.
|
|
49
|
+
* @param authored - the destination as written: relative to the document, or to the root of its files when it starts
|
|
50
|
+
* with `/`; a query or fragment is ignored.
|
|
51
|
+
* @returns the image's path inside the files and its media type, or undefined for a malformed destination, a path that
|
|
52
|
+
* leaves the root, or a type other than PNG, JPEG, GIF, WebP, AVIF, and SVG.
|
|
53
|
+
*/
|
|
54
|
+
export declare function detailImage(document: string, authored: string): {
|
|
55
|
+
path: string;
|
|
56
|
+
mediaType: string;
|
|
57
|
+
} | undefined;
|
|
58
|
+
//# sourceMappingURL=document.d.ts.map
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin detail document selection: the Markdown file a package's locale `meta.detail` names in a display language,
|
|
3
|
+
* otherwise the package's README; and the images beside a selected document.
|
|
4
|
+
*/
|
|
5
|
+
import { posix } from 'node:path';
|
|
6
|
+
import { DetailFailure, joinPath } from "./files.js";
|
|
7
|
+
const LANGUAGE_ID = /^[a-z]{2,8}(?:-[a-z0-9]{1,8})*$/u;
|
|
8
|
+
/**
|
|
9
|
+
* The languages a document is chosen in: the language, each shorter prefix, then English.
|
|
10
|
+
* @param language - a BCP 47 language id such as `zh-CN`.
|
|
11
|
+
* @returns lower-case language ids, most specific first.
|
|
12
|
+
* @throws when `language` is not a language id.
|
|
13
|
+
*/
|
|
14
|
+
export function languageChain(language) {
|
|
15
|
+
const id = language.toLowerCase();
|
|
16
|
+
if (!LANGUAGE_ID.test(id))
|
|
17
|
+
throw new Error(`Not a language id: ${language}`);
|
|
18
|
+
const parts = id.split('-');
|
|
19
|
+
const chain = parts.map((_part, index) => parts.slice(0, parts.length - index).join('-'));
|
|
20
|
+
return [...new Set([...chain, 'en'])];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A locale `meta.detail` value: a `/`-separated Markdown path relative to the package root that cannot leave it.
|
|
24
|
+
* @param value - the declared `meta.detail` value, or `undefined` when the locale file omits it.
|
|
25
|
+
* @param label - the locale file path used in a failure message.
|
|
26
|
+
* @returns the validated path, or `undefined` when the value is absent.
|
|
27
|
+
* @throws {DetailFailure} `invalid` for another value.
|
|
28
|
+
*/
|
|
29
|
+
export function detailPath(value, label) {
|
|
30
|
+
if (value === undefined)
|
|
31
|
+
return undefined;
|
|
32
|
+
const invalid = (rule) => new DetailFailure('invalid', `${label} ${rule}`);
|
|
33
|
+
if (typeof value !== 'string' || value.trim() === '')
|
|
34
|
+
throw invalid('must be a non-empty string');
|
|
35
|
+
if (value.includes('\\') || /^[A-Za-z][A-Za-z\d+.-]*:/u.test(value) || value.split('/').some(segment => segment === '' || segment === '.' || segment === '..')) {
|
|
36
|
+
throw invalid('must be a relative path inside the package');
|
|
37
|
+
}
|
|
38
|
+
if (!/\.(?:md|markdown)$/iu.test(value))
|
|
39
|
+
throw invalid('must name a Markdown file');
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
/** `meta.detail` of the first `locale/<language>.json` along the chain that declares one. */
|
|
43
|
+
async function localeDetail(files, dir, languages, maxBytes, signal) {
|
|
44
|
+
for (const language of languages) {
|
|
45
|
+
// Locale files are named by language id: lower case, and also with an upper-case two-letter region.
|
|
46
|
+
const names = [...new Set([language, language.replace(/^([a-z]{2,3})-([a-z]{2})$/u, (_match, base, region) => `${base}-${region.toUpperCase()}`)])];
|
|
47
|
+
for (const name of names) {
|
|
48
|
+
const path = joinPath(dir, `locale/${name}.json`);
|
|
49
|
+
const bytes = await files.read(path, maxBytes, signal);
|
|
50
|
+
if (bytes === undefined)
|
|
51
|
+
continue;
|
|
52
|
+
let meta;
|
|
53
|
+
try {
|
|
54
|
+
meta = JSON.parse(new TextDecoder().decode(bytes))?.meta;
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
throw new DetailFailure('invalid', `${path}: ${error.message}`, { cause: error });
|
|
58
|
+
}
|
|
59
|
+
const detail = detailPath(typeof meta === 'object' && meta !== null ? meta.detail : undefined, `${path}: meta.detail`);
|
|
60
|
+
if (detail !== undefined)
|
|
61
|
+
return detail;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Decode a Markdown document: UTF-8 without a byte order mark, without a leading YAML front matter block, which is file
|
|
69
|
+
* metadata rather than description text.
|
|
70
|
+
* @param bytes - the file's bytes.
|
|
71
|
+
* @returns the Markdown text.
|
|
72
|
+
*/
|
|
73
|
+
export function markdownOf(bytes) {
|
|
74
|
+
return new TextDecoder().decode(bytes).replace(/^\uFEFF/u, '').replace(/^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/u, '');
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Read a package's detail document: the Markdown file that `meta.detail` in its `locale/<language>.json` names for the
|
|
78
|
+
* first language of the chain that declares one, otherwise its README.
|
|
79
|
+
* @param files - the package's files.
|
|
80
|
+
* @param dir - package directory inside `files`, `''` for its root.
|
|
81
|
+
* @param languages - the chain from {@link languageChain}.
|
|
82
|
+
* @param maxBytes - largest accepted locale file and document.
|
|
83
|
+
* @param signal - cancellation of the reads.
|
|
84
|
+
* @returns the document, or undefined when the package has neither.
|
|
85
|
+
* @throws {DetailFailure} `invalid` for a malformed locale file or `meta.detail` or a named file that does not exist, and
|
|
86
|
+
* the failures of `files`.
|
|
87
|
+
*/
|
|
88
|
+
export async function readPackageDocument(files, dir, languages, maxBytes, signal) {
|
|
89
|
+
const detail = await localeDetail(files, dir, languages, maxBytes, signal);
|
|
90
|
+
if (detail !== undefined) {
|
|
91
|
+
const path = joinPath(dir, detail);
|
|
92
|
+
const bytes = await files.read(path, maxBytes, signal);
|
|
93
|
+
if (bytes === undefined)
|
|
94
|
+
throw new DetailFailure('invalid', `${path}, named by meta.detail, does not exist`);
|
|
95
|
+
return { origin: 'locale', path, markdown: markdownOf(bytes) };
|
|
96
|
+
}
|
|
97
|
+
const readme = await files.readme(dir, maxBytes, signal);
|
|
98
|
+
return readme === undefined ? undefined : { origin: 'readme', path: readme.path, markdown: markdownOf(readme.bytes) };
|
|
99
|
+
}
|
|
100
|
+
/** Image file types served beside a document, by lower-case extension. */
|
|
101
|
+
const IMAGE_TYPES = new Map([
|
|
102
|
+
['.png', 'image/png'], ['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'], ['.gif', 'image/gif'],
|
|
103
|
+
['.webp', 'image/webp'], ['.avif', 'image/avif'], ['.svg', 'image/svg+xml'],
|
|
104
|
+
]);
|
|
105
|
+
/**
|
|
106
|
+
* Resolve an image destination a document writes.
|
|
107
|
+
* @param document - `/`-separated path of the document inside its files.
|
|
108
|
+
* @param authored - the destination as written: relative to the document, or to the root of its files when it starts
|
|
109
|
+
* with `/`; a query or fragment is ignored.
|
|
110
|
+
* @returns the image's path inside the files and its media type, or undefined for a malformed destination, a path that
|
|
111
|
+
* leaves the root, or a type other than PNG, JPEG, GIF, WebP, AVIF, and SVG.
|
|
112
|
+
*/
|
|
113
|
+
export function detailImage(document, authored) {
|
|
114
|
+
let decoded;
|
|
115
|
+
try {
|
|
116
|
+
decoded = decodeURIComponent(authored.replace(/[?#].*$/su, ''));
|
|
117
|
+
}
|
|
118
|
+
catch (_malformed) {
|
|
119
|
+
// A destination with a malformed percent escape names no file.
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
if (decoded.includes('\\') || decoded.includes('\0'))
|
|
123
|
+
return undefined;
|
|
124
|
+
const path = posix.normalize(decoded.startsWith('/') ? decoded.slice(1) : posix.join(posix.dirname(document), decoded));
|
|
125
|
+
if (path === '.' || path.startsWith('../') || path === '..' || posix.isAbsolute(path))
|
|
126
|
+
return undefined;
|
|
127
|
+
const mediaType = IMAGE_TYPES.get(posix.extname(path).toLowerCase());
|
|
128
|
+
return mediaType === undefined ? undefined : { path, mediaType };
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=document.js.map
|