@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 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: 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 = '__tree__';
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
- const items = filterTree(data.tree);
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 key = '__tree__';
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, key, () => loadAll(cacheGeneration)).then(copyItems);
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
- const content = await response.text();
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>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nbtca/docs",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "GitHub-backed document client for NBTCA",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",