@nbtca/prompt 1.0.23 → 1.0.25

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.
Files changed (83) hide show
  1. package/dist/config/data.js +5 -1
  2. package/dist/config/paths.js +24 -0
  3. package/dist/config/preferences.js +4 -15
  4. package/dist/core/icons.js +18 -5
  5. package/dist/core/logo.js +1 -2
  6. package/dist/core/menu.js +7 -122
  7. package/dist/core/text.js +23 -14
  8. package/dist/core/ui.js +16 -1
  9. package/dist/core/vim-keys.js +11 -6
  10. package/dist/features/calendar.js +66 -16
  11. package/dist/features/docs.js +270 -131
  12. package/dist/features/links.js +37 -0
  13. package/dist/features/settings.js +120 -0
  14. package/dist/features/status.js +28 -32
  15. package/dist/features/theme.js +9 -101
  16. package/dist/features/update.js +74 -0
  17. package/dist/i18n/index.js +20 -21
  18. package/dist/i18n/locales/en.json +43 -54
  19. package/dist/i18n/locales/zh.json +43 -54
  20. package/dist/index.js +115 -84
  21. package/dist/main.js +13 -14
  22. package/package.json +13 -11
  23. package/dist/config/data.d.ts +0 -23
  24. package/dist/config/data.d.ts.map +0 -1
  25. package/dist/config/data.js.map +0 -1
  26. package/dist/config/preferences.d.ts +0 -14
  27. package/dist/config/preferences.d.ts.map +0 -1
  28. package/dist/config/preferences.js.map +0 -1
  29. package/dist/config/theme.d.ts +0 -23
  30. package/dist/config/theme.d.ts.map +0 -1
  31. package/dist/config/theme.js +0 -25
  32. package/dist/config/theme.js.map +0 -1
  33. package/dist/core/icons.d.ts +0 -3
  34. package/dist/core/icons.d.ts.map +0 -1
  35. package/dist/core/icons.js.map +0 -1
  36. package/dist/core/logo.d.ts +0 -9
  37. package/dist/core/logo.d.ts.map +0 -1
  38. package/dist/core/logo.js.map +0 -1
  39. package/dist/core/menu.d.ts +0 -14
  40. package/dist/core/menu.d.ts.map +0 -1
  41. package/dist/core/menu.js.map +0 -1
  42. package/dist/core/text.d.ts +0 -7
  43. package/dist/core/text.d.ts.map +0 -1
  44. package/dist/core/text.js.map +0 -1
  45. package/dist/core/ui.d.ts +0 -38
  46. package/dist/core/ui.d.ts.map +0 -1
  47. package/dist/core/ui.js.map +0 -1
  48. package/dist/core/vim-keys.d.ts +0 -8
  49. package/dist/core/vim-keys.d.ts.map +0 -1
  50. package/dist/core/vim-keys.js.map +0 -1
  51. package/dist/features/calendar.d.ts +0 -29
  52. package/dist/features/calendar.d.ts.map +0 -1
  53. package/dist/features/calendar.js.map +0 -1
  54. package/dist/features/docs.d.ts +0 -8
  55. package/dist/features/docs.d.ts.map +0 -1
  56. package/dist/features/docs.js.map +0 -1
  57. package/dist/features/repair.d.ts +0 -10
  58. package/dist/features/repair.d.ts.map +0 -1
  59. package/dist/features/repair.js +0 -29
  60. package/dist/features/repair.js.map +0 -1
  61. package/dist/features/status.d.ts +0 -31
  62. package/dist/features/status.d.ts.map +0 -1
  63. package/dist/features/status.js.map +0 -1
  64. package/dist/features/theme.d.ts +0 -8
  65. package/dist/features/theme.d.ts.map +0 -1
  66. package/dist/features/theme.js.map +0 -1
  67. package/dist/features/website.d.ts +0 -30
  68. package/dist/features/website.d.ts.map +0 -1
  69. package/dist/features/website.js +0 -48
  70. package/dist/features/website.js.map +0 -1
  71. package/dist/i18n/index.d.ts +0 -209
  72. package/dist/i18n/index.d.ts.map +0 -1
  73. package/dist/i18n/index.js.map +0 -1
  74. package/dist/index.d.ts +0 -5
  75. package/dist/index.d.ts.map +0 -1
  76. package/dist/index.js.map +0 -1
  77. package/dist/main.d.ts +0 -12
  78. package/dist/main.d.ts.map +0 -1
  79. package/dist/main.js.map +0 -1
  80. package/dist/types.d.ts +0 -48
  81. package/dist/types.d.ts.map +0 -1
  82. package/dist/types.js +0 -6
  83. package/dist/types.js.map +0 -1
