@nbtca/docs 0.3.1 → 0.3.3
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/README.md +22 -1
- package/dist/client.js +140 -15
- package/dist/index.d.ts +1 -1
- package/dist/types.d.ts +8 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,6 +36,8 @@ const matches = await docs.search('repair', { pathPrefix: 'repair' });
|
|
|
36
36
|
| `token` | `GITHUB_TOKEN` or `GH_TOKEN` | GitHub token |
|
|
37
37
|
| `cacheTtlMs.dir` | `300000` | Directory and tree cache TTL |
|
|
38
38
|
| `cacheTtlMs.file` | `600000` | File cache TTL |
|
|
39
|
+
| `store` | none | Persistent cache, see below |
|
|
40
|
+
| `mirror` | none | Mirror base URL, see below |
|
|
39
41
|
|
|
40
42
|
### `docs.listDir(path?)`
|
|
41
43
|
|
|
@@ -48,7 +50,12 @@ Returns raw file content.
|
|
|
48
50
|
|
|
49
51
|
### `docs.listAll()`
|
|
50
52
|
|
|
51
|
-
Lists every Markdown file through GitHub's recursive tree API.
|
|
53
|
+
Lists every Markdown file through GitHub's recursive tree API. Each item carries its Git blob `sha`.
|
|
54
|
+
|
|
55
|
+
### `docs.peekAll()`
|
|
56
|
+
|
|
57
|
+
Returns the last known tree without a request: the one fetched in this process, else the one in
|
|
58
|
+
`store`, else `undefined`.
|
|
52
59
|
|
|
53
60
|
### `docs.listSections()`
|
|
54
61
|
|
|
@@ -60,6 +67,20 @@ Returns content with its route, section, title, summary, and semantic component
|
|
|
60
67
|
metadata covers `PageHero`, `FactStrip`, `LinkCard`, `Split`, `TimelineEntry`, and `Figure` without
|
|
61
68
|
imposing a renderer.
|
|
62
69
|
|
|
70
|
+
### `store`
|
|
71
|
+
|
|
72
|
+
An object with `read(key): string | undefined` and `write(key, value): void`. The client keeps the
|
|
73
|
+
tree under `tree` and file content under `blob-<sha>`, where `<sha>` is the Git blob id. `getFile`
|
|
74
|
+
serves stored content only when its hash matches the blob id in the last known tree, so a changed
|
|
75
|
+
file is always refetched. Store errors and corrupt entries are ignored; eviction is up to the store.
|
|
76
|
+
|
|
77
|
+
### `mirror`
|
|
78
|
+
|
|
79
|
+
A base URL tried before GitHub by `listAll` and `getFile`, such as
|
|
80
|
+
`https://docs.nbtca.space/docs-api`. It serves `index.json` in the shape of GitHub's recursive tree
|
|
81
|
+
response and each file at `raw/<path>`. Any mirror failure, invalid or truncated index, or wait past
|
|
82
|
+
5 seconds falls back to GitHub. The GitHub token is never sent to the mirror.
|
|
83
|
+
|
|
63
84
|
### `docs.search(query, options?)`
|
|
64
85
|
|
|
65
86
|
Searches paths, titles, summaries, Markdown text, and semantic component attributes. Results are
|
package/dist/client.js
CHANGED
|
@@ -30,6 +30,9 @@ const SKIP = new Set([
|
|
|
30
30
|
'README.md',
|
|
31
31
|
'docs',
|
|
32
32
|
]);
|
|
33
|
+
const TREE_KEY = 'tree';
|
|
34
|
+
const MIRROR_TIMEOUT_MS = 5000;
|
|
35
|
+
const SHA = /^[0-9a-f]{40}$/;
|
|
33
36
|
const SEARCH_CONCURRENCY = 6;
|
|
34
37
|
const SEARCH_RESULT_LIMIT = 20;
|
|
35
38
|
function filterAndSort(raw) {
|
|
@@ -41,6 +44,7 @@ function filterAndSort(raw) {
|
|
|
41
44
|
name: item.name,
|
|
42
45
|
path: item.path,
|
|
43
46
|
type: item.type === 'dir' ? 'dir' : 'file',
|
|
47
|
+
...shaOf(item),
|
|
44
48
|
}))
|
|
45
49
|
.sort((a, b) => {
|
|
46
50
|
if (a.type !== b.type)
|
|
@@ -60,9 +64,40 @@ function filterTree(items) {
|
|
|
60
64
|
name: item.path.slice(item.path.lastIndexOf('/') + 1),
|
|
61
65
|
path: item.path,
|
|
62
66
|
type: 'file',
|
|
67
|
+
...shaOf(item),
|
|
63
68
|
}))
|
|
64
69
|
.sort((a, b) => a.path.localeCompare(b.path));
|
|
65
70
|
}
|
|
71
|
+
function shaOf(item) {
|
|
72
|
+
return typeof item.sha === 'string' && SHA.test(item.sha) ? { sha: item.sha } : {};
|
|
73
|
+
}
|
|
74
|
+
async function blobSha(content) {
|
|
75
|
+
const body = new TextEncoder().encode(content);
|
|
76
|
+
const header = new TextEncoder().encode(`blob ${String(body.length)}\0`);
|
|
77
|
+
const object = new Uint8Array(header.length + body.length);
|
|
78
|
+
object.set(header);
|
|
79
|
+
object.set(body, header.length);
|
|
80
|
+
const digest = new Uint8Array(await crypto.subtle.digest('SHA-1', object));
|
|
81
|
+
return Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
82
|
+
}
|
|
83
|
+
function isDocItem(value) {
|
|
84
|
+
return (isRecord(value) &&
|
|
85
|
+
typeof value.name === 'string' &&
|
|
86
|
+
typeof value.path === 'string' &&
|
|
87
|
+
(value.type === 'file' || value.type === 'dir') &&
|
|
88
|
+
(value.sha === undefined || typeof value.sha === 'string'));
|
|
89
|
+
}
|
|
90
|
+
function parseStoredTree(value) {
|
|
91
|
+
if (value === undefined)
|
|
92
|
+
return undefined;
|
|
93
|
+
try {
|
|
94
|
+
const items = JSON.parse(value);
|
|
95
|
+
return Array.isArray(items) && items.every(isDocItem) ? items : undefined;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
66
101
|
function copyItems(items) {
|
|
67
102
|
return items.map((item) => ({ ...item }));
|
|
68
103
|
}
|
|
@@ -155,6 +190,15 @@ function assertBranchRef(value) {
|
|
|
155
190
|
throw new TypeError('branch must be a valid Git ref');
|
|
156
191
|
}
|
|
157
192
|
}
|
|
193
|
+
function mirrorBase(value) {
|
|
194
|
+
if (value === undefined)
|
|
195
|
+
return undefined;
|
|
196
|
+
const url = URL.canParse(value) ? new URL(value) : undefined;
|
|
197
|
+
if (!url || !['https:', 'http:'].includes(url.protocol) || url.search || url.hash) {
|
|
198
|
+
throw new TypeError('mirror must be an http(s) URL without a query or fragment');
|
|
199
|
+
}
|
|
200
|
+
return url.href.replace(/\/+$/, '');
|
|
201
|
+
}
|
|
158
202
|
function cacheTtl(value, fallback, name) {
|
|
159
203
|
const ttl = value ?? fallback;
|
|
160
204
|
if (!Number.isFinite(ttl) || ttl < 0) {
|
|
@@ -245,6 +289,8 @@ export function createDocsClient(options = {}) {
|
|
|
245
289
|
const apiRepoUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
|
|
246
290
|
const rawRepoUrl = `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
|
|
247
291
|
const encodedBranch = encodeURIComponent(branch);
|
|
292
|
+
const mirror = mirrorBase(options.mirror);
|
|
293
|
+
let mirrorFailed = false;
|
|
248
294
|
const dirTtlMs = cacheTtl(options.cacheTtlMs?.dir, DEFAULTS.dirTtlMs, 'cacheTtlMs.dir');
|
|
249
295
|
const fileTtlMs = cacheTtl(options.cacheTtlMs?.file, DEFAULTS.fileTtlMs, 'cacheTtlMs.file');
|
|
250
296
|
const dirCache = new TtlCache(dirTtlMs, 30);
|
|
@@ -253,7 +299,32 @@ export function createDocsClient(options = {}) {
|
|
|
253
299
|
const dirRequests = new Map();
|
|
254
300
|
const fileRequests = new Map();
|
|
255
301
|
const treeRequests = new Map();
|
|
302
|
+
const store = options.store;
|
|
303
|
+
let storedTree;
|
|
256
304
|
let cacheGeneration = 0;
|
|
305
|
+
function storeRead(key) {
|
|
306
|
+
try {
|
|
307
|
+
return store?.read(key);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function storeWrite(key, value) {
|
|
314
|
+
try {
|
|
315
|
+
store?.write(key, value);
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function knownTree() {
|
|
322
|
+
const fetched = treeCache.getStale(TREE_KEY);
|
|
323
|
+
if (fetched)
|
|
324
|
+
return fetched;
|
|
325
|
+
storedTree ?? (storedTree = parseStoredTree(storeRead(TREE_KEY)));
|
|
326
|
+
return storedTree;
|
|
327
|
+
}
|
|
257
328
|
function headers() {
|
|
258
329
|
const requestHeaders = {
|
|
259
330
|
Accept: 'application/vnd.github.v3+json',
|
|
@@ -262,14 +333,14 @@ export function createDocsClient(options = {}) {
|
|
|
262
333
|
requestHeaders.Authorization = `Bearer ${token}`;
|
|
263
334
|
return requestHeaders;
|
|
264
335
|
}
|
|
265
|
-
async function withResponse(url, timeoutMs, consume) {
|
|
336
|
+
async function withResponse(url, timeoutMs, consume, requestHeaders = headers()) {
|
|
266
337
|
const ctrl = new AbortController();
|
|
267
338
|
const timer = setTimeout(() => {
|
|
268
339
|
ctrl.abort();
|
|
269
340
|
}, timeoutMs);
|
|
270
341
|
let response;
|
|
271
342
|
try {
|
|
272
|
-
response = await fetch(url, { signal: ctrl.signal, headers:
|
|
343
|
+
response = await fetch(url, { signal: ctrl.signal, headers: requestHeaders });
|
|
273
344
|
return await consume(response);
|
|
274
345
|
}
|
|
275
346
|
finally {
|
|
@@ -278,6 +349,36 @@ export function createDocsClient(options = {}) {
|
|
|
278
349
|
cancelUnusedResponseBody(response);
|
|
279
350
|
}
|
|
280
351
|
}
|
|
352
|
+
async function fromMirror(base, path, read) {
|
|
353
|
+
if (mirrorFailed)
|
|
354
|
+
return undefined;
|
|
355
|
+
try {
|
|
356
|
+
return await withResponse(`${base}/${path}`, MIRROR_TIMEOUT_MS, async (response) => {
|
|
357
|
+
if (response.status >= 500)
|
|
358
|
+
mirrorFailed = true;
|
|
359
|
+
return response.ok ? await read(response) : undefined;
|
|
360
|
+
}, {});
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
mirrorFailed = true;
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
function keepTree(items, generation) {
|
|
368
|
+
if (generation === cacheGeneration) {
|
|
369
|
+
treeCache.set(TREE_KEY, copyItems(items));
|
|
370
|
+
if (store)
|
|
371
|
+
storeWrite(TREE_KEY, JSON.stringify(items));
|
|
372
|
+
}
|
|
373
|
+
return items;
|
|
374
|
+
}
|
|
375
|
+
async function keepFile(path, content, generation) {
|
|
376
|
+
if (generation === cacheGeneration)
|
|
377
|
+
fileCache.set(path, content);
|
|
378
|
+
if (store)
|
|
379
|
+
storeWrite(`blob-${await blobSha(content)}`, content);
|
|
380
|
+
return content;
|
|
381
|
+
}
|
|
281
382
|
function recoverFailure(cache, key, path, error, copy = (value) => value) {
|
|
282
383
|
const stale = cache.getStale(key);
|
|
283
384
|
if (stale !== undefined)
|
|
@@ -334,7 +435,15 @@ export function createDocsClient(options = {}) {
|
|
|
334
435
|
return shareRequest(dirRequests, path, () => loadDir(path, cacheGeneration)).then(copyItems);
|
|
335
436
|
}
|
|
336
437
|
async function loadAll(generation) {
|
|
337
|
-
const key =
|
|
438
|
+
const key = TREE_KEY;
|
|
439
|
+
if (mirror !== undefined) {
|
|
440
|
+
const mirrored = await fromMirror(mirror, 'index.json', async (response) => {
|
|
441
|
+
const data = parseTreeResponse(await response.json());
|
|
442
|
+
return data.truncated ? undefined : filterTree(data.tree);
|
|
443
|
+
});
|
|
444
|
+
if (mirrored)
|
|
445
|
+
return keepTree(mirrored, generation);
|
|
446
|
+
}
|
|
338
447
|
const url = `${apiRepoUrl}/git/trees/${encodedBranch}?recursive=1`;
|
|
339
448
|
try {
|
|
340
449
|
return await withResponse(url, 20000, async (response) => {
|
|
@@ -351,10 +460,7 @@ export function createDocsClient(options = {}) {
|
|
|
351
460
|
return copyItems(stale);
|
|
352
461
|
throw new DocsFetchError('', null, 'GitHub truncated the repository tree (too many files) -- results would be incomplete');
|
|
353
462
|
}
|
|
354
|
-
|
|
355
|
-
if (generation === cacheGeneration)
|
|
356
|
-
treeCache.set(key, copyItems(items));
|
|
357
|
-
return items;
|
|
463
|
+
return keepTree(filterTree(data.tree), generation);
|
|
358
464
|
});
|
|
359
465
|
}
|
|
360
466
|
catch (error) {
|
|
@@ -364,16 +470,37 @@ export function createDocsClient(options = {}) {
|
|
|
364
470
|
}
|
|
365
471
|
}
|
|
366
472
|
function listAll() {
|
|
367
|
-
const
|
|
368
|
-
const hit = treeCache.get(key);
|
|
473
|
+
const hit = treeCache.get(TREE_KEY);
|
|
369
474
|
if (hit)
|
|
370
475
|
return Promise.resolve(copyItems(hit));
|
|
371
|
-
return shareRequest(treeRequests,
|
|
476
|
+
return shareRequest(treeRequests, TREE_KEY, () => loadAll(cacheGeneration)).then(copyItems);
|
|
477
|
+
}
|
|
478
|
+
function peekAll() {
|
|
479
|
+
const items = knownTree();
|
|
480
|
+
return items && copyItems(items);
|
|
372
481
|
}
|
|
373
482
|
async function listSections() {
|
|
374
483
|
return sectionsFromItems(await listAll());
|
|
375
484
|
}
|
|
485
|
+
async function loadStoredFile(path) {
|
|
486
|
+
const sha = knownTree()?.find((item) => item.path === path)?.sha;
|
|
487
|
+
if (sha === undefined)
|
|
488
|
+
return undefined;
|
|
489
|
+
const content = storeRead(`blob-${sha}`);
|
|
490
|
+
return content !== undefined && (await blobSha(content)) === sha ? content : undefined;
|
|
491
|
+
}
|
|
376
492
|
async function loadFile(path, generation) {
|
|
493
|
+
const stored = store && (await loadStoredFile(path));
|
|
494
|
+
if (stored !== undefined) {
|
|
495
|
+
if (generation === cacheGeneration)
|
|
496
|
+
fileCache.set(path, stored);
|
|
497
|
+
return stored;
|
|
498
|
+
}
|
|
499
|
+
if (mirror !== undefined) {
|
|
500
|
+
const mirrored = await fromMirror(mirror, `raw/${encodePath(path)}`, (response) => response.text());
|
|
501
|
+
if (mirrored !== undefined)
|
|
502
|
+
return keepFile(path, mirrored, generation);
|
|
503
|
+
}
|
|
377
504
|
const url = `${rawRepoUrl}/${encodedBranch}/${encodePath(path)}`;
|
|
378
505
|
try {
|
|
379
506
|
return await withResponse(url, 15000, async (response) => {
|
|
@@ -383,10 +510,7 @@ export function createDocsClient(options = {}) {
|
|
|
383
510
|
return stale;
|
|
384
511
|
throw new DocsFetchError(path, response.status, `HTTP ${String(response.status)}`);
|
|
385
512
|
}
|
|
386
|
-
|
|
387
|
-
if (generation === cacheGeneration)
|
|
388
|
-
fileCache.set(path, content);
|
|
389
|
-
return content;
|
|
513
|
+
return keepFile(path, await response.text(), generation);
|
|
390
514
|
});
|
|
391
515
|
}
|
|
392
516
|
catch (error) {
|
|
@@ -451,6 +575,7 @@ export function createDocsClient(options = {}) {
|
|
|
451
575
|
}
|
|
452
576
|
function clear() {
|
|
453
577
|
cacheGeneration += 1;
|
|
578
|
+
storedTree = undefined;
|
|
454
579
|
dirCache.clear();
|
|
455
580
|
fileCache.clear();
|
|
456
581
|
treeCache.clear();
|
|
@@ -458,5 +583,5 @@ export function createDocsClient(options = {}) {
|
|
|
458
583
|
fileRequests.clear();
|
|
459
584
|
treeRequests.clear();
|
|
460
585
|
}
|
|
461
|
-
return { listDir, listAll, listSections, getFile, getDocument, search, clear };
|
|
586
|
+
return { listDir, listAll, peekAll, listSections, getFile, getDocument, search, clear };
|
|
462
587
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { createDocsClient } from './client.js';
|
|
2
2
|
export { parseDoc } from './content.js';
|
|
3
|
-
export type { DocComponent, DocItem, DocPage, DocSection, DocsClient, DocsClientOptions, DocsSearchOptions, DocsSearchResult, } from './types.js';
|
|
3
|
+
export type { DocComponent, DocItem, DocPage, DocSection, DocsClient, DocsClientOptions, DocsSearchOptions, DocsSearchResult, DocsStore, } from './types.js';
|
|
4
4
|
export { DocsFetchError } from './types.js';
|
package/dist/types.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export interface DocItem {
|
|
|
2
2
|
name: string;
|
|
3
3
|
path: string;
|
|
4
4
|
type: 'file' | 'dir';
|
|
5
|
+
sha?: string;
|
|
5
6
|
}
|
|
6
7
|
export interface DocComponent {
|
|
7
8
|
attributes: Readonly<Record<string, string | true>>;
|
|
@@ -36,6 +37,10 @@ export interface DocsSearchResult {
|
|
|
36
37
|
summary: string;
|
|
37
38
|
title: string;
|
|
38
39
|
}
|
|
40
|
+
export interface DocsStore {
|
|
41
|
+
read(key: string): string | undefined;
|
|
42
|
+
write(key: string, value: string): void;
|
|
43
|
+
}
|
|
39
44
|
export interface DocsClientOptions {
|
|
40
45
|
owner?: string;
|
|
41
46
|
repo?: string;
|
|
@@ -45,10 +50,13 @@ export interface DocsClientOptions {
|
|
|
45
50
|
dir?: number;
|
|
46
51
|
file?: number;
|
|
47
52
|
};
|
|
53
|
+
store?: DocsStore;
|
|
54
|
+
mirror?: string;
|
|
48
55
|
}
|
|
49
56
|
export interface DocsClient {
|
|
50
57
|
listDir(path?: string): Promise<DocItem[]>;
|
|
51
58
|
listAll(): Promise<DocItem[]>;
|
|
59
|
+
peekAll(): DocItem[] | undefined;
|
|
52
60
|
listSections(): Promise<DocSection[]>;
|
|
53
61
|
getFile(path: string): Promise<string>;
|
|
54
62
|
getDocument(path: string): Promise<DocPage>;
|