@nbtca/docs 0.3.0 → 0.3.1

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/dist/client.js CHANGED
@@ -165,9 +165,45 @@ function cacheTtl(value, fallback, name) {
165
165
  function isTransientStatus(status) {
166
166
  return status === 408 || status === 429 || status >= 500;
167
167
  }
168
+ function isSecondaryRateLimitMessage(value) {
169
+ if (!isRecord(value) || typeof value.message !== 'string')
170
+ return false;
171
+ const message = value.message.toLowerCase().replace(/\s+/g, ' ').trim();
172
+ return (/\b(?:exceeded|hit|triggered) (?:a |the )?secondary rate limit\b/.test(message) ||
173
+ /\bsecondary rate limit (?:was |has been )?(?:exceeded|hit|triggered)\b/.test(message));
174
+ }
175
+ async function isTransientResponse(response) {
176
+ if (isTransientStatus(response.status))
177
+ return true;
178
+ if (response.status !== 403)
179
+ return false;
180
+ if (response.headers.get('x-ratelimit-remaining') === '0' ||
181
+ response.headers.get('retry-after') !== null) {
182
+ return true;
183
+ }
184
+ try {
185
+ return isSecondaryRateLimitMessage(await response.clone().json());
186
+ }
187
+ catch {
188
+ return false;
189
+ }
190
+ }
168
191
  function reject(error) {
169
192
  return Promise.reject(error instanceof Error ? error : new Error('Operation failed with a non-error value'));
170
193
  }
194
+ function cancelUnusedResponseBody(response) {
195
+ try {
196
+ if (!response || response.bodyUsed)
197
+ return;
198
+ const body = response.body;
199
+ if (!body)
200
+ return;
201
+ void body.cancel().catch(() => undefined);
202
+ }
203
+ catch {
204
+ // Cleanup must not override the request result.
205
+ }
206
+ }
171
207
  function isRecord(value) {
172
208
  return typeof value === 'object' && value !== null;
173
209
  }
@@ -231,12 +267,15 @@ export function createDocsClient(options = {}) {
231
267
  const timer = setTimeout(() => {
232
268
  ctrl.abort();
233
269
  }, timeoutMs);
270
+ let response;
234
271
  try {
235
- const response = await fetch(url, { signal: ctrl.signal, headers: headers() });
272
+ response = await fetch(url, { signal: ctrl.signal, headers: headers() });
236
273
  return await consume(response);
237
274
  }
238
275
  finally {
239
276
  clearTimeout(timer);
277
+ // Not awaited: a custom transport's cancel may never settle.
278
+ cancelUnusedResponseBody(response);
240
279
  }
241
280
  }
242
281
  function recoverFailure(cache, key, path, error, copy = (value) => value) {
@@ -265,7 +304,7 @@ export function createDocsClient(options = {}) {
265
304
  return await withResponse(url, 10000, async (response) => {
266
305
  if (!response.ok) {
267
306
  const stale = dirCache.getStale(path);
268
- if (isTransientStatus(response.status) && stale !== undefined)
307
+ if (stale !== undefined && (await isTransientResponse(response)))
269
308
  return copyItems(stale);
270
309
  throw new DocsFetchError(path, response.status, `HTTP ${String(response.status)}`);
271
310
  }
@@ -301,7 +340,7 @@ export function createDocsClient(options = {}) {
301
340
  return await withResponse(url, 20000, async (response) => {
302
341
  if (!response.ok) {
303
342
  const stale = treeCache.getStale(key);
304
- if (isTransientStatus(response.status) && stale !== undefined)
343
+ if (stale !== undefined && (await isTransientResponse(response)))
305
344
  return copyItems(stale);
306
345
  throw new DocsFetchError('', response.status, `HTTP ${String(response.status)}`);
307
346
  }
@@ -340,7 +379,7 @@ export function createDocsClient(options = {}) {
340
379
  return await withResponse(url, 15000, async (response) => {
341
380
  if (!response.ok) {
342
381
  const stale = fileCache.getStale(path);
343
- if (isTransientStatus(response.status) && stale !== undefined)
382
+ if (stale !== undefined && (await isTransientResponse(response)))
344
383
  return stale;
345
384
  throw new DocsFetchError(path, response.status, `HTTP ${String(response.status)}`);
346
385
  }
package/dist/content.js CHANGED
@@ -1,5 +1,10 @@
1
1
  const SUMMARY_LENGTH = 160;
2
2
  const EXCERPT_LENGTH = 180;
3
+ const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
4
+ const LIST_ITEM = /^( {0,3}(?:[-+*]|\d{1,9}[.)]))([ \t]+)/;
5
+ const NORMALIZATION_CHUNK = 512;
6
+ // NFKC never joins these characters to what precedes them, so chunks split before them.
7
+ const NORMALIZATION_BOUNDARY = /[ -~\u4e00-\u9fff]/g;
3
8
  const COMPONENT_ATTRIBUTES = {
4
9
  Band: ['alt', 'source'],
5
10
  Figure: ['alt', 'caption', 'date', 'source'],
@@ -9,11 +14,12 @@ const COMPONENT_ATTRIBUTES = {
9
14
  TimelineEntry: ['year', 'title'],
10
15
  };
11
16
  function splitFrontmatter(content) {
12
- const match = /^(?:\uFEFF)?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(content);
17
+ const source = content.startsWith('\uFEFF') ? content.slice(1) : content;
18
+ const match = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(source);
13
19
  if (!match)
14
- return { body: content, frontmatter: '' };
20
+ return { body: source, frontmatter: '' };
15
21
  return {
16
- body: content.slice(match[0].length),
22
+ body: source.slice(match[0].length),
17
23
  frontmatter: match[1] ?? '',
18
24
  };
19
25
  }
@@ -57,18 +63,113 @@ function cleanInline(value) {
57
63
  .replace(/\s+/g, ' ')
58
64
  .trim();
59
65
  }
60
- function extractTitle(body) {
66
+ function quoteContainer(line) {
67
+ let depth = 0;
68
+ let rest = line;
69
+ for (;;) {
70
+ const prefix = /^ {0,3}>[ \t]?/.exec(rest)?.[0];
71
+ if (!prefix)
72
+ return { depth, rest };
73
+ depth += 1;
74
+ rest = rest.slice(prefix.length);
75
+ }
76
+ }
77
+ function transitionFence(line, current) {
78
+ const quote = quoteContainer(line);
79
+ let candidate = quote.rest;
80
+ let listIndent = 0;
81
+ if (current) {
82
+ if (quote.depth < current.quoteDepth)
83
+ return transitionFence(line, undefined);
84
+ if (quote.depth !== current.quoteDepth)
85
+ return { delimiter: false, fence: current };
86
+ if (current.listIndent > 0) {
87
+ const indentation = /^ */.exec(candidate)?.[0].length ?? 0;
88
+ if (candidate.trim() !== '' && indentation < current.listIndent) {
89
+ return transitionFence(line, undefined);
90
+ }
91
+ candidate = candidate.slice(Math.min(indentation, current.listIndent));
92
+ }
93
+ }
94
+ else {
95
+ listIndent = LIST_ITEM.exec(candidate)?.[0].length ?? 0;
96
+ candidate = candidate.slice(listIndent);
97
+ }
98
+ const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(candidate);
99
+ const sequence = match?.[1];
100
+ if (!sequence)
101
+ return { delimiter: false, fence: current };
102
+ const marker = sequence.startsWith('`') ? '`' : '~';
103
+ const suffix = match[2] ?? '';
104
+ if (!current) {
105
+ if (marker === '`' && suffix.includes('`')) {
106
+ return { delimiter: false, fence: undefined };
107
+ }
108
+ return {
109
+ delimiter: true,
110
+ fence: { length: sequence.length, listIndent, marker, quoteDepth: quote.depth },
111
+ };
112
+ }
113
+ if (marker === current.marker && sequence.length >= current.length && /^[ \t]*$/.test(suffix)) {
114
+ return { delimiter: true, fence: undefined };
115
+ }
116
+ return { delimiter: false, fence: current };
117
+ }
118
+ function indentWidth(line) {
119
+ let width = 0;
120
+ for (const character of line) {
121
+ if (character === ' ')
122
+ width += 1;
123
+ else if (character === '\t')
124
+ width += 4 - (width % 4);
125
+ else
126
+ break;
127
+ }
128
+ return width;
129
+ }
130
+ function proseLines(body) {
61
131
  let fence;
62
- for (const line of body.split(/\r?\n/)) {
63
- const marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1]?.slice(0, 1);
64
- if (marker) {
65
- if (!fence)
66
- fence = marker;
67
- else if (marker === fence)
68
- fence = undefined;
69
- continue;
132
+ let afterBreak = true;
133
+ let inCode = false;
134
+ let listIndent = 0;
135
+ return body.split(/\r?\n/).map((line) => {
136
+ const transition = transitionFence(line, fence);
137
+ fence = transition.fence;
138
+ if (transition.delimiter || fence) {
139
+ afterBreak = true;
140
+ inCode = false;
141
+ return undefined;
70
142
  }
71
- if (fence)
143
+ const candidate = quoteContainer(line).rest;
144
+ if (candidate.trim() === '') {
145
+ afterBreak = true;
146
+ return line;
147
+ }
148
+ const width = indentWidth(candidate);
149
+ if (afterBreak && width < listIndent)
150
+ listIndent = 0;
151
+ inCode = width - listIndent >= 4 && (afterBreak || inCode);
152
+ afterBreak = false;
153
+ if (inCode)
154
+ return undefined;
155
+ const item = LIST_ITEM.exec(candidate);
156
+ if (!item)
157
+ return line;
158
+ const marker = item[1]?.length ?? 0;
159
+ const padding = item[2] ?? '';
160
+ const rest = candidate.slice(item[0].length);
161
+ if (padding.includes('\t') || padding.length >= 5 || /^(?: {4}|\t)/.test(rest)) {
162
+ listIndent = marker + 1;
163
+ inCode = true;
164
+ return undefined;
165
+ }
166
+ listIndent = marker + padding.length;
167
+ return line;
168
+ });
169
+ }
170
+ function extractTitle(body) {
171
+ for (const line of proseLines(body)) {
172
+ if (line === undefined)
72
173
  continue;
73
174
  const match = /^#\s+(.+?)\s*$/.exec(line);
74
175
  if (match?.[1])
@@ -77,17 +178,19 @@ function extractTitle(body) {
77
178
  return undefined;
78
179
  }
79
180
  function truncate(value, length) {
80
- const characters = [];
81
- for (const character of value)
82
- characters.push(character);
83
- if (characters.length <= length)
84
- return value;
85
- return `${characters.slice(0, length).join('').trimEnd()}…`;
181
+ let end = 0;
182
+ let codePoints = 0;
183
+ for (const { index, segment } of GRAPHEME_SEGMENTER.segment(value)) {
184
+ codePoints += Array.from(segment).length;
185
+ if (codePoints > length)
186
+ return `${value.slice(0, end).trimEnd()}…`;
187
+ end = index + segment.length;
188
+ }
189
+ return value;
86
190
  }
87
191
  function extractSummary(body) {
88
192
  const paragraphs = [];
89
193
  let current = [];
90
- let fence;
91
194
  let inContainer = false;
92
195
  let inTag = false;
93
196
  let hiddenTag;
@@ -96,19 +199,12 @@ function extractSummary(body) {
96
199
  paragraphs.push(current.join(' '));
97
200
  current = [];
98
201
  };
99
- for (const rawLine of body.split(/\r?\n/)) {
100
- const line = rawLine.trim();
101
- const marker = /^(`{3,}|~{3,})/.exec(line)?.[1]?.slice(0, 1);
102
- if (marker) {
103
- if (!fence)
104
- fence = marker;
105
- else if (marker === fence)
106
- fence = undefined;
202
+ for (const rawLine of proseLines(body)) {
203
+ if (rawLine === undefined) {
107
204
  finishParagraph();
108
205
  continue;
109
206
  }
110
- if (fence)
111
- continue;
207
+ const line = rawLine.trim();
112
208
  if (hiddenTag) {
113
209
  if (line.toLowerCase().includes(`</${hiddenTag}>`))
114
210
  hiddenTag = undefined;
@@ -175,21 +271,10 @@ function parseAttributes(source) {
175
271
  return attributes;
176
272
  }
177
273
  function componentSource(body) {
178
- const lines = [];
179
- let fence;
180
- for (const line of body.split(/\r?\n/)) {
181
- const marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1]?.slice(0, 1);
182
- if (marker) {
183
- if (!fence)
184
- fence = marker;
185
- else if (marker === fence)
186
- fence = undefined;
187
- continue;
188
- }
189
- if (!fence)
190
- lines.push(line);
191
- }
192
- return lines.join('\n').replace(/<!--[\s\S]*?-->/g, ' ');
274
+ return proseLines(body)
275
+ .filter((line) => line !== undefined)
276
+ .join('\n')
277
+ .replace(/<!--[\s\S]*?-->/g, ' ');
193
278
  }
194
279
  function extractComponents(body) {
195
280
  const components = [];
@@ -258,7 +343,75 @@ export function parseDoc(path, content) {
258
343
  };
259
344
  }
260
345
  function normalize(value) {
261
- return value.normalize('NFKC').toLowerCase();
346
+ // Lowercasing keeps final sigma (ς) where a search for Σ yields σ.
347
+ return value
348
+ .normalize('NFKC')
349
+ .toLowerCase()
350
+ .replace(/\u03c2/g, '\u03c3');
351
+ }
352
+ function normalizeChunks(text) {
353
+ const offsets = [];
354
+ const sources = [];
355
+ const parts = [];
356
+ let length = 0;
357
+ for (let start = 0; start < text.length;) {
358
+ NORMALIZATION_BOUNDARY.lastIndex = start + NORMALIZATION_CHUNK;
359
+ const end = NORMALIZATION_BOUNDARY.exec(text)?.index ?? text.length;
360
+ const part = normalize(text.slice(start, end));
361
+ offsets.push(length);
362
+ sources.push(start);
363
+ parts.push(part);
364
+ length += part.length;
365
+ start = end;
366
+ }
367
+ return { offsets, sources, value: parts.join('') };
368
+ }
369
+ function lastIndexAtMost(count, target, valueAt) {
370
+ let low = 0;
371
+ let high = count - 1;
372
+ while (low < high) {
373
+ const middle = Math.ceil((low + high) / 2);
374
+ if (valueAt(middle) <= target)
375
+ low = middle;
376
+ else
377
+ high = middle - 1;
378
+ }
379
+ return low;
380
+ }
381
+ function graphemeAt(graphemes, position) {
382
+ const part = graphemes.containing(position);
383
+ if (!part)
384
+ return { codePoints: 0, end: position, start: position };
385
+ return {
386
+ codePoints: Array.from(part.segment).length,
387
+ end: part.index + part.segment.length,
388
+ start: part.index,
389
+ };
390
+ }
391
+ function normalizedLength(text, normalized, position) {
392
+ const { offsets, sources } = normalized;
393
+ const chunk = lastIndexAtMost(sources.length, position, (index) => sources[index] ?? 0);
394
+ const start = sources[chunk] ?? 0;
395
+ return (offsets[chunk] ?? 0) + normalize(text.slice(start, position)).length;
396
+ }
397
+ function sourceBoundary(text, normalized, graphemes, offset) {
398
+ const { offsets, sources } = normalized;
399
+ const chunk = lastIndexAtMost(offsets.length, offset, (index) => offsets[index] ?? 0);
400
+ const chunkEnd = sources[chunk + 1] ?? text.length;
401
+ const boundaries = [graphemeAt(graphemes, sources[chunk] ?? 0).start];
402
+ for (let position = boundaries[0] ?? 0; position < chunkEnd;) {
403
+ position = graphemeAt(graphemes, position).end;
404
+ boundaries.push(position);
405
+ }
406
+ const lengths = new Map();
407
+ const lengthAt = (index) => {
408
+ const position = boundaries[index] ?? 0;
409
+ const length = lengths.get(position) ?? normalizedLength(text, normalized, position);
410
+ lengths.set(position, length);
411
+ return length;
412
+ };
413
+ const index = lastIndexAtMost(boundaries.length, offset, lengthAt);
414
+ return { length: lengthAt(index), position: boundaries[index] ?? 0 };
262
415
  }
263
416
  function countMatches(value, term) {
264
417
  let count = 0;
@@ -275,18 +428,50 @@ function countMatches(value, term) {
275
428
  function excerpt(text, query, terms) {
276
429
  if (!text)
277
430
  return '';
278
- const normalized = normalize(text);
279
- const exactIndex = normalized.indexOf(query);
280
- const matchIndex = exactIndex >= 0
281
- ? exactIndex
282
- : Math.min(...terms.map((term) => normalized.indexOf(term)).filter((index) => index >= 0));
431
+ const normalized = normalizeChunks(text);
432
+ const exactIndex = normalized.value.indexOf(query);
433
+ let matchIndex = exactIndex;
434
+ let matchLength = query.length;
435
+ if (matchIndex < 0) {
436
+ matchIndex = Number.POSITIVE_INFINITY;
437
+ for (const term of terms) {
438
+ const index = normalized.value.indexOf(term);
439
+ if (index >= 0 && index < matchIndex) {
440
+ matchIndex = index;
441
+ matchLength = term.length;
442
+ }
443
+ }
444
+ }
283
445
  if (!Number.isFinite(matchIndex))
284
446
  return truncate(text, EXCERPT_LENGTH);
285
- const start = Math.max(0, matchIndex - Math.floor(EXCERPT_LENGTH / 3));
447
+ const graphemes = GRAPHEME_SEGMENTER.segment(text);
448
+ const matchStart = sourceBoundary(text, normalized, graphemes, matchIndex).position;
449
+ const endOffset = Math.min(normalized.value.length, matchIndex + matchLength);
450
+ const floor = sourceBoundary(text, normalized, graphemes, endOffset);
451
+ const matchEnd = floor.length < endOffset ? graphemeAt(graphemes, floor.position).end : floor.position;
452
+ const matchCodePoints = Array.from(text.slice(matchStart, matchEnd)).length;
453
+ const contextLimit = Math.min(Math.floor(EXCERPT_LENGTH / 3), Math.max(0, EXCERPT_LENGTH - matchCodePoints));
454
+ let start = matchStart;
455
+ let contextCodePoints = 0;
456
+ while (start > 0) {
457
+ const previous = graphemeAt(graphemes, start - 1);
458
+ if (contextCodePoints + previous.codePoints > contextLimit)
459
+ break;
460
+ contextCodePoints += previous.codePoints;
461
+ start = previous.start;
462
+ }
463
+ let end = start;
464
+ let selectedCodePoints = 0;
465
+ while (end < text.length) {
466
+ const next = graphemeAt(graphemes, end);
467
+ if (end > start && selectedCodePoints + next.codePoints > EXCERPT_LENGTH)
468
+ break;
469
+ selectedCodePoints += next.codePoints;
470
+ end = next.end;
471
+ }
286
472
  const prefix = start > 0 ? '…' : '';
287
- const value = text.slice(start, start + EXCERPT_LENGTH).trim();
288
- const suffix = start + EXCERPT_LENGTH < text.length ? '…' : '';
289
- return `${prefix}${value}${suffix}`;
473
+ const suffix = end < text.length ? '…' : '';
474
+ return `${prefix}${text.slice(start, end).trim()}${suffix}`;
290
475
  }
291
476
  export function searchDoc(page, query) {
292
477
  const normalizedQuery = normalize(query.trim());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nbtca/docs",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "GitHub-backed document client for NBTCA",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -60,6 +60,6 @@
60
60
  "typescript": "^5.9.3",
61
61
  "typescript-eslint": "^8.67.0",
62
62
  "vite": "6.4.3",
63
- "vitest": "^3.2.7"
63
+ "vitest": "^4.1.11"
64
64
  }
65
65
  }