@nbtca/prompt 1.4.2 → 1.5.0

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 (67) hide show
  1. package/README.md +27 -58
  2. package/SECURITY.md +16 -45
  3. package/dist/app/app.js +53 -55
  4. package/dist/app/chrome.js +67 -50
  5. package/dist/app/fields/list-field.js +12 -25
  6. package/dist/app/fields/text-field.js +3 -8
  7. package/dist/app/frame.js +2 -21
  8. package/dist/app/keys.js +10 -2
  9. package/dist/app/views/docs-render.js +31 -24
  10. package/dist/app/views/docs.js +211 -60
  11. package/dist/app/views/events-render.js +19 -26
  12. package/dist/app/views/events.js +44 -31
  13. package/dist/app/views/home.js +28 -30
  14. package/dist/app/views/schedule-grid-cursor.js +9 -18
  15. package/dist/app/views/schedule-render.js +47 -71
  16. package/dist/app/views/schedule.js +158 -81
  17. package/dist/app/views/settings-render.js +8 -19
  18. package/dist/app/views/settings.js +92 -17
  19. package/dist/auth/cookie-transport.js +31 -32
  20. package/dist/auth/errors.js +3 -1
  21. package/dist/auth/nbt-auth.js +42 -25
  22. package/dist/auth/session-store.js +17 -9
  23. package/dist/config/data.js +9 -11
  24. package/dist/config/preferences.js +14 -7
  25. package/dist/core/calendar-day.js +37 -0
  26. package/dist/core/capabilities.js +6 -3
  27. package/dist/core/components/confirm.js +9 -8
  28. package/dist/core/components/menu.js +41 -16
  29. package/dist/core/components/messages.js +12 -4
  30. package/dist/core/components/painter.js +3 -1
  31. package/dist/core/components/spinner.js +17 -6
  32. package/dist/core/components/text-input.js +24 -18
  33. package/dist/core/icons.js +2 -2
  34. package/dist/core/logo.js +23 -5
  35. package/dist/core/motion.js +25 -19
  36. package/dist/core/text.js +182 -69
  37. package/dist/core/theme.js +0 -28
  38. package/dist/core/transitions.js +2 -2
  39. package/dist/core/ui.js +15 -13
  40. package/dist/core/vim-keys.js +9 -15
  41. package/dist/features/about.js +23 -0
  42. package/dist/features/calendar-heatmap.js +16 -40
  43. package/dist/features/calendar-query.js +1 -2
  44. package/dist/features/calendar.js +12 -185
  45. package/dist/features/docs.js +436 -275
  46. package/dist/features/schedule-render.js +65 -101
  47. package/dist/features/schedule-store.js +51 -9
  48. package/dist/features/schedule-view.js +46 -213
  49. package/dist/features/status.js +44 -56
  50. package/dist/features/student-timetable.js +73 -95
  51. package/dist/features/theme.js +6 -2
  52. package/dist/features/timetable-sanitize.js +40 -0
  53. package/dist/features/update.js +9 -27
  54. package/dist/i18n/index.js +83 -19
  55. package/dist/i18n/locales/en.json +1 -1
  56. package/dist/i18n/locales/zh.json +1 -1
  57. package/dist/index.js +83 -58
  58. package/dist/logo/ca-dotmatrix.txt +16 -18
  59. package/dist/main.js +7 -48
  60. package/package.json +27 -18
  61. package/bin/nbtca-welcome.js +0 -2
  62. package/dist/core/components/screen.js +0 -18
  63. package/dist/core/menu.js +0 -68
  64. package/dist/features/links.js +0 -36
  65. package/dist/features/schedule-query.js +0 -47
  66. package/dist/features/settings.js +0 -127
  67. package/dist/logo/ca-logo.png +0 -0
@@ -2,6 +2,7 @@ import { marked } from 'marked';
2
2
  import { markedTerminal } from 'marked-terminal';
3
3
  import chalk from 'chalk';
4
4
  import open from 'open';
5
+ import { createHash } from 'node:crypto';
5
6
  import { runMenu, menuFooter } from '../core/components/menu.js';
6
7
  import { runTextInput } from '../core/components/text-input.js';
7
8
  import { runConfirm } from '../core/components/confirm.js';
@@ -9,25 +10,30 @@ import { warning, createSpinner } from '../core/ui.js';
9
10
  import { pickIcon } from '../core/icons.js';
10
11
  import { spawn, execFileSync } from 'child_process';
11
12
  import { URLS } from '../config/data.js';
12
- import { t, fmt } from '../i18n/index.js';
13
+ import { t, fmt, getCurrentLanguage } from '../i18n/index.js';
13
14
  import { enterScreen, breadcrumb } from '../core/transitions.js';
15
+ import { sanitizeTerminalLine, sanitizeTerminalText, truncate } from '../core/text.js';
14
16
  import { createDocsClient } from '@nbtca/docs';
15
17
  function detectTerminalType() {
16
- const term = (process.env['TERM'] || '').toLowerCase();
17
- const termProgram = (process.env['TERM_PROGRAM'] || '').toLowerCase();
18
- const hasImages = termProgram.includes('iterm') || term.includes('kitty') ||
19
- termProgram.includes('wezterm') || term.includes('sixel');
20
- const hasColor = process.env['COLORTERM'] !== undefined || term.includes('color') ||
21
- term.includes('256') || term.includes('ansi') || termProgram !== '';
22
- const hasUnicode = (process.env['LANG'] || '').includes('UTF-8') ||
23
- (process.env['LC_ALL'] || '').includes('UTF-8');
18
+ const term = (process.env['TERM'] ?? '').toLowerCase();
19
+ const termProgram = (process.env['TERM_PROGRAM'] ?? '').toLowerCase();
20
+ const hasImages = termProgram.includes('iterm') ||
21
+ term.includes('kitty') ||
22
+ termProgram.includes('wezterm') ||
23
+ term.includes('sixel');
24
+ const hasColor = process.env['COLORTERM'] !== undefined ||
25
+ term.includes('color') ||
26
+ term.includes('256') ||
27
+ term.includes('ansi') ||
28
+ termProgram !== '';
29
+ const hasUnicode = (process.env['LANG'] ?? '').includes('UTF-8') ||
30
+ (process.env['LC_ALL'] ?? '').includes('UTF-8');
24
31
  if (hasImages && hasColor && hasUnicode)
25
32
  return 'advanced';
26
33
  if (hasColor && hasUnicode)
27
34
  return 'enhanced';
28
35
  return 'basic';
29
36
  }
