@nbtca/docs 0.3.1 → 0.3.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/README.md CHANGED
@@ -36,6 +36,7 @@ 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 |
39
40
 
40
41
  ### `docs.listDir(path?)`
41
42
 
@@ -48,7 +49,12 @@ Returns raw file content.
48
49
 
49
50
  ### `docs.listAll()`
50
51
 
51
- Lists every Markdown file through GitHub's recursive tree API.
52
+ Lists every Markdown file through GitHub's recursive tree API. Each item carries its Git blob `sha`.
53
+
54
+ ### `docs.peekAll()`
55
+
56
+ Returns the last known tree without a request: the one fetched in this process, else the one in
57
+ `store`, else `undefined`.
52
58
 
53
59
  ### `docs.listSections()`
54
60
 
@@ -60,6 +66,13 @@ Returns content with its route, section, title, summary, and semantic component
60
66
  metadata covers `PageHero`, `FactStrip`, `LinkCard`, `Split`, `TimelineEntry`, and `Figure` without
61
67
  imposing a renderer.
62
68
 
69
+ ### `store`
70
+
71
+ An object with `read(key): string | undefined` and `write(key, value): void`. The client keeps the
72
+ tree under `tree` and file content under `blob-<sha>`, where `<sha>` is the Git blob id. `getFile`
73
+ serves stored content only when its hash matches the blob id in the last known tree, so a changed
74
+ file is always refetched. Store errors and corrupt entries are ignored; eviction is up to the store.
75
+
63
76
  ### `docs.search(query, options?)`
64
77
 
65
78
  Searches paths, titles, summaries, Markdown text, and semantic component attributes. Results are
package/dist/client.js CHANGED
@@ -30,6 +30,8 @@ const SKIP = new Set([
30
30
  'README.md',
31
31
  'docs',
32
32
  ]);
33
+ const TREE_KEY = 'tree';
34
+ const SHA = /^[0-9a-f]{40}$/;
33
35
  const SEARCH_CONCURRENCY = 6;
34
36
  const SEARCH_RESULT_LIMIT = 20;
35
37
  function filterAndSort(raw) {
@@ -41,6 +43,7 @@ function filterAndSort(raw) {
41
43
  name: item.name,
42
44
  path: item.path,
43
45
  type: item.type === 'dir' ? 'dir' : 'file',
46
+ ...shaOf(item),
44
47
  }))
45
48
  .sort((a, b) => {
46
49
  if (a.type !== b.type)
@@ -60,9 +63,40 @@ function filterTree(items) {
60
63
  name: item.path.slice(item.path.lastIndexOf('/') + 1),
61
64
  path: item.path,
62
65
  type: 'file',
66
+ ...shaOf(item),
63
67
  }))
64
68
  .sort((a, b) => a.path.localeCompare(b.path));
65
69
  }
70
+ function shaOf(item) {
71
+ return typeof item.sha === 'string' && SHA.test(item.sha) ? { sha: item.sha } : {};
72
+ }
73
+ async function blobSha(content) {
74
+ const body = new TextEncoder().encode(content);
75
+ const header = new TextEncoder().encode(`blob ${String(body.length)}\0`);
76
+ const object = new Uint8Array(header.length + body.length);
77
+ object.set(header);
78
+ object.set(body, header.length);
79
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-1', object));
80
+ return Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('');
81
+ }
82
+ function isDocItem(value) {
83
+ return (isRecord(value) &&
84
+ typeof value.name === 'string' &&
85
+ typeof value.path === 'string' &&
86
+ (value.type === 'file' || value.type === 'dir') &&
87
+ (value.sha === undefined || typeof value.sha === 'string'));
88
+ }
89
+ function parseStoredTree(value) {
90
+ if (value === undefined)
91
+ return undefined;
92
+ try {
93
+ const items = JSON.parse(value);
94
+ return Array.isArray(items) && items.every(isDocItem) ? items : undefined;
95
+ }
96
+ catch {
97
+ return undefined;
98
+ }
99
+ }
66
100
  function copyItems(items) {
67
101
  return items.map((item) => ({ ...item }));
68
102
  }