@@ -2,17 +2,17 @@
2
2
  * 知识库终端查看模块
3
3
  * 获取并渲染Markdown文档
4
4
  */
5
- import axios from 'axios';
6
5
  import { marked } from 'marked';
7
6
  import TerminalRenderer from 'marked-terminal';
8
7
  import chalk from 'chalk';
9
8
  import open from 'open';
10
- import { select, isCancel, confirm } from '@clack/prompts';
9
+ import { select, isCancel, confirm, text } from '@clack/prompts';
11
10
  import { error, warning, success, createSpinner } from '../core/ui.js';
12
11
  import { pickIcon } from '../core/icons.js';
13
12
  import { spawn, execFileSync } from 'child_process';
14
- import { URLS } from '../config/data.js';
15
- import { t } from '../i18n/index.js';
13
+ import { APP_INFO, GITHUB_REPO, URLS } from '../config/data.js';
14
+ import { t, fmt } from '../i18n/index.js';
15
+ import { setVimKeysActive } from '../core/vim-keys.js';
16
16
  function detectTerminalType() {
17
17
  const term = (process.env['TERM'] || '').toLowerCase();
18
18
  const termProgram = (process.env['TERM_PROGRAM'] || '').toLowerCase();
@@ -38,8 +38,27 @@ function commandExists(cmd) {
38
38
  return false;
39
39
  }
40
40
  }
41
- const TERMINAL_TYPE = detectTerminalType();
42
- const HAS_GLOW = commandExists('glow');
41
+ let _terminalType = null;
42
+ function getTerminalType() {
43
+ if (_terminalType === null)
44
+ _terminalType = detectTerminalType();
45
+ return _terminalType;
46
+ }
47
+ let _hasGlow = null;
48
+ function hasGlow() {
49
+ if (_hasGlow === null)
50
+ _hasGlow = commandExists('glow');
51
+ return _hasGlow;
52
+ }
53
+ let _markedConfigured = false;
54
+ function ensureMarkedConfigured() {
55
+ if (_markedConfigured)
56
+ return;
57
+ _markedConfigured = true;
58
+ marked.use({
59
+ renderer: new TerminalRenderer(getRendererOptions(getTerminalType()))
60
+ });
61
+ }
43
62
  // ─── marked-terminal renderer ─────────────────────────────────────────────────
44
63
  function getRendererOptions(type) {
45
64
  // Cap at 80 columns — optimal prose reading width regardless of terminal size
@@ -82,10 +101,7 @@ function getRendererOptions(type) {
82
101
  }
83
102
  };
84
103
  }
85
- // @ts-ignore
86
- marked.setOptions({ renderer: new TerminalRenderer(getRendererOptions(TERMINAL_TYPE)) });
87
104
  // ─── GitHub data layer ────────────────────────────────────────────────────────
88
- const GITHUB_REPO = { owner: 'nbtca', repo: 'documents', branch: 'main' };
89
105
  const GITHUB_TOKEN = process.env['GITHUB_TOKEN'] || process.env['GH_TOKEN'];
90
106
  const SKIP_NAMES = new Set(['node_modules', 'package.json', 'pnpm-lock.yaml']);
91
107
  const DIR_CACHE_TTL_MS = 5 * 60 * 1000;
@@ -96,15 +112,13 @@ const fileCache = new Map();
96
112
  const renderCache = new Map();
97
113
  function getDocCategories() {
98
114
  const trans = t();
99
- const withIcon = (unicodeIcon, asciiIcon, label) => `${pickIcon(unicodeIcon, asciiIcon)} ${label}`;
100
115
  return [
101
- { name: withIcon('📖', '[DOC]', trans.docs.categoryTutorial), path: 'tutorial' },
102
- { name: withIcon('🔧', '[LOG]', trans.docs.categoryRepairLogs), path: '维修日' },
103
- { name: withIcon('🎉', '[EVT]', trans.docs.categoryEvents), path: '相关活动举办' },
104
- { name: withIcon('📋', '[PROC]', trans.docs.categoryProcess), path: 'process' },
105
- { name: withIcon('🛠', '[FIX]', trans.docs.categoryRepair), path: 'repair' },
106
- { name: withIcon('📦', '[ARC]', trans.docs.categoryArchived), path: 'archived' },
107
- { name: withIcon('📄', '[README]', trans.docs.categoryReadme), path: 'README.md' },
116
+ { name: trans.docs.categoryTutorial, path: 'tutorial' },
117
+ { name: trans.docs.categoryRepairLogs, path: '维修日' },
118
+ { name: trans.docs.categoryEvents, path: '相关活动举办' },
119
+ { name: trans.docs.categoryProcess, path: 'process' },
120
+ { name: trans.docs.categoryRepair, path: 'repair' },
121
+ { name: trans.docs.categoryArchived, path: 'archived' },
108
122
  ];
109
123
  }