30
- /** Check whether an external command exists on PATH (once at startup). */
31
37
  function commandExists(cmd) {
32
38
  try {
33
39
  const check = process.platform === 'win32' ? 'where' : 'which';
@@ -40,21 +46,14 @@ function commandExists(cmd) {
40
46
  }
41
47
  let _terminalType = null;
42
48
  function getTerminalType() {
43
- if (_terminalType === null)
44
- _terminalType = detectTerminalType();
49
+ _terminalType ??= detectTerminalType();
45
50
  return _terminalType;
46
51
  }
47
52
  let _hasGlow = null;
48
53
  function hasGlow() {
49
- if (_hasGlow === null)
50
- _hasGlow = commandExists('glow');
54
+ _hasGlow ??= commandExists('glow');
51
55
  return _hasGlow;
52
56
  }
53
- // nbtca/documents links internally with relative/root-relative paths
54
- // (`./what-is-nbtca`, `/concepts/school`) that only resolve in a browser --
55
- // a terminal pager can neither follow nor hover-preview them, so showing
56
- // the path is dead weight. Matches './x', '../x', and '/x' but not a bare
57
- // '/' (an internal href is never *just* a slash in this content).
58
57
  function isInternalHref(href) {
59
58
  return /^\.{0,2}\/./.test(href);
60
59
  }
@@ -73,56 +72,58 @@ export function ensureMarkedConfigured() {
73
72
  return renderExternalLink.call(this, token);
74
73
  };
75
74
  }
76
- // marked-terminal's own `text` renderer always uses the token's raw
77
- // `.text` string, never `.tokens` -- fine for a plain text run, but a
78
- // *tight* list item (nbtca/documents' convention throughout: no blank
79
- // line between "- " entries) tokenizes its content as a `text` token
80
- // with markdown links inside still sitting unparsed in `.tokens`, not
81
- // resolved into `.text`. Result: every link inside every bullet list
82
- // rendered as completely raw, un-clickable-looking `[text](url)` syntax
83
- // (only paragraph-level links, which *do* go through inline-parsing,
84
- // picked up the `link` override above at all). Recursing into
85
- // `parser.parseInline` here when `.tokens` exists routes list-item links
86
- // through the same override, matching how paragraph/heading already do.
87
75
  const renderPlainText = renderer.text;
88
76
  if (renderPlainText) {
89
77
  renderer.text = function (token) {
90
78
  const withTokens = token;
91
79
  if (Array.isArray(withTokens.tokens) && withTokens.tokens.length > 0) {
92
- return this
93
- .parser.parseInline(withTokens.tokens);
80
+ return this.parser.parseInline(withTokens.tokens);
94
81
  }
95
82
  return renderPlainText.call(this, token);
96
83
  };
97
84
  }
98
85
  marked.use(extension);
99
86
  }
100
- // ─── marked-terminal renderer ─────────────────────────────────────────────────
101
87
  function getRendererOptions(type) {
102
88
  const width = 80;
103
89
  const unicodeTableChars = {
104
- top: '─', 'top-mid': '┬', 'top-left': '┌', 'top-right': '┐',
105
- bottom: '─', 'bottom-mid': '┴', 'bottom-left': '└', 'bottom-right': '┘',
106
- left: '│', 'left-mid': '├', mid: '─', 'mid-mid': '┼',
107
- right: '│', 'right-mid': '┤', middle: '│'
90
+ top: '─',
91
+ 'top-mid': '┬',
92
+ 'top-left': '┌',
93
+ 'top-right': '┐',
94
+ bottom: '─',
95
+ 'bottom-mid': '┴',
96
+ 'bottom-left': '└',
97
+ 'bottom-right': '┘',
98
+ left: '│',
99
+ 'left-mid': '├',
100
+ mid: '─',
101
+ 'mid-mid': '┼',
102
+ right: '│',
103
+ 'right-mid': '┤',
104
+ middle: '│',
108
105
  };
109
106
  const asciiTableChars = {
110
- top: '-', 'top-mid': '+', 'top-left': '+', 'top-right': '+',
111
- bottom: '-', 'bottom-mid': '+', 'bottom-left': '+', 'bottom-right': '+',
112
- left: '|', 'left-mid': '+', mid: '-', 'mid-mid': '+',
113
- right: '|', 'right-mid': '+', middle: '|'
107
+ top: '-',
108
+ 'top-mid': '+',
109
+ 'top-left': '+',
110
+ 'top-right': '+',
111
+ bottom: '-',
112
+ 'bottom-mid': '+',
113
+ 'bottom-left': '+',
114
+ 'bottom-right': '+',
115
+ left: '|',
116
+ 'left-mid': '+',
117
+ mid: '-',
118
+ 'mid-mid': '+',
119
+ right: '|',
120
+ 'right-mid': '+',
121
+ middle: '|',
114
122
  };
115
123
  return {
116
124
  width,
117
125
  emoji: true,
118
126
  unescape: true,
119
- // marked-terminal defaults this to true, prefixing every heading with
120
- // its literal '#'/'##'/etc. markdown syntax. displayWithLess() already
121
- // prints its own clean title line above the content, and most docs'
122
- // first line is an H1 matching that same title -- so the raw '#
123
- // Title' immediately below just repeated it a second time, syntax
124
- // marks and all. firstHeading/heading's bold+color already
125
- // distinguishes heading levels without the extra prefix.
126
127
  showSectionPrefix: false,
127
128
  firstHeading: chalk.bold.cyan,
128
129
  heading: chalk.bold.white,
@@ -135,14 +136,20 @@ function getRendererOptions(type) {
135
136
  link: chalk.cyan,
136
137
  href: chalk.cyan.underline,
137
138
  tableOptions: {
138
- chars: type === 'basic' ? asciiTableChars : unicodeTableChars
139
- }
139
+ chars: type === 'basic' ? asciiTableChars : unicodeTableChars,
140
+ },
140
141
  };
141
142
  }
142
143
  const RENDER_CACHE_TTL_MS = 10 * 60 * 1000;
143
144
  const RENDER_CACHE_MAX = 50;
144
145
  const renderCache = new Map();
145
- let docsClient = createDocsClient();
146
+ const METADATA_CACHE_TTL_MS = 10 * 60 * 1000;
147
+ const METADATA_CACHE_MAX = 200;
148
+ const METADATA_CONCURRENCY = 4;
149
+ const metadataCache = new Map();
150
+ const metadataRequests = new Map();
151
+ let cacheGeneration = 0;
152
+ const docsClient = createDocsClient();
146
153
  function getFreshRender(key) {
147
154
  const entry = renderCache.get(key);
148
155
  return entry && entry.expiresAt > Date.now() ? entry.value : null;
@@ -156,27 +163,107 @@ function setRender(key, value) {
156
163
  }
157
164
  }
158
165
  function contentFingerprint(content) {
159
- return `${content.length}:${content.slice(0, 80)}:${content.slice(-80)}`;
166
+ return createHash('sha256').update(content).digest('base64url');
167
+ }
168
+ function renderCacheKey(filePath) {
169
+ return [
170
+ filePath,
171
+ getCurrentLanguage(),
172
+ getTerminalType(),
173
+ pickIcon('unicode', 'ascii'),
174
+ chalk.level,
175
+ ].join('\0');
160
176
  }
161
177
  export function clearDocsCache() {
178
+ cacheGeneration += 1;
162
179
  docsClient.clear();
163
180
  renderCache.clear();
181
+ metadataCache.clear();
182
+ metadataRequests.clear();
164
183
  }
