@nbtca/prompt 1.0.24 → 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.
@@ -1,5 +1,24 @@
1
- import path from 'path';
1
+ import { existsSync, mkdirSync } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+ function getXdgConfigDir() {
5
+ const xdgHome = process.env['XDG_CONFIG_HOME'] || join(homedir(), '.config');
6
+ return join(xdgHome, 'nbtca');
7
+ }
8
+ function getLegacyConfigDir() {
9
+ return join(homedir(), '.nbtca');
10
+ }
2
11
  export function getConfigDir() {
3
- const homeDir = process.env['HOME'] || process.env['USERPROFILE'] || '';
4
- return path.join(homeDir, '.nbtca');
12
+ const xdgDir = getXdgConfigDir();
13
+ if (existsSync(xdgDir))
14
+ return xdgDir;
15
+ const legacyDir = getLegacyConfigDir();
16
+ if (existsSync(legacyDir))
17
+ return legacyDir;
18
+ return xdgDir;
19
+ }
20
+ export function getWritableConfigDir() {
21
+ const dir = getXdgConfigDir();
22
+ mkdirSync(dir, { recursive: true });
23
+ return dir;
5
24
  }
@@ -1,6 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { getConfigDir } from './paths.js';
3
+ import { getConfigDir, getWritableConfigDir } from './paths.js';
4
4
  const DEFAULT_PREFERENCES = {
5
5
  iconMode: 'auto',
6
6
  colorMode: 'auto',
@@ -8,11 +8,8 @@ const DEFAULT_PREFERENCES = {
8
8
  function getPreferencesPath() {
9
9
  return path.join(getConfigDir(), 'preferences.json');
10
10
  }
11
- function ensureConfigDir() {
12
- const configDir = getConfigDir();
13
- if (!fs.existsSync(configDir)) {
14
- fs.mkdirSync(configDir, { recursive: true });
15
- }
11
+ function getWritablePreferencesPath() {
12
+ return path.join(getWritableConfigDir(), 'preferences.json');
16
13
  }
17
14
  export function loadPreferences() {
18
15
  try {
@@ -32,8 +29,7 @@ export function loadPreferences() {
32
29
  }
33
30
  function savePreferences(preferences) {
34
31
  try {
35
- ensureConfigDir();
36
- fs.writeFileSync(getPreferencesPath(), JSON.stringify(preferences, null, 2));
32
+ fs.writeFileSync(getWritablePreferencesPath(), JSON.stringify(preferences, null, 2));
37
33
  return true;
38
34
  }
39
35
  catch {
package/dist/core/text.js CHANGED
@@ -1,17 +1,20 @@
1
1
  /** Width of a single Unicode character: 2 for CJK/fullwidth, 1 otherwise. */
2
2
  function charWidth(ch) {
3
3
  const cp = ch.codePointAt(0) ?? 0;
4
- return ((cp >= 0x1100 && cp <= 0x115F) || // Hangul Jamo
5
- (cp >= 0x2E80 && cp <= 0x303F) || // CJK Radicals / Kangxi
6
- (cp >= 0x3040 && cp <= 0x33FF) || // Japanese kana + CJK symbols
7
- (cp >= 0x3400 && cp <= 0x4DBF) || // CJK Extension A
8
- (cp >= 0x4E00 && cp <= 0x9FFF) || // CJK Unified Ideographs
9
- (cp >= 0xAC00 && cp <= 0xD7AF) || // Hangul Syllables
10
- (cp >= 0xF900 && cp <= 0xFAFF) || // CJK Compatibility Ideographs
11
- (cp >= 0xFE30 && cp <= 0xFE4F) || // CJK Compatibility Forms
12
- (cp >= 0xFF00 && cp <= 0xFF60) || // Fullwidth Forms
13
- (cp >= 0xFFE0 && cp <= 0xFFE6) // Fullwidth Signs
14
- ) ? 2 : 1;
4
+ return ((cp >= 0x1100 && cp <= 0x115F) ||
5
+ (cp >= 0x2E80 && cp <= 0x303F) ||
6
+ (cp >= 0x3040 && cp <= 0x33FF) ||
7
+ (cp >= 0x3400 && cp <= 0x4DBF) ||
8
+ (cp >= 0x4E00 && cp <= 0x9FFF) ||
9
+ (cp >= 0xAC00 && cp <= 0xD7AF) ||
10
+ (cp >= 0xF900 && cp <= 0xFAFF) ||
11
+ (cp >= 0xFE30 && cp <= 0xFE4F) ||
12
+ (cp >= 0xFF00 && cp <= 0xFF60) ||
13
+ (cp >= 0xFFE0 && cp <= 0xFFE6) ||
14
+ (cp >= 0x20000 && cp <= 0x2A6DF) ||
15
+ (cp >= 0x2A700 && cp <= 0x2CEAF) ||
16
+ (cp >= 0x2CEB0 && cp <= 0x2EBEF) ||
17
+ (cp >= 0x30000 && cp <= 0x323AF)) ? 2 : 1;
15
18
  }
16
19
  /** Strip ANSI escape sequences from a string. */
17
20
  // eslint-disable-next-line no-control-regex
package/dist/core/ui.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import { log, spinner as clackSpinner } from '@clack/prompts';
6
6
  import chalk from 'chalk';
7
7
  import { pickIcon } from './icons.js';
8
+ import { t } from '../i18n/index.js';
8
9
  /**
9
10
  * Display success message
10
11
  */
@@ -62,3 +63,18 @@ export function createSpinner(msg) {
62
63
  s.start(msg);
63
64
  return s;
64
65
  }
66
+ export function handleGracefulExit(err) {
67
+ const message = err instanceof Error ? err.message : String(err ?? '');
68
+ if (message.includes('SIGINT') || message.includes('User force closed')) {
69
+ console.log();
70
+ console.log(chalk.dim(t().common.goodbye));
71
+ process.exit(0);
72
+ }
73
+ if (message) {
74
+ console.error(message);
75
+ }
76
+ else {
77
+ console.error('Error occurred:', err);
78
+ }
79
+ process.exit(1);
80
+ }
@@ -14,13 +14,17 @@ const VIM_TO_SEQ = {
14
14
  G: Buffer.from('\u001b[F'), // end (last item)
15
15
  q: Buffer.from('\u0003'), // quit
16
16
  };
17
+ let vimActive = true;
18
+ export function setVimKeysActive(active) {
19
+ vimActive = active;
20
+ }
17
21
  export function enableVimKeys() {
18
22
  const stdin = process.stdin;
19
23
  if (!stdin.isTTY)
20
24
  return;
21
25
  const originalEmit = stdin.emit.bind(stdin);
22
26
  stdin.emit = function (event, ...args) {
23
- if (event === 'data') {
27
+ if (event === 'data' && vimActive) {
24
28
  const chunk = args[0];
25
29
  if (Buffer.isBuffer(chunk) && chunk.length === 1) {
26
30
  const seq = VIM_TO_SEQ[String.fromCharCode(chunk[0])];
@@ -2,7 +2,6 @@
2
2
  * ICS calendar module
3
3
  * Fetches and renders upcoming events with Unicode box table.
4
4
  */
5
- import axios from 'axios';
6
5
  import ICAL from 'ical.js';
7
6
  import chalk from 'chalk';
8
7
  import { select, isCancel } from '@clack/prompts';
@@ -13,13 +12,17 @@ import { t } from '../i18n/index.js';
13
12
  import { APP_INFO, URLS } from '../config/data.js';
14
13
  export async function fetchEvents() {
15
14
  try {
16
- const response = await axios.get('https://ical.nbtca.space', {
17
- timeout: 5000,
18
- headers: {
19
- 'User-Agent': `NBTCA-CLI/${APP_INFO.version}`
20
- }
15
+ const controller = new AbortController();
16
+ const timeout = setTimeout(() => controller.abort(), 5000);
17
+ const response = await fetch('https://ical.nbtca.space', {
18
+ signal: controller.signal,
19
+ headers: { 'User-Agent': `NBTCA-CLI/${APP_INFO.version}` },
21
20
  });
22
- const jcalData = ICAL.parse(response.data);
21
+ clearTimeout(timeout);
22
+ if (!response.ok)
23
+ throw new Error(`HTTP ${response.status}`);
24
+ const data = await response.text();
25
+ const jcalData = ICAL.parse(data);
23
26
  const comp = new ICAL.Component(jcalData);
24
27
  const vevents = comp.getAllSubcomponents('vevent');
25
28
  const events = [];
@@ -46,7 +49,9 @@ export async function fetchEvents() {
46
49
  return events;
47
50
  }
48
51
  catch (err) {
49
- const detail = err instanceof Error ? err.message : String(err);
52
+ const detail = err instanceof Error
53
+ ? (err.name === 'AbortError' ? 'Request timed out' : err.message)
54
+ : String(err);
50
55
  throw new Error(`${t().calendar.error}: ${detail}`);
51
56
  }
52
57
  }
@@ -61,8 +66,12 @@ export function serializeEvents(events) {
61
66
  }));
62
67
  }
63
68
  function formatDate(date) {
69
+ const now = new Date();
64
70
  const month = String(date.getMonth() + 1).padStart(2, '0');
65
71
  const day = String(date.getDate()).padStart(2, '0');
72
+ if (date.getFullYear() !== now.getFullYear()) {
73
+ return `${date.getFullYear()}-${month}-${day}`;
74
+ }
66
75
  return `${month}-${day}`;
67
76
  }
68
77
  function formatTime(date) {
@@ -78,7 +87,7 @@ export function renderEventsTable(events, options) {
78
87
  const color = options?.color !== false;
79
88
  if (events.length === 0)
80
89
  return trans.calendar.noEvents;
81
- const dateWidth = 13;
90
+ const dateWidth = 16;
82
91
  const titleWidth = 30;
83
92
  const locationWidth = 16;
84
93
  const h = pickIcon('─', '-');
@@ -2,7 +2,6 @@
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';
@@ -12,7 +11,8 @@ 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
13
  import { APP_INFO, GITHUB_REPO, URLS } from '../config/data.js';
15
- import { t } from '../i18n/index.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();
@@ -55,8 +55,9 @@ function ensureMarkedConfigured() {
55
55
  if (_markedConfigured)
56
56
  return;
57
57
  _markedConfigured = true;
58
- // @ts-ignore - marked v11 / marked-terminal v7 type incompatibility
59
- marked.setOptions({ renderer: new TerminalRenderer(getRendererOptions(getTerminalType())) });
58
+ marked.use({
59
+ renderer: new TerminalRenderer(getRendererOptions(getTerminalType()))
60
+ });
60
61
  }
61
62
  // ─── marked-terminal renderer ─────────────────────────────────────────────────
62
63
  function getRendererOptions(type) {
@@ -135,6 +136,18 @@ function getAnyCacheValue(cache, key) {
135
136
  function setCacheValue(cache, key, value, ttlMs) {
136
137
  cache.set(key, { value, expiresAt: Date.now() + ttlMs });
137
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
+ }
138
151
  function contentFingerprint(content) {
139
152
  const head = content.slice(0, 80);
140
153
  const tail = content.slice(-80);
@@ -157,12 +170,29 @@ async function fetchGitHubDirectory(path = '', options = {}) {
157
170
  try {
158
171
  const headers = {
159
172
  'Accept': 'application/vnd.github.v3+json',
160
- 'User-Agent': `NBTCA-CLI/${APP_INFO.version}`
173
+ 'User-Agent': `NBTCA-CLI/${APP_INFO.version}`,
161
174
  };
162
175
  if (GITHUB_TOKEN)
163
176
  headers['Authorization'] = `Bearer ${GITHUB_TOKEN}`;
164
- const response = await axios.get(url, { timeout: 10000, headers });
165
- 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
166
196
  .filter((item) => !item.name.startsWith('.') &&
167
197
  !SKIP_NAMES.has(item.name) &&
168
198
  !(item.type === 'file' && !item.name.endsWith('.md')))
@@ -180,6 +210,7 @@ async function fetchGitHubDirectory(path = '', options = {}) {
180
210
  return a.name.localeCompare(b.name);
181
211
  });
182
212
  setCacheValue(dirCache, cacheKey, items, DIR_CACHE_TTL_MS);
213
+ evictStalest(dirCache, DIR_CACHE_MAX);
183
214
  return { data: items, fromCache: false, staleFallback: false };
184
215
  }
185
216
  catch (err) {
@@ -188,18 +219,10 @@ async function fetchGitHubDirectory(path = '', options = {}) {
188
219
  return { data: staleCached, fromCache: true, staleFallback: true };
189
220
  }
190
221
  const trans = t();
191
- const errorMessage = err instanceof Error ? err.message : String(err);
192
- const axiosErr = err;
193
- if (axiosErr.response?.status === 403) {
194
- const rateLimitRemaining = axiosErr.response.headers?.['x-ratelimit-remaining'];
195
- const rateLimitReset = axiosErr.response.headers?.['x-ratelimit-reset'];
196
- if (rateLimitRemaining === '0' && rateLimitReset) {
197
- const resetDate = new Date(Number.parseInt(rateLimitReset, 10) * 1000);
198
- throw new Error(`${trans.docs.githubRateLimited.replace('{time}', resetDate.toLocaleTimeString())}\n${trans.docs.githubTokenHint}`);
199
- }
200
- throw new Error(`${trans.docs.githubForbidden}\n${trans.docs.githubTokenHint}`);
201
- }
202
- 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 }));
203
226
  }
204
227
  }
205
228
  async function fetchGitHubRawContent(path, options = {}) {
@@ -211,9 +234,18 @@ async function fetchGitHubRawContent(path, options = {}) {
211
234
  }
212
235
  const url = `https://raw.githubusercontent.com/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/${GITHUB_REPO.branch}/${path}`;
213
236
  try {
214
- const response = await axios.get(url, { timeout: 15000, headers: { 'User-Agent': `NBTCA-CLI/${APP_INFO.version}` } });
215
- 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();
216
247
  setCacheValue(fileCache, path, content, FILE_CACHE_TTL_MS);
248
+ evictStalest(fileCache, FILE_CACHE_MAX);
217
249
  return { data: content, fromCache: false, staleFallback: false };
218
250
  }
219
251
  catch (err) {
@@ -222,8 +254,10 @@ async function fetchGitHubRawContent(path, options = {}) {
222
254
  return { data: staleCached, fromCache: true, staleFallback: true };
223
255
  }
224
256
  const trans = t();
225
- const errorMessage = err instanceof Error ? err.message : String(err);
226
- 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 }));
227
261
  }
228
262
  }
229
263
  // ─── Content cleaning ─────────────────────────────────────────────────────────
@@ -361,22 +395,41 @@ async function displayWithLess(rendered, title, filePath, readTime) {
361
395
  });
362
396
  }
363
397
  // ─── Directory browser ────────────────────────────────────────────────────────
364
- async function browseDirectory(dirPath = '') {
365
- const trans = t();
366
- try {
367
- const s = createSpinner(dirPath ? `${trans.docs.loadingDir}: ${dirPath}` : trans.docs.loading);
368
- const result = await fetchGitHubDirectory(dirPath);
369
- const items = result.data;
370
- s.stop(dirPath || trans.docs.chooseDoc);
371
- if (result.staleFallback) {
372
- 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;
373
422
  }
374
423
  if (items.length === 0) {
375
424
  warning(trans.docs.emptyDir);
425
+ if (currentPath) {
426
+ currentPath = currentPath.split('/').slice(0, -1).join('/');
427
+ continue;
428
+ }
376
429
  return;
377
430
  }
378
431
  const options = [
379
- ...(dirPath ? [{ value: '__back__', label: chalk.dim(trans.docs.upToParent) }] : []),
432
+ ...(currentPath ? [{ value: '__back__', label: chalk.dim(trans.docs.upToParent) }] : []),
380
433
  ...items.map(item => ({
381
434
  value: item.path,
382
435
  label: item.type === 'dir'
@@ -387,93 +440,89 @@ async function browseDirectory(dirPath = '') {
387
440
  { value: '__exit__', label: chalk.dim(trans.docs.returnToMenu) },
388
441
  ];
389
442
  const selected = await select({
390
- message: dirPath ? `${trans.docs.currentDir}: ${dirPath}` : trans.docs.chooseDoc,
443
+ message: currentPath ? `${trans.docs.currentDir}: ${currentPath}` : trans.docs.chooseDoc,
391
444
  options,
392
445
  });
393
446
  if (isCancel(selected) || selected === '__exit__')
394
447
  return;
395
448
  if (selected === '__back__') {
396
- const parentPath = dirPath.split('/').slice(0, -1).join('/');
397
- await browseDirectory(parentPath);
449
+ currentPath = currentPath.split('/').slice(0, -1).join('/');
450
+ continue;
398
451
  }
399
- else {
400
- const item = items.find(i => i.path === selected);
401
- if (item?.type === 'dir') {
402
- await browseDirectory(selected);
403
- }
404
- else if (item?.type === 'file') {
405
- await viewMarkdownFile(selected);
406
- await browseDirectory(dirPath);
407
- }
452
+ const item = items.find(i => i.path === selected);
453
+ if (item?.type === 'dir') {
454
+ currentPath = selected;
455
+ continue;
408
456
  }
409
- }
410
- catch (err) {
411
- error(trans.docs.loadError);
412
- const errMsg = err instanceof Error ? err.message : String(err);
413
- console.log(chalk.gray(` ${trans.docs.errorHint}: ${errMsg}`));
414
- const retry = await confirm({ message: trans.docs.retry });
415
- if (!isCancel(retry) && retry) {
416
- await browseDirectory(dirPath);
457
+ if (item?.type === 'file') {
458
+ await viewMarkdownFile(selected);
417
459
  }
418
460
  }
419
461
  }
420
462
  // ─── Document viewer ──────────────────────────────────────────────────────────
421
463
  async function viewMarkdownFile(filePath) {
422
464
  const trans = t();
423
- try {
424
- ensureMarkedConfigured();
425
- const s = createSpinner(`${trans.docs.loading.replace('...', '')}: ${filePath}`);
426
- const rawResult = await fetchGitHubRawContent(filePath);
427
- if (rawResult.staleFallback) {
428
- warning(trans.docs.usingCachedData);
429
- }
430
- const rawContent = rawResult.data;
431
- const fingerprint = contentFingerprint(rawContent);
432
- const cachedRendered = getFreshCacheValue(renderCache, filePath);
433
- let renderedDoc;
434
- if (cachedRendered && cachedRendered.fingerprint === fingerprint) {
435
- renderedDoc = cachedRendered;
436
- }
437
- else {
438
- const cleaned = cleanMarkdownContent(rawContent, getTerminalType());
439
- const title = extractDocTitle(rawContent, cleaned) || filePath.split('/').pop() || filePath;
440
- const readTime = estimateReadTime(cleaned);
441
- const rendered = await marked(cleaned);
442
- renderedDoc = { fingerprint, cleaned, rendered, title, readTime };
443
- setCacheValue(renderCache, filePath, renderedDoc, RENDER_CACHE_TTL_MS);
444
- }
445
- s.stop(`${chalk.bold(renderedDoc.title)} ${chalk.dim(renderedDoc.readTime)}`);
446
- if (hasGlow()) {
447
- await displayWithGlow(renderedDoc.cleaned);
448
- }
449
- else {
450
- 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
451
514
  }
452
- console.log();
453
- success(trans.docs.docCompleted);
454
- console.log();
455
- const action = await select({
456
- message: trans.docs.chooseAction,
457
- options: [
458
- { value: 'back', label: trans.docs.backToList },
459
- { value: 'reread', label: trans.docs.reread },
460
- { value: 'browser', label: trans.docs.openBrowser },
461
- ],
462
- });
463
- 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
+ }
464
525
  return;
465
- if (action === 'browser')
466
- await openDocsInBrowser(filePath);
467
- if (action === 'reread')
468
- await viewMarkdownFile(filePath);
469
- }
470
- catch (err) {
471
- error(trans.docs.loadError);
472
- const errMsg = err instanceof Error ? err.message : String(err);
473
- console.log(chalk.gray(` ${trans.docs.errorHint}: ${errMsg}`));
474
- const openBrowser = await confirm({ message: trans.docs.openBrowserPrompt });
475
- if (!isCancel(openBrowser) && openBrowser) {
476
- await openDocsInBrowser(filePath);
477
526
  }
478
527
  }
479
528
  }
@@ -497,10 +546,12 @@ export async function openDocsInBrowser(path) {
497
546
  // ─── Search ────────────────────────────────────────────────────────────────────
498
547
  async function searchDocs() {
499
548
  const trans = t();
549
+ setVimKeysActive(false);
500
550
  const query = await text({
501
551
  message: trans.docs.searchPrompt,
502
552
  placeholder: trans.docs.searchPlaceholder,
503
553
  });
554
+ setVimKeysActive(true);
504
555
  if (isCancel(query) || !query.trim())
505
556
  return;
506
557
  const keyword = query.trim().toLowerCase();
@@ -523,6 +574,15 @@ async function searchDocs() {
523
574
  }
524
575
  }
525
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
+ }
526
586
  s.stop(`${results.length} ${trans.docs.searchResults}`);
527
587
  }
528
588
  catch {
@@ -35,8 +35,3 @@ export async function showLinksMenu() {
35
35
  return;
36
36
  await openUrl(selected);
37
37
  }
38
- /** Direct openers for CLI non-interactive mode */
39
- export async function openHomepage() { await openUrl(URLS.homepage); }
40
- export async function openGithub() { await openUrl(URLS.github); }
41
- export async function openRoadmap() { await openUrl(URLS.roadmap); }
42
- export async function openRepairService() { await openUrl(URLS.repair); }
@@ -1,4 +1,3 @@
1
- import axios from 'axios';
2
1
  import chalk from 'chalk';
3
2
  import { APP_INFO, URLS } from '../config/data.js';
4
3
  import { pickIcon } from '../core/icons.js';
@@ -18,19 +17,23 @@ function getServiceTargets() {
18
17
  async function checkService(name, url, timeoutMs) {
19
18
  const start = Date.now();
20
19
  try {
21
- const response = await axios.get(url, {
22
- timeout: timeoutMs,
23
- maxRedirects: 5,
24
- validateStatus: () => true,
20
+ const controller = new AbortController();
21
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
22
+ const response = await fetch(url, {
23
+ signal: controller.signal,
24
+ redirect: 'follow',
25
25
  headers: { 'User-Agent': `NBTCA-CLI/${APP_INFO.version}` },
26
26
  });
27
+ clearTimeout(timeout);
27
28
  const latencyMs = Date.now() - start;
28
29
  const ok = response.status >= 200 && response.status < 400;
29
30
  return { name, url, ok, statusCode: response.status, latencyMs };
30
31
  }
31
32
  catch (err) {
32
33
  const latencyMs = Date.now() - start;
33
- const error = err instanceof Error ? err.message : String(err);
34
+ const error = err instanceof Error
35
+ ? (err.name === 'AbortError' ? 'Request timed out' : err.message)
36
+ : String(err);
34
37
  return { name, url, ok: false, latencyMs, error };
35
38
  }
36
39
  }
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import chalk from 'chalk';
6
6
  import { APP_INFO } from '../config/data.js';
7
- import { t } from '../i18n/index.js';
7
+ import { t, fmt } from '../i18n/index.js';
8
8
  const NPM_REGISTRY_URL = `https://registry.npmjs.org/@nbtca/prompt/latest`;
9
9
  /**
10
10
  * Fetch latest version from npm registry.
@@ -52,7 +52,7 @@ export async function checkForUpdate() {
52
52
  if (!latest || !isNewer(APP_INFO.version, latest))
53
53
  return null;
54
54
  const trans = t();
55
- return `${trans.update.available.replace('{latest}', latest).replace('{current}', APP_INFO.version)} ${chalk.dim(trans.update.command)}`;
55
+ return `${fmt(trans.update.available, { latest, current: APP_INFO.version })} ${chalk.dim(trans.update.command)}`;
56
56
  }
57
57
  /**
58
58
  * Explicit update check command (nbtca update).
@@ -65,10 +65,10 @@ export async function runUpdateCheck() {
65
65
  return;
66
66
  }
67
67
  if (isNewer(APP_INFO.version, latest)) {
68
- console.log(chalk.yellow(`${trans.update.available.replace('{latest}', latest).replace('{current}', APP_INFO.version)}`));
68
+ console.log(chalk.yellow(`${fmt(trans.update.available, { latest, current: APP_INFO.version })}`));
69
69
  console.log(chalk.dim(trans.update.command));
70
70
  }
71
71
  else {
72
- console.log(chalk.green(`${trans.update.upToDate.replace('{version}', APP_INFO.version)}`));
72
+ console.log(chalk.green(`${fmt(trans.update.upToDate, { version: APP_INFO.version })}`));
73
73
  }
74
74
  }
@@ -6,7 +6,7 @@ import fs from 'fs';
6
6
  import path from 'path';
7
7
  import { fileURLToPath } from 'url';
8
8
  import { dirname } from 'path';
9
- import { getConfigDir } from '../config/paths.js';
9
+ import { getConfigDir, getWritableConfigDir } from '../config/paths.js';
10
10
  const __filename = fileURLToPath(import.meta.url);
11
11
  const __dirname = dirname(__filename);
12
12
  /**
@@ -14,11 +14,17 @@ const __dirname = dirname(__filename);
14
14
  */
15
15
  let currentLanguage = 'zh'; // Default to Chinese
16
16
  /**
17
- * Get language configuration file path
17
+ * Get language configuration file path (read, with legacy fallback)
18
18
  */
19
19
  function getLanguageConfigPath() {
20
20
  return path.join(getConfigDir(), 'language.json');
21
21
  }
22
+ /**
23
+ * Get writable language configuration file path (XDG, creates dir)
24
+ */
25
+ function getWritableLanguageConfigPath() {
26
+ return path.join(getWritableConfigDir(), 'language.json');
27
+ }
22
28
  /**
23
29
  * Load language preference from config file
24
30
  */
@@ -40,11 +46,7 @@ export function loadLanguagePreference() {
40
46
  */
41
47
  export function saveLanguagePreference(language) {
42
48
  try {
43
- const configDir = getConfigDir();
44
- if (!fs.existsSync(configDir)) {
45
- fs.mkdirSync(configDir, { recursive: true });
46
- }
47
- const configPath = getLanguageConfigPath();
49
+ const configPath = getWritableLanguageConfigPath();
48
50
  fs.writeFileSync(configPath, JSON.stringify({ language }, null, 2));
49
51
  currentLanguage = language;
50
52
  return true;
@@ -95,6 +97,12 @@ export function t() {
95
97
  }
96
98
  return translationsCache.get(currentLanguage);
97
99
  }
100
+ export function fmt(template, vars) {
101
+ return template.replace(/\{(\w+)\}/g, (_, key) => {
102
+ const val = vars[key];
103
+ return val !== undefined ? String(val) : `{${key}}`;
104
+ });
105
+ }
98
106
  /**
99
107
  * Clear translation cache (useful when switching languages)
100
108
  */
package/dist/index.js CHANGED
@@ -10,8 +10,8 @@ import { pickIcon } from './core/icons.js';
10
10
  import { applyColorModePreference } from './config/preferences.js';
11
11
  import { openDocsInBrowser } from './features/docs.js';
12
12
  import { runThemeCommand } from './features/theme.js';
13
- import { setLanguage, t } from './i18n/index.js';
14
- import { clearScreen } from './core/ui.js';
13
+ import { setLanguage, t, fmt } from './i18n/index.js';
14
+ import { clearScreen, handleGracefulExit } from './core/ui.js';
15
15
  import { APP_INFO, URLS } from './config/data.js';
16
16
  import { runUpdateCheck } from './features/update.js';
17
17
  const ACTION_ALIASES = {
@@ -169,8 +169,12 @@ async function runEventsCommand(flags) {
169
169
  let events = await fetchEvents();
170
170
  if (flags.has('--today')) {
171
171
  const now = new Date();
172
- const todayStr = `${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
173
- events = events.filter(e => e.date === todayStr);
172
+ events = events.filter(e => {
173
+ const d = e.startDate;
174
+ return d.getFullYear() === now.getFullYear() &&
175
+ d.getMonth() === now.getMonth() &&
176
+ d.getDate() === now.getDate();
177
+ });
174
178
  }
175
179
  const nextFlag = Array.from(flags).find(f => f.startsWith('--next='));
176
180
  if (nextFlag) {
@@ -202,11 +206,11 @@ async function runStatusCommand(flags) {
202
206
  process.exit(1);
203
207
  }
204
208
  if (!Number.isInteger(timeoutMs) || timeoutMs < STATUS_TIMEOUT_MIN || timeoutMs > STATUS_TIMEOUT_MAX) {
205
- console.error(chalk.red(trans.status.invalidTimeout.replace('{min}', String(STATUS_TIMEOUT_MIN)).replace('{max}', String(STATUS_TIMEOUT_MAX))));
209
+ console.error(chalk.red(fmt(trans.status.invalidTimeout, { min: STATUS_TIMEOUT_MIN, max: STATUS_TIMEOUT_MAX })));
206
210
  process.exit(1);
207
211
  }
208
212
  if (!Number.isInteger(retries) || retries < STATUS_RETRIES_MIN || retries > STATUS_RETRIES_MAX) {
209
- console.error(chalk.red(trans.status.invalidRetries.replace('{min}', String(STATUS_RETRIES_MIN)).replace('{max}', String(STATUS_RETRIES_MAX))));
213
+ console.error(chalk.red(fmt(trans.status.invalidRetries, { min: STATUS_RETRIES_MIN, max: STATUS_RETRIES_MAX })));
210
214
  process.exit(1);
211
215
  }
212
216
  if (watch && flags.has('--json')) {
@@ -214,7 +218,7 @@ async function runStatusCommand(flags) {
214
218
  process.exit(1);
215
219
  }
216
220
  if (watch && (!Number.isInteger(intervalSeconds) || intervalSeconds < STATUS_WATCH_INTERVAL_MIN || intervalSeconds > STATUS_WATCH_INTERVAL_MAX)) {
217
- console.error(chalk.red(trans.status.invalidInterval.replace('{min}', String(STATUS_WATCH_INTERVAL_MIN)).replace('{max}', String(STATUS_WATCH_INTERVAL_MAX))));
221
+ console.error(chalk.red(fmt(trans.status.invalidInterval, { min: STATUS_WATCH_INTERVAL_MIN, max: STATUS_WATCH_INTERVAL_MAX })));
218
222
  process.exit(1);
219
223
  }
220
224
  if (watch && !hasInteractiveTerminal()) {
@@ -225,7 +229,7 @@ async function runStatusCommand(flags) {
225
229
  let stopped = false;
226
230
  const onSigint = () => { stopped = true; };
227
231
  process.once('SIGINT', onSigint);
228
- console.log(chalk.dim(`${trans.status.watchStarted.replace('{seconds}', String(intervalSeconds))} | ${trans.status.watchHint}`));
232
+ console.log(chalk.dim(`${fmt(trans.status.watchStarted, { seconds: intervalSeconds })} | ${trans.status.watchHint}`));
229
233
  try {
230
234
  while (!stopped) {
231
235
  const services = await checkServices({ timeoutMs, retries });
@@ -394,17 +398,4 @@ async function runCommandMode(argv) {
394
398
  return;
395
399
  }
396
400
  }
397
- runCommandMode(process.argv.slice(2)).catch((err) => {
398
- if (err?.message?.includes('SIGINT') || err?.message?.includes('User force closed')) {
399
- console.log();
400
- console.log(chalk.dim(t().common.goodbye));
401
- process.exit(0);
402
- }
403
- if (err?.message) {
404
- console.error(err.message);
405
- }
406
- else {
407
- console.error('Error occurred:', err);
408
- }
409
- process.exit(1);
410
- });
401
+ runCommandMode(process.argv.slice(2)).catch(handleGracefulExit);
package/dist/main.js CHANGED
@@ -5,11 +5,10 @@
5
5
  import chalk from 'chalk';
6
6
  import { intro } from '@clack/prompts';
7
7
  import { printLogo } from './core/logo.js';
8
- import { clearScreen } from './core/ui.js';
8
+ import { clearScreen, handleGracefulExit } from './core/ui.js';
9
9
  import { showMainMenu } from './core/menu.js';
10
10
  import { APP_INFO } from './config/data.js';
11
11
  import { enableVimKeys } from './core/vim-keys.js';
12
- import { t } from './i18n/index.js';
13
12
  import { checkForUpdate } from './features/update.js';
14
13
  /**
15
14
  * Main program entry point
@@ -41,15 +40,6 @@ export async function main(options = {}) {
41
40
  await showMainMenu();
42
41
  }
43
42
  catch (err) {
44
- const message = err instanceof Error ? err.message : String(err ?? '');
45
- if (message.includes('SIGINT') || message.includes('User force closed')) {
46
- console.log();
47
- console.log(chalk.dim(t().common.goodbye));
48
- process.exit(0);
49
- }
50
- else {
51
- console.error('Error occurred:', message || err);
52
- process.exit(1);
53
- }
43
+ handleGracefulExit(err);
54
44
  }
55
45
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nbtca/prompt",
3
- "version": "1.0.24",
3
+ "version": "1.0.25",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -21,7 +21,7 @@
21
21
  "dev": "tsx src/index.ts",
22
22
  "dev:watch": "tsx watch src/index.ts",
23
23
  "build": "tsc",
24
- "postbuild": "mkdir -p dist/logo dist/i18n/locales && cp src/logo/logo.txt dist/logo/ && cp src/logo/ascii-logo.txt dist/logo/ && cp src/i18n/locales/*.json dist/i18n/locales/",
24
+ "postbuild": "node scripts/copy-assets.js",
25
25
  "clean": "rm -rf dist",
26
26
  "prebuild": "npm run clean",
27
27
  "prepublishOnly": "npm run build",
@@ -38,21 +38,20 @@
38
38
  "interactive"
39
39
  ],
40
40
  "dependencies": {
41
- "@clack/prompts": "^1.0.1",
42
- "axios": "^1.6.2",
43
- "chalk": "^5.4.1",
41
+ "@clack/prompts": "^1.2.0",
42
+ "chalk": "^5.6.2",
44
43
  "gradient-string": "^3.0.0",
45
- "ical.js": "^2.0.1",
46
- "marked": "^11.1.0",
44
+ "ical.js": "^2.2.1",
45
+ "marked": "^15.0.12",
47
46
  "marked-terminal": "^7.0.0",
48
- "open": "^10.1.2"
47
+ "open": "^11.0.0"
49
48
  },
50
49
  "devDependencies": {
51
50
  "@types/gradient-string": "^1.1.6",
52
51
  "@types/marked-terminal": "^3.1.3",
53
- "@types/node": "^20.11.0",
54
- "tsx": "^4.7.0",
55
- "typescript": "^5.3.3"
52
+ "@types/node": "^22.19.17",
53
+ "tsx": "^4.21.0",
54
+ "typescript": "^5.9.3"
56
55
  },
57
56
  "engines": {
58
57
  "node": ">=20.12.0"