110
124
  function getFreshCacheValue(cache, key) {
@@ -122,6 +136,18 @@ function getAnyCacheValue(cache, key) {
122
136
  function setCacheValue(cache, key, value, ttlMs) {
123
137
  cache.set(key, { value, expiresAt: Date.now() + ttlMs });
124
138
  }
139
+ const DIR_CACHE_MAX = 30;
140
+ const FILE_CACHE_MAX = 50;
141
+ const RENDER_CACHE_MAX = 50;
142
+ function evictStalest(cache, maxSize) {
143
+ if (cache.size <= maxSize)
144
+ return;
145
+ const oldest = [...cache.entries()]
146
+ .sort((a, b) => a[1].expiresAt - b[1].expiresAt)
147
+ .slice(0, cache.size - maxSize);
148
+ for (const [key] of oldest)
149
+ cache.delete(key);
150
+ }
125
151
  function contentFingerprint(content) {
126
152
  const head = content.slice(0, 80);
127
153
  const tail = content.slice(-80);
@@ -144,19 +170,36 @@ async function fetchGitHubDirectory(path = '', options = {}) {
144
170
  try {
145
171
  const headers = {
146
172
  'Accept': 'application/vnd.github.v3+json',
147
- 'User-Agent': 'NBTCA-CLI'
173
+ 'User-Agent': `NBTCA-CLI/${APP_INFO.version}`,
148
174
  };
149
175
  if (GITHUB_TOKEN)
150
176
  headers['Authorization'] = `Bearer ${GITHUB_TOKEN}`;
151
- const response = await axios.get(url, { timeout: 10000, headers });
152
- const items = response.data
177
+ const controller = new AbortController();
178
+ const timeout = setTimeout(() => controller.abort(), 10000);
179
+ const response = await fetch(url, { signal: controller.signal, headers });
180
+ clearTimeout(timeout);
181
+ if (!response.ok) {
182
+ const trans = t();
183
+ if (response.status === 403) {
184
+ const rateLimitRemaining = response.headers.get('x-ratelimit-remaining');
185
+ const rateLimitReset = response.headers.get('x-ratelimit-reset');
186
+ if (rateLimitRemaining === '0' && rateLimitReset) {
187
+ const resetDate = new Date(Number.parseInt(rateLimitReset, 10) * 1000);
188
+ throw new Error(`${fmt(trans.docs.githubRateLimited, { time: resetDate.toLocaleTimeString() })}\n${trans.docs.githubTokenHint}`);
189
+ }
190
+ throw new Error(`${trans.docs.githubForbidden}\n${trans.docs.githubTokenHint}`);
191
+ }
192
+ throw new Error(`HTTP ${response.status}`);
193
+ }
194
+ const data = (await response.json());
195
+ const items = data
153
196
  .filter((item) => !item.name.startsWith('.') &&
154
197
  !SKIP_NAMES.has(item.name) &&
155
198
  !(item.type === 'file' && !item.name.endsWith('.md')))
156
199
  .map((item) => ({
157
200
  name: item.name,
158
201
  path: item.path,
159
- type: item.type === 'dir' ? 'dir' : 'file',
202
+ type: (item.type === 'dir' ? 'dir' : 'file'),
160
203
  sha: item.sha
161
204
  }))
162
205
  .sort((a, b) => {
@@ -167,6 +210,7 @@ async function fetchGitHubDirectory(path = '', options = {}) {
167
210
  return a.name.localeCompare(b.name);
168
211
  });
169
212
  setCacheValue(dirCache, cacheKey, items, DIR_CACHE_TTL_MS);
213
+ evictStalest(dirCache, DIR_CACHE_MAX);
170
214
  return { data: items, fromCache: false, staleFallback: false };
171
215
  }
172
216
  catch (err) {
@@ -175,17 +219,10 @@ async function fetchGitHubDirectory(path = '', options = {}) {
175
219
  return { data: staleCached, fromCache: true, staleFallback: true };
176
220
  }
177
221
  const trans = t();
178
- const errorMessage = err instanceof Error ? err.message : String(err);
179
- if (err.response?.status === 403) {
180
- const rateLimitRemaining = err.response.headers['x-ratelimit-remaining'];
181
- const rateLimitReset = err.response.headers['x-ratelimit-reset'];
182
- if (rateLimitRemaining === '0') {
183
- const resetDate = new Date(parseInt(rateLimitReset) * 1000);
184
- throw new Error(`${trans.docs.githubRateLimited.replace('{time}', resetDate.toLocaleTimeString())}\n${trans.docs.githubTokenHint}`);
185
- }
186
- throw new Error(`${trans.docs.githubForbidden}\n${trans.docs.githubTokenHint}`);
187
- }
188
- throw new Error(trans.docs.fetchDirFailed.replace('{error}', errorMessage));
222
+ const errorMessage = err instanceof Error
223
+ ? (err.name === 'AbortError' ? 'Request timed out' : err.message)
224
+ : String(err);
225
+ throw new Error(fmt(trans.docs.fetchDirFailed, { error: errorMessage }));
189
226
  }
190
227
  }
191
228
  async function fetchGitHubRawContent(path, options = {}) {
@@ -197,9 +234,18 @@ async function fetchGitHubRawContent(path, options = {}) {
197
234
  }
198
235
  const url = `https://raw.githubusercontent.com/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/${GITHUB_REPO.branch}/${path}`;
199
236
  try {
200
- const response = await axios.get(url, { timeout: 15000, headers: { 'User-Agent': 'NBTCA-CLI' } });
201
- const content = String(response.data);
237
+ const controller = new AbortController();
238
+ const timeout = setTimeout(() => controller.abort(), 15000);
239
+ const response = await fetch(url, {
240
+ signal: controller.signal,
241
+ headers: { 'User-Agent': `NBTCA-CLI/${APP_INFO.version}` },
242
+ });
243
+ clearTimeout(timeout);
244
+ if (!response.ok)
245
+ throw new Error(`HTTP ${response.status}`);
246
+ const content = await response.text();
202
247
  setCacheValue(fileCache, path, content, FILE_CACHE_TTL_MS);
248
+ evictStalest(fileCache, FILE_CACHE_MAX);
203
249
  return { data: content, fromCache: false, staleFallback: false };
204
250
  }
205
251
  catch (err) {
@@ -208,8 +254,10 @@ async function fetchGitHubRawContent(path, options = {}) {
208
254
  return { data: staleCached, fromCache: true, staleFallback: true };
209
255
  }
210
256
  const trans = t();
211
- const errorMessage = err instanceof Error ? err.message : String(err);
212
- throw new Error(trans.docs.fetchFileFailed.replace('{error}', errorMessage));
257
+ const errorMessage = err instanceof Error
258
+ ? (err.name === 'AbortError' ? 'Request timed out' : err.message)
259
+ : String(err);
260
+ throw new Error(fmt(trans.docs.fetchFileFailed, { error: errorMessage }));
213
261
  }
214
262
  }
215
263
  // ─── Content cleaning ─────────────────────────────────────────────────────────
@@ -219,7 +267,7 @@ const CONTAINER_ICONS_ASCII = {
219
267
  const CONTAINER_ICONS_UNICODE = {
220
268
  info: 'ℹ️', tip: '💡', warning: '⚠️', danger: '🚨', details: '▶️'
221
269
  };
222
- function cleanMarkdownContent(content, type) {
270
+ function cleanMarkdownContent(content, type = getTerminalType()) {
223
271
  let c = content;
224
272
  // 1. YAML frontmatter
225
273
  c = c.replace(/^---\n[\s\S]*?\n---\n?/m, '');
@@ -259,9 +307,17 @@ function cleanMarkdownContent(content, type) {
259
307
  c = c.replace(/\n{3,}/g, '\n\n');
260
308
  return c.trim();
261
309
  }
262
- function extractDocTitle(content) {
263
- const match = content.match(/^#\s+(.+)$/m);
264
- return match?.[1]?.trim() ?? null;
310
+ function extractDocTitle(rawContent, cleanedContent) {
311
+ // 1. Try YAML frontmatter title: field (before it was stripped)
312
+ const fmMatch = rawContent.match(/^---\n[\s\S]*?\n---/m);
313
+ if (fmMatch) {
314
+ const titleMatch = fmMatch[0].match(/^title:\s*['"]?(.+?)['"]?\s*$/m);
315
+ if (titleMatch?.[1])
316
+ return titleMatch[1].trim();
317
+ }
318
+ // 2. Fallback to first # H1 heading in cleaned content
319
+ const h1Match = cleanedContent.match(/^#\s+(.+)$/m);
320
+ return h1Match?.[1]?.trim() ?? null;
265
321
  }
266
322
  /** Approximate reading time: ~200 words/min for technical Chinese/English prose. */
267
323
  function estimateReadTime(text) {
@@ -339,116 +395,134 @@ async function displayWithLess(rendered, title, filePath, readTime) {
339
395
  });
340
396
  }
341
397
  // ─── Directory browser ────────────────────────────────────────────────────────
342
- async function browseDirectory(dirPath = '') {
343
- const trans = t();
344
- try {
345
- const s = createSpinner(dirPath ? `${trans.docs.loadingDir}: ${dirPath}` : trans.docs.loading);
346
- const result = await fetchGitHubDirectory(dirPath);
347
- const items = result.data;
348
- s.stop('');
349
- if (result.staleFallback) {
350
- warning(trans.docs.usingCachedData);
398
+ async function browseDirectory(initialPath = '') {
399
+ let currentPath = initialPath;
400
+ while (true) {
401
+ const trans = t();
402
+ let items;
403
+ try {
404
+ const s = createSpinner(currentPath ? `${trans.docs.loadingDir}: ${currentPath}` : trans.docs.loading);
405
+ const result = await fetchGitHubDirectory(currentPath);
406
+ items = result.data;
407
+ s.stop(currentPath || trans.docs.chooseDoc);
408
+ if (result.staleFallback) {
409
+ warning(trans.docs.usingCachedData);
410
+ }
411
+ }
412
+ catch (err) {
413
+ error(trans.docs.loadError);
414
+ const errMsg = err instanceof Error ? err.message : String(err);
415
+ console.log(chalk.gray(` ${trans.docs.errorHint}: ${errMsg}`));
416
+ setVimKeysActive(false);
417
+ const retry = await confirm({ message: trans.docs.retry });
418
+ setVimKeysActive(true);
419
+ if (!isCancel(retry) && retry)
420
+ continue;
421
+ return;
351
422
  }
352
423
  if (items.length === 0) {
353
424
  warning(trans.docs.emptyDir);
425
+ if (currentPath) {
426
+ currentPath = currentPath.split('/').slice(0, -1).join('/');
427
+ continue;
428
+ }
354
429
  return;
355
430
  }
356
431
  const options = [
357
- ...(dirPath ? [{ value: '__back__', label: chalk.gray(`${pickIcon('↑', '[..]')} ${trans.docs.upToParent}`) }] : []),
432
+ ...(currentPath ? [{ value: '__back__', label: chalk.dim(trans.docs.upToParent) }] : []),
358
433
  ...items.map(item => ({
359
434
  value: item.path,
360
435
  label: item.type === 'dir'
361
- ? chalk.cyan(`${pickIcon('📁', '[DIR]')} ${item.name}/`)
362
- : chalk.white(`${pickIcon('📄', '[MD]')} ${item.name}`),
363
- hint: item.type === 'dir' ? 'dir' : '.md',
436
+ ? chalk.cyan(`${item.name}/`)
437
+ : item.name,
438
+ hint: item.type === 'dir' ? 'dir' : undefined,
364
439
  })),
365
- { value: '__exit__', label: chalk.gray(`${pickIcon('✕', '[x]')} ${trans.docs.returnToMenu}`) },
440
+ { value: '__exit__', label: chalk.dim(trans.docs.returnToMenu) },
366
441
  ];
367
442
  const selected = await select({
368
- message: dirPath ? `${trans.docs.currentDir}: ${dirPath}` : trans.docs.chooseDoc,
443
+ message: currentPath ? `${trans.docs.currentDir}: ${currentPath}` : trans.docs.chooseDoc,
369
444
  options,
370
445
  });
371
446
  if (isCancel(selected) || selected === '__exit__')
372
447
  return;
373
448
  if (selected === '__back__') {
374
- const parentPath = dirPath.split('/').slice(0, -1).join('/');
375
- await browseDirectory(parentPath);
449
+ currentPath = currentPath.split('/').slice(0, -1).join('/');
450
+ continue;
376
451
  }
377
- else {
378
- const item = items.find(i => i.path === selected);
379
- if (item?.type === 'dir') {
380
- await browseDirectory(selected);
381
- }
382
- else if (item?.type === 'file') {
383
- await viewMarkdownFile(selected);
384
- await browseDirectory(dirPath);
385
- }
452
+ const item = items.find(i => i.path === selected);
453
+ if (item?.type === 'dir') {
454
+ currentPath = selected;
455
+ continue;
386
456
  }
387
- }
388
- catch (err) {
389
- error(trans.docs.loadError);
390
- console.log(chalk.gray(` ${trans.docs.errorHint}: ${err.message}`));
391
- const retry = await confirm({ message: trans.docs.retry });
392
- if (!isCancel(retry) && retry) {
393
- await browseDirectory(dirPath);
457
+ if (item?.type === 'file') {
458
+ await viewMarkdownFile(selected);
394
459
  }
395
460
  }
396
461
  }
397
462
  // ─── Document viewer ──────────────────────────────────────────────────────────
398
463
  async function viewMarkdownFile(filePath) {
399
464
  const trans = t();
400
- try {
401
- const s = createSpinner(`${trans.docs.loading.replace('...', '')}: ${filePath}`);
402
- const rawResult = await fetchGitHubRawContent(filePath);
403
- if (rawResult.staleFallback) {
404
- warning(trans.docs.usingCachedData);
405
- }
406
- const rawContent = rawResult.data;
407
- const fingerprint = contentFingerprint(rawContent);
408
- const cachedRendered = getFreshCacheValue(renderCache, filePath);
409
- let renderedDoc;
410
- if (cachedRendered && cachedRendered.fingerprint === fingerprint) {
411
- renderedDoc = cachedRendered;
412
- }
413
- else {
414
- const cleaned = cleanMarkdownContent(rawContent, TERMINAL_TYPE);
415
- const title = extractDocTitle(cleaned) || filePath.split('/').pop() || filePath;
416
- const readTime = estimateReadTime(cleaned);
417
- const rendered = await marked(cleaned);
418
- renderedDoc = { fingerprint, cleaned, rendered, title, readTime };
419
- setCacheValue(renderCache, filePath, renderedDoc, RENDER_CACHE_TTL_MS);
420
- }
421
- s.stop(`${chalk.bold(renderedDoc.title)} ${chalk.dim(renderedDoc.readTime)}`);
422
- if (HAS_GLOW) {
423
- await displayWithGlow(renderedDoc.cleaned);
424
- }
425
- else {
426
- await displayWithLess(renderedDoc.rendered, renderedDoc.title, filePath, renderedDoc.readTime);
465
+ while (true) {
466
+ try {
467
+ ensureMarkedConfigured();
468
+ const s = createSpinner(`${trans.docs.loading.replace('...', '')}: ${filePath}`);
469
+ const rawResult = await fetchGitHubRawContent(filePath);
470
+ if (rawResult.staleFallback) {
471
+ warning(trans.docs.usingCachedData);
472
+ }
473
+ const rawContent = rawResult.data;
474
+ const fingerprint = contentFingerprint(rawContent);
475
+ const cachedRendered = getFreshCacheValue(renderCache, filePath);
476
+ let renderedDoc;
477
+ if (cachedRendered && cachedRendered.fingerprint === fingerprint) {
478
+ renderedDoc = cachedRendered;
479
+ }
480
+ else {
481
+ const cleaned = cleanMarkdownContent(rawContent, getTerminalType());
482
+ const title = extractDocTitle(rawContent, cleaned) || filePath.split('/').pop() || filePath;
483
+ const readTime = estimateReadTime(cleaned);
484
+ const rendered = await marked(cleaned);
485
+ renderedDoc = { fingerprint, cleaned, rendered, title, readTime };
486
+ setCacheValue(renderCache, filePath, renderedDoc, RENDER_CACHE_TTL_MS);
487
+ evictStalest(renderCache, RENDER_CACHE_MAX);
488
+ }
489
+ s.stop(`${chalk.bold(renderedDoc.title)} ${chalk.dim(renderedDoc.readTime)}`);
490
+ if (hasGlow()) {
491
+ await displayWithGlow(renderedDoc.cleaned);
492
+ }
493
+ else {
494
+ await displayWithLess(renderedDoc.rendered, renderedDoc.title, filePath, renderedDoc.readTime);
495
+ }
496
+ console.log();
497
+ success(trans.docs.docCompleted);
498
+ console.log();
499
+ const action = await select({
500
+ message: trans.docs.chooseAction,
501
+ options: [
502
+ { value: 'back', label: trans.docs.backToList },
503
+ { value: 'reread', label: trans.docs.reread },
504
+ { value: 'browser', label: trans.docs.openBrowser },
505
+ ],
506
+ });
507
+ if (isCancel(action) || action === 'back')
508
+ return;
509
+ if (action === 'browser') {
510
+ await openDocsInBrowser(filePath);
511
+ return;
512
+ }
513
+ // action === 'reread' → continue loop
427
514
  }
428
- console.log();
429
- success(trans.docs.docCompleted);
430
- console.log();
431
- const action = await select({
432
- message: trans.docs.chooseAction,
433
- options: [
434
- { value: 'back', label: `${pickIcon('←', '[<]')} ${trans.docs.backToList}` },
435
- { value: 'reread', label: `${pickIcon('↻', '[r]')} ${trans.docs.reread}` },
436
- { value: 'browser', label: `${pickIcon('🌐', '[*]')} ${trans.docs.openBrowser}` },
437
- ],
438
- });
439
- if (isCancel(action))
515
+ catch (err) {
516
+ error(trans.docs.loadError);
517
+ const errMsg = err instanceof Error ? err.message : String(err);
518
+ console.log(chalk.gray(` ${trans.docs.errorHint}: ${errMsg}`));
519
+ setVimKeysActive(false);
520
+ const openBrowser = await confirm({ message: trans.docs.openBrowserPrompt });
521
+ setVimKeysActive(true);
522
+ if (!isCancel(openBrowser) && openBrowser) {
523
+ await openDocsInBrowser(filePath);
524
+ }
440
525
  return;
441
- if (action === 'browser')
442
- await openDocsInBrowser(filePath);
443
- if (action === 'reread')
444
- await viewMarkdownFile(filePath);
445
- }
446
- catch (err) {
447
- error(trans.docs.loadError);
448
- console.log(chalk.gray(` ${trans.docs.errorHint}: ${err.message}`));
449
- const openBrowser = await confirm({ message: trans.docs.openBrowserPrompt });
450
- if (!isCancel(openBrowser) && openBrowser) {
451
- await openDocsInBrowser(filePath);
452
526
  }
453
527
  }
454
528
  }
@@ -469,6 +543,71 @@ export async function openDocsInBrowser(path) {
469
543
  }
470
544
  console.log();
471
545
  }
546
+ // ─── Search ────────────────────────────────────────────────────────────────────
547
+ async function searchDocs() {
548
+ const trans = t();
549
+ setVimKeysActive(false);
550
+ const query = await text({
551
+ message: trans.docs.searchPrompt,
552
+ placeholder: trans.docs.searchPlaceholder,
553
+ });
554
+ setVimKeysActive(true);
555
+ if (isCancel(query) || !query.trim())
556
+ return;
557
+ const keyword = query.trim().toLowerCase();
558
+ const s = createSpinner(trans.docs.searching);
559
+ // Fetch all category directories in parallel
560
+ const categories = getDocCategories().filter(c => c.path !== 'README.md');
561
+ const results = [];
562
+ try {
563
+ const fetches = await Promise.allSettled(categories.map(async (cat) => {
564
+ const res = await fetchGitHubDirectory(cat.path);
565
+ return { items: res.data, category: cat.name };
566
+ }));
567
+ for (const result of fetches) {
568
+ if (result.status !== 'fulfilled')
569
+ continue;
570
+ for (const item of result.value.items) {
571
+ const nameLC = item.name.toLowerCase();
572
+ if (nameLC.includes(keyword)) {
573
+ results.push({ name: item.name, path: item.path, category: result.value.category });
574
+ }
575
+ }
576
+ }
577
+ // Also search already-cached file content
578
+ for (const [cachedPath, entry] of fileCache) {
579
+ if (results.some(r => r.path === cachedPath))
580
+ continue;
581
+ if (entry.value.toLowerCase().includes(keyword)) {
582
+ const name = cachedPath.split('/').pop() || cachedPath;
583
+ results.push({ name, path: cachedPath, category: trans.docs.searchResults });
584
+ }
585
+ }
586
+ s.stop(`${results.length} ${trans.docs.searchResults}`);
587
+ }
588
+ catch {
589
+ s.error(trans.docs.loadError);
590
+ return;
591
+ }
592
+ if (results.length === 0) {
593
+ warning(trans.docs.searchNoResults);
594
+ return;
595
+ }
596
+ const selected = await select({
597
+ message: trans.docs.chooseDoc,
598
+ options: [
599
+ ...results.map(r => ({
600
+ value: r.path,
601
+ label: r.name,
602
+ hint: r.category,
603
+ })),
604
+ { value: '__back__', label: chalk.dim(trans.docs.returnToMenu) },
605
+ ],
606
+ });
607
+ if (isCancel(selected) || selected === '__back__')
608
+ return;
609
+ await viewMarkdownFile(selected);
610
+ }
472
611
  // ─── Menu ─────────────────────────────────────────────────────────────────────
473
612
  export async function showDocsMenu() {
474
613
  while (true) {
@@ -476,9 +615,10 @@ export async function showDocsMenu() {
476
615
  const categories = getDocCategories();
477
616
  const options = [
478
617
  ...categories.map(cat => ({ value: cat.path, label: cat.name })),
479
- { value: 'refresh-cache', label: chalk.gray(`${pickIcon('♻️', '[r]')} ${trans.docs.refreshCache}`) },
480
- { value: 'browser', label: chalk.gray(`${pickIcon('🌐', '[*]')} ${trans.docs.openBrowser}`) },
481
- { value: 'back', label: chalk.gray(`${pickIcon('←', '[^]')} ${trans.docs.returnToMenu}`) },
618
+ { value: 'search', label: chalk.dim(trans.docs.searchPrompt.replace(':', '')) },
619
+ { value: 'refresh-cache', label: chalk.dim(trans.docs.refreshCache) },
620
+ { value: 'browser', label: chalk.dim(trans.docs.openBrowser) },
621
+ { value: 'back', label: chalk.dim(trans.docs.returnToMenu) },
482
622
  ];
483
623
  const action = await select({
484
624
  message: trans.docs.chooseCategory,
@@ -486,19 +626,18 @@ export async function showDocsMenu() {
486
626
  });
487
627
  if (isCancel(action) || action === 'back')
488
628
  return;
489
- if (action === 'refresh-cache') {
629
+ if (action === 'search') {
630
+ await searchDocs();
631
+ }
632
+ else if (action === 'refresh-cache') {
490
633
  clearDocsCache();
491
634
  success(trans.docs.cacheCleared);
492
635
  }
493
636
  else if (action === 'browser') {
494
637
  await openDocsInBrowser();
495
638
  }
496
- else if (action === 'README.md') {
497
- await viewMarkdownFile('README.md');
498
- }
499
639
  else {
500
640
  await browseDirectory(action);
501
641
  }
502
642
  }
503
643
  }
504
- //# sourceMappingURL=docs.js.map
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Links — open NBTCA resources in browser
3
+ */
4
+ import open from 'open';
5
+ import chalk from 'chalk';
6
+ import { select, isCancel } from '@clack/prompts';
7
+ import { createSpinner } from '../core/ui.js';
8
+ import { URLS } from '../config/data.js';
9
+ import { t } from '../i18n/index.js';
10
+ async function openUrl(url) {
11
+ const trans = t();
12
+ const s = createSpinner(trans.links.opening);
13
+ try {
14
+ await open(url);
15
+ s.stop(trans.links.opened);
16
+ }
17
+ catch {
18
+ s.error(trans.links.error);
19
+ console.log(chalk.dim(` ${url}`));
20
+ }
21
+ }
22
+ export async function showLinksMenu() {
23
+ const trans = t();
24
+ const selected = await select({
25
+ message: trans.links.choose,
26
+ options: [
27
+ { value: URLS.homepage, label: trans.links.website },
28
+ { value: URLS.github, label: trans.links.github },
29
+ { value: URLS.roadmap, label: trans.links.roadmap },
30
+ { value: URLS.repair, label: trans.links.repair },
31
+ { value: '__back__', label: chalk.dim(trans.common.back) },
32
+ ],
33
+ });
34
+ if (isCancel(selected) || selected === '__back__')
35
+ return;
36
+ await openUrl(selected);
37
+ }