@@ -253,7 +287,32 @@ export function createDocsClient(options = {}) {
253
287
  const dirRequests = new Map();
254
288
  const fileRequests = new Map();
255
289
  const treeRequests = new Map();
290
+ const store = options.store;
291
+ let storedTree;
256
292
  let cacheGeneration = 0;
293
+ function storeRead(key) {
294
+ try {
295
+ return store?.read(key);
296
+ }
297
+ catch {
298
+ return undefined;
299
+ }
300
+ }
301
+ function storeWrite(key, value) {
302
+ try {
303
+ store?.write(key, value);
304
+ }
305
+ catch {
306
+ return;
307
+ }
308
+ }
309
+ function knownTree() {
310
+ const fetched = treeCache.getStale(TREE_KEY);
311
+ if (fetched)
312
+ return fetched;
313
+ storedTree ?? (storedTree = parseStoredTree(storeRead(TREE_KEY)));
314
+ return storedTree;
315
+ }
257
316
  function headers() {
258
317
  const requestHeaders = {
259
318
  Accept: 'application/vnd.github.v3+json',
@@ -334,7 +393,7 @@ export function createDocsClient(options = {}) {
334
393
  return shareRequest(dirRequests, path, () => loadDir(path, cacheGeneration)).then(copyItems);
335
394
  }
336
395
  async function loadAll(generation) {
337
- const key = '__tree__';
396
+ const key = TREE_KEY;
338
397
  const url = `${apiRepoUrl}/git/trees/${encodedBranch}?recursive=1`;
339
398
  try {
340
399
  return await withResponse(url, 20000, async (response) => {
@@ -352,8 +411,11 @@ export function createDocsClient(options = {}) {
352
411
  throw new DocsFetchError('', null, 'GitHub truncated the repository tree (too many files) -- results would be incomplete');
353
412
  }
354
413
  const items = filterTree(data.tree);
355
- if (generation === cacheGeneration)
414
+ if (generation === cacheGeneration) {
356
415
  treeCache.set(key, copyItems(items));
416
+ if (store)
417
+ storeWrite(key, JSON.stringify(items));
418
+ }
357
419
  return items;
358
420
  });
359
421
  }
@@ -364,16 +426,32 @@ export function createDocsClient(options = {}) {
364
426
  }
365
427
  }
366
428
  function listAll() {
367
- const key = '__tree__';
368
- const hit = treeCache.get(key);
429
+ const hit = treeCache.get(TREE_KEY);
369
430
  if (hit)
370
431
  return Promise.resolve(copyItems(hit));
371
- return shareRequest(treeRequests, key, () => loadAll(cacheGeneration)).then(copyItems);
432
+ return shareRequest(treeRequests, TREE_KEY, () => loadAll(cacheGeneration)).then(copyItems);
433
+ }
434
+ function peekAll() {
435
+ const items = knownTree();
436
+ return items && copyItems(items);
372
437
  }
373
438
  async function listSections() {
374
439
  return sectionsFromItems(await listAll());
375
440
  }
441
+ async function loadStoredFile(path) {
442
+ const sha = knownTree()?.find((item) => item.path === path)?.sha;
443
+ if (sha === undefined)
444
+ return undefined;
445
+ const content = storeRead(`blob-${sha}`);
446
+ return content !== undefined && (await blobSha(content)) === sha ? content : undefined;
447
+ }
376
448
  async function loadFile(path, generation) {
449
+ const stored = store && (await loadStoredFile(path));
450
+ if (stored !== undefined) {
451
+ if (generation === cacheGeneration)
452
+ fileCache.set(path, stored);
453
+ return stored;
454
+ }
377
455
  const url = `${rawRepoUrl}/${encodedBranch}/${encodePath(path)}`;
378
456
  try {
379
457
  return await withResponse(url, 15000, async (response) => {
@@ -386,6 +464,8 @@ export function createDocsClient(options = {}) {
386
464
  const content = await response.text();
387
465
  if (generation === cacheGeneration)
388
466
  fileCache.set(path, content);
467
+ if (store)
468
+ storeWrite(`blob-${await blobSha(content)}`, content);
389
469
  return content;
390
470
  });
391
471
  }
@@ -451,6 +531,7 @@ export function createDocsClient(options = {}) {
451
531
  }
452
532
  function clear() {
453
533
  cacheGeneration += 1;
534
+ storedTree = undefined;
454
535
  dirCache.clear();
455
536
  fileCache.clear();
456
537
  treeCache.clear();
@@ -458,5 +539,5 @@ export function createDocsClient(options = {}) {
458
539
  fileRequests.clear();
459
540
  treeRequests.clear();
460
541
  }
461
- return { listDir, listAll, listSections, getFile, getDocument, search, clear };
542
+ return { listDir, listAll, peekAll, listSections, getFile, getDocument, search, clear };
462
543
  }
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,12 @@ export interface DocsClientOptions {
45
50
  dir?: number;
46
51
  file?: number;
47
52
  };
53
+ store?: DocsStore;
48
54
  }
49
55
  export interface DocsClient {
50
56
  listDir(path?: string): Promise<DocItem[]>;
51
57
  listAll(): Promise<DocItem[]>;
58
+ peekAll(): DocItem[] | undefined;
52
59
  listSections(): Promise<DocSection[]>;
53
60
  getFile(path: string): Promise<string>;
54
61
  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.2",
4
4
  "description": "GitHub-backed document client for NBTCA",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",