165
- async function fetchFileContent(path) {
184
+ async function fetchDocument(path) {
166
185
  try {
167
- return await docsClient.getFile(path);
186
+ return await docsClient.getDocument(path);
168
187
  }
169
188
  catch (err) {
170
189
  const trans = t();
171
- throw new Error(fmt(trans.docs.fetchFileFailed, { error: String(err) }));
190
+ throw new Error(fmt(trans.docs.fetchFileFailed, { error: sanitizeTerminalLine(String(err)) }));
191
+ }
192
+ }
193
+ function metadataFromPage(page) {
194
+ return {
195
+ title: sanitizeTerminalLine(page.title),
196
+ summary: sanitizeTerminalLine(page.summary),
197
+ route: page.route,
198
+ };
199
+ }
200
+ function getFreshMetadata(path) {
201
+ const entry = metadataCache.get(path);
202
+ if (!entry)
203
+ return null;
204
+ if (entry.expiresAt <= Date.now()) {
205
+ metadataCache.delete(path);
206
+ return null;
207
+ }
208
+ metadataCache.delete(path);
209
+ metadataCache.set(path, entry);
210
+ return entry.value;
211
+ }
212
+ function setMetadata(path, value) {
213
+ metadataCache.delete(path);
214
+ metadataCache.set(path, { value, expiresAt: Date.now() + METADATA_CACHE_TTL_MS });
215
+ if (metadataCache.size > METADATA_CACHE_MAX) {
216
+ const oldest = metadataCache.keys().next().value;
217
+ if (oldest)
218
+ metadataCache.delete(oldest);
172
219
  }
173
220
  }
174
- // ─── Content cleaning ─────────────────────────────────────────────────────────
175
- /**
176
- * Line-by-line scanner that processes fenced code blocks before marked sees them:
177
- * - mermaid blocks → styled blockquote placeholder with diagram type
178
- * - other blocks with a language tag → prepend an inline-code label line
179
- */
221
+ function loadDocMetadata(path) {
222
+ const cached = getFreshMetadata(path);
223
+ if (cached)
224
+ return Promise.resolve(cached);
225
+ const pending = metadataRequests.get(path);
226
+ if (pending)
227
+ return pending;
228
+ const generation = cacheGeneration;
229
+ const request = fetchDocument(path).then((page) => {
230
+ const metadata = metadataFromPage(page);
231
+ if (generation === cacheGeneration)
232
+ setMetadata(path, metadata);
233
+ return metadata;
234
+ });
235
+ metadataRequests.set(path, request);
236
+ const release = () => {
237
+ if (metadataRequests.get(path) === request)
238
+ metadataRequests.delete(path);
239
+ };
240
+ void request.then(release, release);
241
+ return request;
242
+ }
243
+ async function loadRenderedDoc(filePath) {
244
+ const generation = cacheGeneration;
245
+ const page = await fetchDocument(filePath);
246
+ if (generation === cacheGeneration)
247
+ setMetadata(filePath, metadataFromPage(page));
248
+ const rawContent = page.content;
249
+ const fingerprint = contentFingerprint(rawContent);
250
+ const cacheKey = renderCacheKey(filePath);
251
+ const cached = getFreshRender(cacheKey);
252
+ if (cached?.fingerprint === fingerprint)
253
+ return { rawContent, renderedDoc: cached };
254
+ const cleaned = cleanMarkdownContent(rawContent, getTerminalType());
255
+ const title = sanitizeTerminalLine(page.title) || cleanFileName(filePath.split('/').pop() ?? filePath);
256
+ const renderedDoc = {
257
+ fingerprint,
258
+ cleaned,
259
+ rendered: await marked(cleaned),
260
+ title,
261
+ readTime: estimateReadTime(cleaned),
262
+ };
263
+ if (generation === cacheGeneration)
264
+ setRender(cacheKey, renderedDoc);
265
+ return { rawContent, renderedDoc };
266
+ }
180
267
  function processFencedCodeBlocks(content) {
181
268
  const trans = t();
182
269
  const lines = content.split('\n');
@@ -187,11 +274,15 @@ function processFencedCodeBlocks(content) {
187
274
  let blockBody = [];
188
275
  for (const line of lines) {
189
276
  if (!inBlock) {
190
- // Accept VitePress code meta after language: ```js{1,3} or ```ts [file.ts] :line-numbers
191
- const m = line.match(/^(`{3,})(\w+)?[^`\n]*$/);
277
+ const m = /^(`{3,})(\w+)?[^`\n]*$/.exec(line);
192
278
  if (m) {
279
+ const matchedFence = m[1];
280
+ if (!matchedFence) {
281
+ result.push(line);
282
+ continue;
283
+ }
193
284
  inBlock = true;
194
- fence = m[1];
285
+ fence = matchedFence;
195
286
  blockLang = (m[2] ?? '').toLowerCase();
196
287
  blockBody = [];
197
288
  }
@@ -204,9 +295,10 @@ function processFencedCodeBlocks(content) {
204
295
  inBlock = false;
205
296
  const body = blockBody.join('\n');
206
297
  if (blockLang === 'mermaid') {
207
- // Skip %%{ init: ... }%% config directives to find the actual diagram type
208
- const meaningfulLine = body.trim().split('\n')
209
- .find(l => !l.trimStart().startsWith('%%') && l.trim()) ?? '';
298
+ const meaningfulLine = body
299
+ .trim()
300
+ .split('\n')
301
+ .find((l) => !l.trimStart().startsWith('%%') && l.trim()) ?? '';
210
302
  const firstToken = meaningfulLine.trim().split(/\s+/)[0] ?? 'diagram';
211
303
  const icon = pickIcon('📊', '[diagram]');
212
304
  result.push(`> ${icon} **${firstToken}** — _${trans.docs.mermaidHint}_`);
@@ -231,107 +323,141 @@ function processFencedCodeBlocks(content) {
231
323
  return result.join('\n');
232
324
  }
233
325
  const CONTAINER_ICONS_ASCII = {
234
- info: '[INFO]', tip: '[TIP]', warning: '[WARN]', danger: '[DANGER]', details: '[DETAIL]'
326
+ info: '[INFO]',
327
+ tip: '[TIP]',
328
+ warning: '[WARN]',
329
+ danger: '[DANGER]',
330
+ details: '[DETAIL]',
235
331
  };
236
332
  const CONTAINER_ICONS_UNICODE = {
237
- info: 'ℹ️', tip: '💡', warning: '⚠️', danger: '🚨', details: '▶️'
333
+ info: 'ℹ️',
334
+ tip: '💡',
335
+ warning: '⚠️',
336
+ danger: '🚨',
337
+ details: '▶️',
238
338
  };
339
+ function componentAttributes(source) {
340
+ const attributes = new Map();
341
+ const pattern = /(?:^|\s)([:@\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
342
+ for (const match of source.matchAll(pattern)) {
343
+ const name = match[1];
344
+ const value = match[2] ?? match[3];
345
+ if (name && value !== undefined)
346
+ attributes.set(name, sanitizeTerminalLine(value));
347
+ }
348
+ return attributes;
349
+ }
350
+ function replaceDocumentComponents(content) {
351
+ let result = content;
352
+ result = result.replace(/<PageHero\b([\s\S]*?)\/>/gi, (_match, source) => {
353
+ const attributes = componentAttributes(source);
354
+ const title = attributes.get('title');
355
+ const lede = attributes.get('lede');
356
+ return [title ? `# ${title}` : '', lede ?? ''].filter(Boolean).join('\n\n');
357
+ });
358
+ result = result.replace(/<LinkCard\b([\s\S]*?)\/>/gi, (_match, source) => {
359
+ const attributes = componentAttributes(source);
360
+ const href = attributes.get('href');
361
+ const title = attributes.get('title');
362
+ if (!href || !title)
363
+ return '';
364
+ const description = attributes.get('desc');
365
+ return `- [${title}](${href})${description ? ` — ${description}` : ''}`;
366
+ });
367
+ result = result.replace(/<(?:Figure|Band)\b([\s\S]*?)\/>/gi, (_match, source) => {
368
+ const attributes = componentAttributes(source);
369
+ const src = attributes.get('src');
370
+ if (!src)
371
+ return '';
372
+ const label = attributes.get('caption') ?? attributes.get('alt') ?? 'image';
373
+ const details = [attributes.get('date'), attributes.get('source')].filter(Boolean).join(' · ');
374
+ return `![${label}](${src})${details ? `\n\n_${details}_` : ''}`;
375
+ });
376
+ result = result.replace(/<Split\b([^>]*)>/gi, (_match, source) => {
377
+ const heading = componentAttributes(source).get('heading');
378
+ return heading ? `### ${heading}\n\n` : '';
379
+ });
380
+ result = result.replace(/<TimelineEntry\b([^>]*)>/gi, (_match, source) => {
381
+ const attributes = componentAttributes(source);
382
+ const heading = [attributes.get('year'), attributes.get('title')].filter(Boolean).join(' · ');
383
+ return heading ? `### ${heading}\n\n` : '';
384
+ });
385
+ result = result.replace(/<FactStrip\b([\s\S]*?)\/>/gi, (_match, source) => {
386
+ const facts = componentAttributes(source).get(':facts');
387
+ if (!facts)
388
+ return '';
389
+ return [...facts.matchAll(/\{\s*label:\s*'([^']*)'\s*,\s*value:\s*'([^']*)'\s*\}/g)]
390
+ .map((match) => `- **${match[1]}:** ${match[2]}`)
391
+ .join('\n');
392
+ });
393
+ return result;
394
+ }
239
395
  export function cleanMarkdownContent(content, type = getTerminalType()) {
240
- let c = content;
241
- // 1. YAML frontmatter
242
- c = c.replace(/^---\n[\s\S]*?\n---\n?/m, '');
243
- // 1.5. Fenced code blocks: mermaid → placeholder, other langs → label prefix
396
+ let c = sanitizeTerminalText(content);
397
+ c = c.replace(/^---\n[\s\S]*?\n---(?:\n|$)/, '');
244
398
  c = processFencedCodeBlocks(c);
245
- // 2. VitePress script / style blocks
246
399
  c = c.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
247
400
  c = c.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
248
- // 3. VitePress containers → blockquote with icon
401
+ c = replaceDocumentComponents(c);
249
402
  c = c.replace(/^:::\s*(info|tip|warning|danger|details)\s*(.*?)\n([\s\S]*?)^:::\s*$/gm, (_m, type, title, body) => {
250
- const label = (title.trim() || type.charAt(0).toUpperCase() + type.slice(1));
403
+ const label = title.trim() || type.charAt(0).toUpperCase() + type.slice(1);
251
404
  const icon = pickIcon(CONTAINER_ICONS_UNICODE[type] ?? '', CONTAINER_ICONS_ASCII[type] ?? '');
252
- const quoted = body.trimEnd().split('\n').map(l => `> ${l}`).join('\n');
405
+ const quoted = body
406
+ .trimEnd()
407
+ .split('\n')
408
+ .map((l) => `> ${l}`)
409
+ .join('\n');
253
410
  return `> ${icon} **${label}**\n>\n${quoted}\n`;
254
411
  });
255
412
  c = c.replace(/^:::\s*\w*.*$/gm, '');
256
- // Internal wiki links (./foo, /concepts/foo) are handled at the renderer
257
- // level (ensureMarkedConfigured's link override below), not here -- an
258
- // earlier version of this rewrote link syntax into pre-colored raw ANSI
259
- // text before marked() ever saw it, which broke when marked-terminal's
260
- // own text reflow/wrapping ran on top of already-escaped text, corrupting
261
- // the escape sequences into literal visible "[36m...[24m" garbage.
262
- // Overriding the renderer instead lets marked-terminal own all ANSI
263
- // output, so nothing downstream can mangle it.
264
- // 4. GitHub / GitLab callout alerts (> [!NOTE])
265
413
  c = c.replace(/^>\s*\[!(NOTE|TIP|WARNING|CAUTION|IMPORTANT)\]\s*$/gim, (_, type) => `> **${type.charAt(0) + type.slice(1).toLowerCase()}:**`);
266
- // 5. [[toc]] — no value in terminal
267
414
  c = c.replace(/\[\[toc\]\]/gi, '');
268
- // 5.5. VitePress heading anchors {#custom-id} — no value in terminal
269
415
  c = c.replace(/^(#{1,6}\s+[^\n]*?)\s*\{#[^}]+\}\s*$/gm, '$1');
270
- // 5.6. ==highlight== → bold (VitePress extended syntax)
271
416
  c = c.replace(/==([^=\n]+)==/g, '**$1**');
272
- // 6. Images — adapt to terminal capability
273
417
  if (type === 'basic') {
274
- c = c.replace(/!\[([^\]]*)\]\([^)]+\)/g, (_, alt) => `${pickIcon('📎', '[image]')} ${alt || 'image'}`);
418
+ c = c.replace(/!\[([^\]]*)\]\([^)]+\)/g, (_match, alt) => `${pickIcon('📎', '[image]')} ${alt.length > 0 ? alt : 'image'}`);
275
419
  }
276
420
  else {
277
- c = c.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) => {
278
- const filename = url.split('/').pop() || url;
279
- return `${pickIcon('🖼️', '[image]')} **${alt || 'image'}** _(${filename})_`;
421
+ c = c.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, url) => {
422
+ const basename = url.split('/').pop();
423
+ const filename = basename?.length ? basename : url;
424
+ return `${pickIcon('🖼️', '[image]')} **${alt.length > 0 ? alt : 'image'}** _(${filename})_`;
280
425
  });
281
426
  }
282
- // 7. HTML comments
283
427
  c = c.replace(/<!--[\s\S]*?-->/g, '');
284
- // 8. Strip HTML tags, keep inner text
285
428
  c = c.replace(/<br\s*\/?>/gi, '\n'); // void: line break
286
429
  c = c.replace(/<(?:hr|input|link|meta)\b[^>]*\/?>/gi, ''); // void: discard
287
430
  c = c.replace(/<([a-z][a-z0-9]*)\b[^>]*>([\s\S]*?)<\/\1>/gi, '$2');
288
431
  c = c.replace(/<[a-z][a-z0-9]*\b[^>]*\/>/gi, '');
289
- // 8.5. Task list checkboxes
432
+ c = c.replace(/<\/(?:Split|TimelineEntry)>/gi, '');
290
433
  c = c.replace(/^(\s*[-*+] )\[x\] /gim, '$1☑ ');
291
434
  c = c.replace(/^(\s*[-*+] )\[ \] /gm, '$1☐ ');
292
- // 9. Collapse runs of 3+ blank lines
293
435
  c = c.replace(/\n{3,}/g, '\n\n');
294
436
  return c.trim();
295
437
  }
296
- function extractDocTitle(rawContent, cleanedContent) {
297
- const fmMatch = rawContent.match(/^---\n[\s\S]*?\n---/m);
298
- if (fmMatch) {
299
- const titleMatch = fmMatch[0].match(/^title:\s*['"]?(.+?)['"]?\s*$/m);
300
- if (titleMatch?.[1])
301
- return titleMatch[1].trim();
302
- }
303
- const h1Match = cleanedContent.match(/^#\s+(.+)$/m);
304
- return h1Match?.[1]?.trim() ?? null;
305
- }
306
- /** Approximate reading time: ~200 words/min for technical Chinese/English prose. */
307
438
  function estimateReadTime(text) {
308
- const cjkChars = (text.match(/[㐀-鿿]/g) || []).length;
439
+ const cjkChars = [...text.matchAll(/[㐀-鿿]/g)].length;
309
440
  const nonCjk = text.replace(/[㐀-鿿]/g, ' ');
310
441
  const words = nonCjk.trim().split(/\s+/).filter(Boolean).length;
311
442
  const units = words + cjkChars / 2;
312
443
  const mins = Math.max(1, Math.ceil(units / 220));
313
444
  return mins === 1 ? '~1 min' : `~${mins} min`;
314
445
  }
315
- /** Extract h2/h3 headings for TOC display (skips the h1 title). */
316
446
  function extractTOC(content) {
317
- const lines = content.split('\n').filter(l => /^#{2,3}\s/.test(l));
318
- return lines.map(l => {
319
- const m = l.match(/^(#+)/);
447
+ const lines = content.split('\n').filter((l) => /^#{2,3}\s/.test(l));
448
+ return lines.map((l) => {
449
+ const m = /^(#+)/.exec(l);
320
450
  const level = m?.[1]?.length ?? 2;
321
451
  const text = l.replace(/^#+\s+/, '').trim();
322
452
  return (level === 3 ? ' ' : '') + text;
323
453
  });
324
454
  }
325
- /** True if the markdown source contains a pipe table. */
326
455
  function hasMarkdownTable(content) {
327
456
  return /^\|.+\|/m.test(content) && /^\|[-: |]+\|/m.test(content);
328
457
  }
329
- /** True if the markdown source contains a mermaid diagram block. */
330
458
  function hasMermaidBlock(content) {
331
459
  return /^```mermaid\b/m.test(content);
332
460
  }
333
- /** Every internal ([text](href) where isInternalHref(href)) link in a
334
- * document, in reading order, raw href not yet resolved to a real path. */
335
461
  function extractInternalLinks(markdown) {
336
462
  const links = [];
337
463
  const re = /\[([^\]]+)\]\(([^)]+)\)/g;
@@ -343,14 +469,14 @@ function extractInternalLinks(markdown) {
343
469
  }
344
470
  return links;
345
471
  }
346
- /** Resolves a wiki-style href (relative to the *linking* document, VitePress
347
- * conventions: no .md extension, trailing '/' means that dir's index) into
348
- * a real repo-relative path matching DocItem.path -- e.g. './what-is-nbtca'
349
- * from within 'about/index.md' -> 'about/what-is-nbtca.md'; '/concepts/'
350
- * (root-relative, works from anywhere) -> 'concepts/index.md'. */
351
- function resolveInternalHref(href, fromPath) {
472
+ export function resolveInternalHref(href, fromPath) {
473
+ const normalizedHref = href.split(/[?#]/, 1)[0] ?? '';
352
474
  const fromDir = fromPath.includes('/') ? fromPath.slice(0, fromPath.lastIndexOf('/')) : '';
353
- const combined = href.startsWith('/') ? href.slice(1) : (fromDir ? `${fromDir}/${href}` : href);
475
+ const combined = normalizedHref.startsWith('/')
476
+ ? normalizedHref.slice(1)
477
+ : fromDir
478
+ ? `${fromDir}/${normalizedHref}`
479
+ : normalizedHref;
354
480
  const stack = [];
355
481
  for (const part of combined.split('/')) {
356
482
  if (part === '' || part === '.')
@@ -362,33 +488,15 @@ function resolveInternalHref(href, fromPath) {
362
488
  stack.push(part);
363
489
  }
364
490
  let target = stack.join('/');
365
- if (target === '' || href.endsWith('/'))
491
+ if (target === '' || normalizedHref.endsWith('/'))
366
492
  target += (target ? '/' : '') + 'index';
367
493
  if (!target.endsWith('.md'))
368
494
  target += '.md';
369
495
  return target;
370
496
  }
371
- /** Loads and renders a doc for the native in-app reader -- the same
372
- * fetch/clean/render/cache pipeline viewMarkdownFile uses, minus the
373
- * spinner/pager/post-read menu, which belong to the classic-pager
374
- * presentation layer, not this one. */
375
497
  export async function loadDocForReader(filePath) {
376
498
  ensureMarkedConfigured();
377
- const rawContent = await fetchFileContent(filePath);
378
- const fingerprint = contentFingerprint(rawContent);
379
- const cached = getFreshRender(filePath);
380
- let renderedDoc;
381
- if (cached && cached.fingerprint === fingerprint) {
382
- renderedDoc = cached;
383
- }
384
- else {
385
- const cleaned = cleanMarkdownContent(rawContent, getTerminalType());
386
- const title = extractDocTitle(rawContent, cleaned) || cleanFileName(filePath.split('/').pop() ?? filePath);
387
- const readTime = estimateReadTime(cleaned);
388
- const rendered = await marked(cleaned);
389
- renderedDoc = { fingerprint, cleaned, rendered, title, readTime };
390
- setRender(filePath, renderedDoc);
391
- }
499
+ const { renderedDoc } = await loadRenderedDoc(filePath);
392
500
  const seen = new Set();
393
501
  const links = [];
394
502
  for (const raw of extractInternalLinks(renderedDoc.cleaned)) {
@@ -396,11 +504,15 @@ export async function loadDocForReader(filePath) {
396
504
  if (seen.has(resolved))
397
505
  continue;
398
506
  seen.add(resolved);
399
- links.push({ text: raw.text, href: resolved });
507
+ links.push({ text: sanitizeTerminalLine(raw.text), href: resolved });
400
508
  }
401
- return { path: filePath, title: renderedDoc.title, lines: renderedDoc.rendered.split('\n'), links };
509
+ return {
510
+ path: filePath,
511
+ title: renderedDoc.title,
512
+ lines: renderedDoc.rendered.split('\n'),
513
+ links,
514
+ };
402
515
  }
403
- // ─── Document tree ────────────────────────────────────────────────────────────
404
516
  const TOP_SECTION_ORDER = ['about', 'guide', 'repair', 'concepts', 'archived'];
405
517
  const TOP_SECTION_SKIP = new Set(['docs', 'index.md', 'README.md']);
406
518
  const SECTION_ALIAS = { tutorial: 'guide', process: 'guide' };
@@ -415,38 +527,66 @@ export function localizeDocSections(sections, trans = t()) {
415
527
  return sections.map((section) => ({ ...section, label: labels[section.key] ?? section.label }));
416
528
  }
417
529
  export function cleanFileName(name) {
418
- const base = name.replace(/\.md$/, '');
530
+ const base = sanitizeTerminalLine(name).replace(/\.md$/, '');
419
531
  if (/^[\d.]/.test(base))
420
532
  return base;
421
- return base
422
- .replace(/[-_]/g, ' ')
423
- .replace(/\b([a-z])/g, (_, c) => c.toUpperCase());
424
- }
425
- const KNOWN_DOC_TITLES = {
426
- 'tutorial/2025/clean-drive-c.md': 'C盘清理标准化流程',
427
- 'tutorial/2025/edu-email.md': '教育邮箱用途',
428
- 'tutorial/2025/github-education-verification.md': 'Github Education 认证指南',
429
- 'tutorial/2025/github-workflow.md': '快速上手社团目前的Github工作流',
430
- 'tutorial/2025/google-calendar.md': '谷歌日历使用指南',
431
- 'tutorial/2025/nginx-usage.md': '快速上手你的nginx',
432
- 'tutorial/2025/tailscale-usage.md': '社团自建 Tailscale 使用指南',
433
- 'tutorial/manual/hardware-establish.md': '计算机硬件系统的搭建与维护',
434
- 'tutorial/manual/net-usage.md': '国际互联网的使用',
435
- 'tutorial/manual/os-skills.md': '基础操作系统的使用技术',
436
- 'tutorial/manual/windows-from-scratch.md': '从零开始安装 Windows',
437
- 'process/2025/apply-for-credits.md': '申请第二课堂学分',
438
- 'process/2025/borrow-classroom.md': '借教室',
439
- 'process/2025/event-organization.md': '活动举办文档(待完善)',
440
- 'process/2025/nbtca-post.md': '撰写并发布你的第一篇NBTCA博客',
441
- 'process/2025/reimbursement-process.md': '报销流程',
442
- 'repair/checklist.md': '维修日检查单',
443
- 'repair/guide.md': '维修操作指南',
444
- 'repair/repair-day.md': '维修日',
445
- 'repair/tools.md': '软件仓库(校内镜像站)',
446
- 'repair/weekend.md': '维修工单系统 (weekend)',
447
- };
448
- export function displayDocTitle(path, name) {
449
- return KNOWN_DOC_TITLES[path] ?? cleanFileName(name);
533
+ return base.replace(/[-_]/g, ' ').replace(/\b([a-z])/g, (_, c) => c.toUpperCase());
534
+ }
535
+ export function displayDocTitle(name, title) {
536
+ const candidate = title?.trim();
537
+ return sanitizeTerminalLine(candidate?.length ? candidate : cleanFileName(name));
538
+ }
539
+ function listedDoc(item, metadata) {
540
+ return {
541
+ ...item,
542
+ name: sanitizeTerminalLine(item.name),
543
+ title: displayDocTitle(item.name, metadata?.title),
544
+ summary: sanitizeTerminalLine(metadata?.summary ?? ''),
545
+ };
546
+ }
547
+ export async function fetchDocMetadata(items) {
548
+ const results = items.map((item) => listedDoc(item));
549
+ let nextIndex = 0;
550
+ async function worker() {
551
+ for (;;) {
552
+ const index = nextIndex;
553
+ nextIndex += 1;
554
+ if (index >= items.length)
555
+ return;
556
+ const item = items[index];
557
+ if (!item)
558
+ continue;
559
+ try {
560
+ results[index] = listedDoc(item, await loadDocMetadata(item.path));
561
+ }
562
+ catch {
563
+ results[index] = listedDoc(item);
564
+ }
565
+ }
566
+ }
567
+ const workerCount = Math.min(METADATA_CONCURRENCY, items.length);
568
+ await Promise.all(Array.from({ length: workerCount }, worker));
569
+ return results;
570
+ }
571
+ export async function fetchSectionMetadata(section) {
572
+ const files = await fetchDocMetadata(section.files);
573
+ return { ...section, count: files.length, files };
574
+ }
575
+ function searchDoc(result) {
576
+ return {
577
+ name: sanitizeTerminalLine(result.name),
578
+ path: result.path,
579
+ type: 'file',
580
+ title: displayDocTitle(result.name, result.title),
581
+ summary: sanitizeTerminalLine(result.summary),
582
+ excerpt: sanitizeTerminalLine(result.excerpt),
583
+ route: result.route,
584
+ score: result.score,
585
+ section: result.section,
586
+ };
587
+ }
588
+ export async function searchDocuments(query) {
589
+ return (await docsClient.search(query, { limit: 20 })).map(searchDoc);
450
590
  }
451
591
  export function buildSections(all) {
452
592
  const groups = new Map();
@@ -455,31 +595,35 @@ export function buildSections(all) {
455
595
  if (parts.length < 2)
456
596
  continue;
457
597
  const rawTop = parts[0];
598
+ if (!rawTop)
599
+ continue;
458
600
  if (TOP_SECTION_SKIP.has(rawTop))
459
601
  continue;
460
602
  const top = SECTION_ALIAS[rawTop] ?? rawTop;
461
603
  if (!TOP_SECTION_ORDER.includes(top))
462
604
  continue;
463
- if (!groups.has(top))
464
- groups.set(top, []);
465
- groups.get(top).push(item);
605
+ const items = groups.get(top);
606
+ const file = listedDoc(item);
607
+ if (items)
608
+ items.push(file);
609
+ else
610
+ groups.set(top, [file]);
466
611
  }
467
- return localizeDocSections(TOP_SECTION_ORDER
468
- .filter(k => groups.has(k))
469
- .map(k => ({
470
- key: k,
471
- label: k,
472
- count: groups.get(k).length,
473
- files: groups.get(k),
474
- })));
612
+ const orderedGroups = TOP_SECTION_ORDER.flatMap((key) => {
613
+ const files = groups.get(key);
614
+ return files ? [{ key, label: key, count: files.length, files }] : [];
615
+ });
616
+ return localizeDocSections(orderedGroups);
475
617
  }
476
618
  export function getArchivedGroups(files) {
477
619
  const groups = new Map();
478
620
  for (const item of files) {
479
621
  const group = item.path.split('/')[1] ?? 'other';
480
- if (!groups.has(group))
481
- groups.set(group, []);
482
- groups.get(group).push(item);
622
+ const items = groups.get(group);
623
+ if (items)
624
+ items.push(item);
625
+ else
626
+ groups.set(group, [item]);
483
627
  }
484
628
  return groups;
485
629
  }
@@ -502,19 +646,37 @@ async function loadSections() {
502
646
  return null;
503
647
  }
504
648
  }
505
- // ─── Pager layer ──────────────────────────────────────────────────────────────
506
- async function displayWithGlow(cleanedMarkdown) {
507
- const cols = String(Math.min(process.stdout.columns || 80, 80));
508
- return new Promise(resolve => {
509
- const child = spawn('glow', ['--pager', '--width', cols, '-'], {
510
- stdio: ['pipe', 'inherit', 'inherit']
649
+ function pipeToPager(command, args, content) {
650
+ return new Promise((resolve) => {
651
+ const child = spawn(command, args, { stdio: ['pipe', 'inherit', 'inherit'] });
652
+ let settled = false;
653
+ const finish = (started) => {
654
+ if (settled)
655
+ return;
656
+ settled = true;
657
+ resolve(started);
658
+ };
659
+ child.once('close', () => {
660
+ finish(true);
661
+ });
662
+ child.once('error', () => {
663
+ finish(false);
511
664
  });
512
- child.stdin.write(cleanedMarkdown, 'utf-8');
513
- child.stdin.end();
514
- child.on('close', resolve);
515
- child.on('error', resolve);
665
+ child.stdin.once('error', () => {
666
+ finish(true);
667
+ });
668
+ try {
669
+ child.stdin.end(content, 'utf8');
670
+ }
671
+ catch {
672
+ finish(false);
673
+ }
516
674
  });
517
675
  }
676
+ async function displayWithGlow(cleanedMarkdown) {
677
+ const cols = String(Math.min(process.stdout.columns || 80, 80));
678
+ return pipeToPager('glow', ['--pager', '--width', cols, '-'], cleanedMarkdown);
679
+ }
518
680
  async function displayWithLess(rendered, title, filePath, readTime, toc) {
519
681
  const trans = t();
520
682
  const cols = Math.min(process.stdout.columns || 80, 80);
@@ -523,7 +685,7 @@ async function displayWithLess(rendered, title, filePath, readTime, toc) {
523
685
  ? [
524
686
  chalk.dim(` ${trans.docs.tocTitle}`),
525
687
  chalk.dim(` ${'─'.repeat(36)}`),
526
- ...toc.map(h => chalk.dim(` ${h}`)),
688
+ ...toc.map((h) => chalk.dim(` ${h}`)),
527
689
  chalk.dim(` ${'─'.repeat(36)}`),
528
690
  '',
529
691
  ].join('\n')
@@ -536,49 +698,39 @@ async function displayWithLess(rendered, title, filePath, readTime, toc) {
536
698
  ...(tocBlock ? [tocBlock] : []),
537
699
  '',
538
700
  ].join('\n');
539
- const footer = [
540
- '',
541
- rule,
542
- chalk.dim(` ${trans.docs.endOfDocument}`),
543
- '',
544
- ].join('\n');
701
+ const footer = ['', rule, chalk.dim(` ${trans.docs.endOfDocument}`), ''].join('\n');
545
702
  const fullContent = header + rendered + footer;
546
- const pagerSetting = (process.env['PAGER'] || 'less').trim();
703
+ const pagerSetting = (process.env['PAGER'] ?? 'less').trim();
547
704
  const [pagerCommand = 'less', ...pagerArgs] = pagerSetting.split(/\s+/).filter(Boolean);
548
- const args = [...pagerArgs, '-R', '-F', '-X', '-i', '-j4'];
705
+ const isLess = /(?:^|[\\/])less(?:\.exe)?$/i.test(pagerCommand);
706
+ const args = isLess ? [...pagerArgs, '-R', '-F', '-X', '-i', '-j4'] : pagerArgs;
549
707
  if (!commandExists(pagerCommand)) {
550
708
  console.log(fullContent);
551
709
  return;
552
710
  }
553
- return new Promise(resolve => {
554
- try {
555
- const child = spawn(pagerCommand, args, { stdio: ['pipe', 'inherit', 'inherit'] });
556
- child.stdin.write(fullContent, 'utf-8');
557
- child.stdin.end();
558
- child.on('close', resolve);
559
- child.on('error', () => { console.log(fullContent); resolve(); });
560
- }
561
- catch {
562
- console.log(fullContent);
563
- resolve();
564
- }
565
- });
711
+ if (!(await pipeToPager(pagerCommand, args, fullContent)))
712
+ console.log(fullContent);
566
713
  }
567
- // ─── Section browsers ─────────────────────────────────────────────────────────
568
- /** Show a flat file list for tutorial / process / repair. */
569
714
  async function showDocSection(section) {
570
715
  const trans = t();
571
716
  if (section.key === 'archived') {
572
717
  await showArchivedSection(section.files);
573
718
  return;
574
719
  }
575
- const files = section.files.filter(f => f.name !== 'index.md' && !f.name.startsWith('index.'));
720
+ const spinner = createSpinner(trans.docs.loading);
721
+ const hydrated = await fetchSectionMetadata(section);
722
+ spinner.stop();
723
+ const files = hydrated.files.filter((file) => file.name !== 'index.md' && !file.name.startsWith('index.'));
576
724
  if (files.length === 0)
577
725
  return;
578
726
  const selected = await runMenu({
579
- title: section.label,
727
+ title: hydrated.label,
580
728
  options: [
581
- ...files.map(f => ({ value: f.path, label: cleanFileName(f.name) })),
729
+ ...files.map((file) => ({
730
+ value: file.path,
731
+ label: displayDocTitle(file.name, file.title),
732
+ ...(!file.summary ? {} : { hint: file.summary }),
733
+ })),
582
734
  { value: '__back__', label: chalk.dim(trans.common.back) },
583
735
  ],
584
736
  footer: menuFooter(),
@@ -587,7 +739,6 @@ async function showDocSection(section) {
587
739
  return;
588
740
  await viewMarkdownFile(selected);
589
741
  }
590
- /** Show archived docs grouped by year, then files within the year. */
591
742
  async function showArchivedSection(files) {
592
743
  const trans = t();
593
744
  const groups = getArchivedGroups(files);
@@ -605,10 +756,10 @@ async function showArchivedSection(files) {
605
756
  const groupKey = await runMenu({
606
757
  title: trans.docs.categoryArchived,
607
758
  options: [
608
- ...sortedKeys.map(k => ({
759
+ ...sortedKeys.map((k) => ({
609
760
  value: k,
610
761
  label: k,
611
- hint: String(groups.get(k).length),
762
+ hint: String(groups.get(k)?.length ?? 0),
612
763
  })),
613
764
  { value: '__back__', label: chalk.dim(trans.common.back) },
614
765
  ],
@@ -616,17 +767,19 @@ async function showArchivedSection(files) {
616
767
  });
617
768
  if (groupKey === null || groupKey === '__back__')
618
769
  return;
619
- const groupFiles = groups.get(groupKey) ?? [];
620
- const subDirs = new Set(groupFiles.map(f => f.path.split('/')[2]).filter(Boolean));
770
+ const spinner = createSpinner(trans.docs.loading);
771
+ const groupFiles = await fetchDocMetadata(groups.get(groupKey) ?? []);
772
+ spinner.stop();
773
+ const subDirs = new Set(groupFiles.map((f) => f.path.split('/')[2]).filter(Boolean));
621
774
  const fileSelected = await runMenu({
622
775
  title: `${trans.docs.categoryArchived} · ${groupKey}`,
623
776
  options: [
624
- ...groupFiles.map(f => {
777
+ ...groupFiles.map((f) => {
625
778
  const sub = f.path.split('/').slice(2, -1).join('/');
626
779
  return {
627
780
  value: f.path,
628
- label: cleanFileName(f.name),
629
- hint: subDirs.size > 1 ? sub : undefined,
781
+ label: displayDocTitle(f.name, f.title),
782
+ ...(subDirs.size > 1 ? { hint: sanitizeTerminalLine(sub) } : {}),
630
783
  };
631
784
  }),
632
785
  { value: '__back__', label: chalk.dim(trans.common.back) },
@@ -637,31 +790,18 @@ async function showArchivedSection(files) {
637
790
  return;
638
791
  await viewMarkdownFile(fileSelected);
639
792
  }
640
- // ─── Document viewer ──────────────────────────────────────────────────────────
641
- export async function viewMarkdownFile(filePath) {
793
+ async function viewMarkdownFile(filePath) {
642
794
  const trans = t();
643
795
  ensureMarkedConfigured();
644
796
  const s = createSpinner(`${trans.docs.loadingFile}: ${filePath}`);
645
797
  try {
646
- const rawContent = await fetchFileContent(filePath);
647
- const fingerprint = contentFingerprint(rawContent);
648
- const cachedRendered = getFreshRender(filePath);
649
- let renderedDoc;
650
- if (cachedRendered && cachedRendered.fingerprint === fingerprint) {
651
- renderedDoc = cachedRendered;
652
- }
653
- else {
654
- const cleaned = cleanMarkdownContent(rawContent, getTerminalType());
655
- const title = extractDocTitle(rawContent, cleaned) || cleanFileName(filePath.split('/').pop() ?? filePath);
656
- const readTime = estimateReadTime(cleaned);
657
- const rendered = await marked(cleaned);
658
- renderedDoc = { fingerprint, cleaned, rendered, title, readTime };
659
- setRender(filePath, renderedDoc);
660
- }
798
+ const { rawContent, renderedDoc } = await loadRenderedDoc(filePath);
661
799
  s.stop(`${chalk.bold(renderedDoc.title)} ${chalk.dim(renderedDoc.readTime)}`);
662
800
  const toc = extractTOC(renderedDoc.cleaned);
663
801
  if (hasGlow()) {
664
- await displayWithGlow(renderedDoc.cleaned);
802
+ if (!(await displayWithGlow(renderedDoc.cleaned))) {
803
+ await displayWithLess(renderedDoc.rendered, renderedDoc.title, filePath, renderedDoc.readTime, toc);
804
+ }
665
805
  }
666
806
  else {
667
807
  await displayWithLess(renderedDoc.rendered, renderedDoc.title, filePath, renderedDoc.readTime, toc);
@@ -671,8 +811,11 @@ export async function viewMarkdownFile(filePath) {
671
811
  title: trans.docs.chooseAction,
672
812
  options: [
673
813
  { value: 'back', label: trans.docs.backToList },
674
- { value: 'browser', label: trans.docs.openBrowser,
675
- hint: needsBrowser ? trans.docs.tableHint : undefined },
814
+ {
815
+ value: 'browser',
816
+ label: trans.docs.openBrowser,
817
+ ...(needsBrowser ? { hint: trans.docs.tableHint } : {}),
818
+ },
676
819
  ],
677
820
  footer: menuFooter(),
678
821
  });
@@ -682,7 +825,7 @@ export async function viewMarkdownFile(filePath) {
682
825
  }
683
826
  catch (err) {
684
827
  s.error(trans.docs.loadError);
685
- const errMsg = err instanceof Error ? err.message : String(err);
828
+ const errMsg = sanitizeTerminalLine(err instanceof Error ? err.message : String(err));
686
829
  console.log(chalk.gray(` ${trans.docs.errorHint}: ${errMsg}`));
687
830
  const openBrowser = await runConfirm({ message: trans.docs.openBrowserPrompt });
688
831
  if (openBrowser === true) {
@@ -690,14 +833,24 @@ export async function viewMarkdownFile(filePath) {
690
833
  }
691
834
  }
692
835
  }
693
- // ─── Browser fallback ─────────────────────────────────────────────────────────
694
836
  export async function openDocsInBrowser(path) {
695
837
  const trans = t();
696
838
  const s = createSpinner(trans.docs.opening);
697
839
  try {
698
- const url = path
699
- ? `${URLS.docs}/${path.replace(/\.md$/, '')}`
700
- : URLS.docs;
840
+ let route = path ? docsRouteFromPath(path) : '';
841
+ if (path) {
842
+ try {
843
+ route = (await loadDocMetadata(path)).route;
844
+ }
845
+ catch {
846
+ route = docsRouteFromPath(path);
847
+ }
848
+ }
849
+ const encodedRoute = route
850
+ .split('/')
851
+ .map((segment) => encodeURIComponent(segment))
852
+ .join('/');
853
+ const url = path ? `${URLS.docs}${encodedRoute}` : URLS.docs;
701
854
  await open(url);
702
855
  s.stop(trans.docs.browserOpened);
703
856
  }
@@ -707,20 +860,25 @@ export async function openDocsInBrowser(path) {
707
860
  }
708
861
  console.log();
709
862
  }
710
- // ─── Search ────────────────────────────────────────────────────────────────────
863
+ export function docsRouteFromPath(path) {
864
+ const withoutExtension = path.replace(/\.md$/i, '');
865
+ if (withoutExtension === 'index')
866
+ return '/';
867
+ if (withoutExtension.endsWith('/index'))
868
+ return `/${withoutExtension.slice(0, -5)}`;
869
+ return `/${withoutExtension}`;
870
+ }
711
871
  async function searchDocs() {
712
872
  const trans = t();
713
873
  const query = await runTextInput({
714
874
  message: trans.docs.searchPrompt,
715
875
  placeholder: trans.docs.searchPlaceholder,
716
876
  });
717
- if (query === null || !query.trim())
877
+ if (!query?.trim())
718
878
  return;
719
- const keyword = query.trim().toLowerCase();
720
879
  const s = createSpinner(trans.docs.searching);
721
880
  try {
722
- const all = await docsClient.listAll();
723
- const results = all.filter(item => item.path.toLowerCase().includes(keyword));
881
+ const results = await searchDocuments(query.trim());
724
882
  s.stop(`${results.length} ${trans.docs.searchResults}`);
725
883
  if (results.length === 0) {
726
884
  warning(trans.docs.searchNoResults);
@@ -729,10 +887,14 @@ async function searchDocs() {
729
887
  const selected = await runMenu({
730
888
  title: trans.docs.chooseDoc,
731
889
  options: [
732
- ...results.map(r => ({
890
+ ...results.map((r) => ({
733
891
  value: r.path,
734
- label: cleanFileName(r.name),
735
- hint: r.path.includes('/') ? r.path.split('/').slice(0, -1).join('/') : '',
892
+ label: r.title,
893
+ hint: truncate(r.excerpt ||
894
+ r.summary ||
895
+ (r.path.includes('/')
896
+ ? sanitizeTerminalLine(r.path.split('/').slice(0, -1).join('/'))
897
+ : ''), 44),
736
898
  })),
737
899
  { value: '__back__', label: chalk.dim(trans.docs.returnToMenu) },
738
900
  ],
@@ -746,18 +908,17 @@ async function searchDocs() {
746
908
  s.error(trans.docs.loadError);
747
909
  }
748
910
  }
749
- // ─── Menu ─────────────────────────────────────────────────────────────────────
750
911
  export async function showDocsMenu() {
751
912
  await enterScreen(breadcrumb(t().menu.docs));
752
- let sections = await loadSections();
913
+ const sections = await loadSections();
753
914
  if (!sections)
754
915
  return;
755
- while (true) {
916
+ for (;;) {
756
917
  const trans = t();
757
918
  const action = await runMenu({
758
919
  title: trans.docs.chooseCategory,
759
920
  options: [
760
- ...sections.map(sec => ({ value: sec.key, label: sec.label })),
921
+ ...sections.map((sec) => ({ value: sec.key, label: sec.label })),
761
922
  { value: 'search', label: chalk.dim(trans.docs.searchPrompt.replace(':', '')) },
762
923
  { value: 'browser', label: chalk.dim(trans.docs.openBrowser) },
763
924
  ],
@@ -772,7 +933,7 @@ export async function showDocsMenu() {
772
933
  await openDocsInBrowser();
773
934
  }
774
935
  else {
775
- const section = sections.find(s => s.key === action);
936
+ const section = sections.find((s) => s.key === action);
776
937
  if (section)
777
938
  await showDocSection(section);
778
939
  }