@nbtca/prompt 1.4.1 → 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 (68) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +27 -58
  3. package/SECURITY.md +16 -45
  4. package/dist/app/app.js +53 -55
  5. package/dist/app/chrome.js +67 -50
  6. package/dist/app/fields/list-field.js +12 -25
  7. package/dist/app/fields/text-field.js +3 -8
  8. package/dist/app/frame.js +2 -21
  9. package/dist/app/keys.js +10 -2
  10. package/dist/app/views/docs-render.js +31 -24
  11. package/dist/app/views/docs.js +211 -67
  12. package/dist/app/views/events-render.js +19 -26
  13. package/dist/app/views/events.js +44 -31
  14. package/dist/app/views/home.js +33 -76
  15. package/dist/app/views/schedule-grid-cursor.js +9 -18
  16. package/dist/app/views/schedule-render.js +47 -71
  17. package/dist/app/views/schedule.js +158 -90
  18. package/dist/app/views/settings-render.js +8 -19
  19. package/dist/app/views/settings.js +93 -18
  20. package/dist/auth/cookie-transport.js +31 -32
  21. package/dist/auth/errors.js +3 -1
  22. package/dist/auth/nbt-auth.js +42 -25
  23. package/dist/auth/session-store.js +17 -9
  24. package/dist/config/data.js +10 -13
  25. package/dist/config/preferences.js +14 -7
  26. package/dist/core/calendar-day.js +37 -0
  27. package/dist/core/capabilities.js +6 -3
  28. package/dist/core/components/confirm.js +9 -8
  29. package/dist/core/components/menu.js +41 -16
  30. package/dist/core/components/messages.js +12 -4
  31. package/dist/core/components/painter.js +3 -1
  32. package/dist/core/components/spinner.js +17 -6
  33. package/dist/core/components/text-input.js +24 -18
  34. package/dist/core/icons.js +2 -2
  35. package/dist/core/logo.js +25 -21
  36. package/dist/core/motion.js +25 -19
  37. package/dist/core/text.js +182 -75
  38. package/dist/core/theme.js +0 -28
  39. package/dist/core/transitions.js +2 -2
  40. package/dist/core/ui.js +15 -30
  41. package/dist/core/vim-keys.js +9 -15
  42. package/dist/features/about.js +23 -0
  43. package/dist/features/calendar-heatmap.js +16 -40
  44. package/dist/features/calendar-query.js +1 -2
  45. package/dist/features/calendar.js +12 -185
  46. package/dist/features/docs.js +439 -320
  47. package/dist/features/schedule-render.js +65 -102
  48. package/dist/features/schedule-store.js +51 -9
  49. package/dist/features/schedule-view.js +46 -220
  50. package/dist/features/status.js +44 -59
  51. package/dist/features/student-timetable.js +73 -95
  52. package/dist/features/theme.js +6 -5
  53. package/dist/features/timetable-sanitize.js +40 -0
  54. package/dist/features/update.js +9 -37
  55. package/dist/i18n/index.js +87 -65
  56. package/dist/i18n/locales/en.json +1 -1
  57. package/dist/i18n/locales/zh.json +1 -1
  58. package/dist/index.js +85 -64
  59. package/dist/logo/ca-dotmatrix.txt +16 -18
  60. package/dist/main.js +7 -48
  61. package/package.json +30 -18
  62. package/bin/nbtca-welcome.js +0 -2
  63. package/dist/core/components/screen.js +0 -18
  64. package/dist/core/menu.js +0 -71
  65. package/dist/features/links.js +0 -39
  66. package/dist/features/schedule-query.js +0 -47
  67. package/dist/features/settings.js +0 -130
  68. 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,26 +504,17 @@ 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 };
402
- }
403
- // ─── Document tree ────────────────────────────────────────────────────────────
404
- // Sourced from a live audit of nbtca/documents (2026-07-18): `about` and
405
- // `concepts` are two whole new top-level sections added in the repo's wiki
406
- // reconstruction (5abcc4d, 5beee27) -- omitted here, buildSections() below
407
- // silently drops every file under them, which is exactly what happened
408
- // before this fix caught up to the upstream restructuring. `about` leads
409
- // (org intro for newcomers) and `concepts` sits after the practical guide
410
- // as reference material.
509
+ return {
510
+ path: filePath,
511
+ title: renderedDoc.title,
512
+ lines: renderedDoc.rendered.split('\n'),
513
+ links,
514
+ };
515
+ }
411
516
  const TOP_SECTION_ORDER = ['about', 'guide', 'repair', 'concepts', 'archived'];
412
517
  const TOP_SECTION_SKIP = new Set(['docs', 'index.md', 'README.md']);
413
- // tutorial/ and process/ are two folders on disk but one section everywhere
414
- // a reader actually sees them: nbtca/documents' own site nav collapses both
415
- // under a single "指南/Guide" entry, and tutorial/sidebar.ts spells out why
416
- // ("「指南」= 教程(学技术)+流程(办社务)高内聚合并为一栏") -- presenting
417
- // them as two separate top-level categories in the terminal was true to the
418
- // folder layout but false to how the content is actually meant to be read.
419
518
  const SECTION_ALIAS = { tutorial: 'guide', process: 'guide' };
420
519
  export function localizeDocSections(sections, trans = t()) {
421
520
  const labels = {
@@ -427,66 +526,68 @@ export function localizeDocSections(sections, trans = t()) {
427
526
  };
428
527
  return sections.map((section) => ({ ...section, label: labels[section.key] ?? section.label }));
429
528
  }
430
- /**
431
- * Convert a kebab-case filename to a display-friendly title.
432
- * Preserves Chinese characters and date prefixes.
433
- */
434
529
  export function cleanFileName(name) {
435
- const base = name.replace(/\.md$/, '');
530
+ const base = sanitizeTerminalLine(name).replace(/\.md$/, '');
436
531
  if (/^[\d.]/.test(base))
437
532
  return base;
438
- return base
439
- .replace(/[-_]/g, ' ')
440
- .replace(/\b([a-z])/g, (_, c) => c.toUpperCase());
441
- }
442
- /**
443
- * Real titles (each document's own top-level `# heading`) for the
444
- * curated tutorial/process/repair sections, keyed by repo-relative path.
445
- * These are hand-authored English-filename docs with Chinese content —
446
- * mechanically title-casing the filename ("Clean Drive C") reads as a
447
- * different, lower-quality product than the document's own title ("C盘
448
- * 清理标准化流程"). Deliberately scoped to these three sections only:
449
- * `archived/`'s meeting notes are informal and often share the same
450
- * generic real heading across many different dates (e.g. five different
451
- * files all titled just "维修日") — there, the current filename-derived,
452
- * date-prefixed label is more useful for telling entries apart than the
453
- * real heading would be, so it is intentionally left as-is.
454
- *
455
- * Pulled from a live audit of the actual nbtca/documents content
456
- * (2026-07-16). A doc added later without an entry here simply falls
457
- * back to `cleanFileName` — never an error, never a blank label.
458
- */
459
- const KNOWN_DOC_TITLES = {
460
- 'tutorial/2025/clean-drive-c.md': 'C盘清理标准化流程',
461
- 'tutorial/2025/edu-email.md': '教育邮箱用途',
462
- 'tutorial/2025/github-education-verification.md': 'Github Education 认证指南',
463
- 'tutorial/2025/github-workflow.md': '快速上手社团目前的Github工作流',
464
- 'tutorial/2025/google-calendar.md': '谷歌日历使用指南',
465
- 'tutorial/2025/nginx-usage.md': '快速上手你的nginx',
466
- 'tutorial/2025/tailscale-usage.md': '社团自建 Tailscale 使用指南',
467
- 'tutorial/manual/hardware-establish.md': '计算机硬件系统的搭建与维护',
468
- 'tutorial/manual/net-usage.md': '国际互联网的使用',
469
- 'tutorial/manual/os-skills.md': '基础操作系统的使用技术',
470
- 'tutorial/manual/windows-from-scratch.md': '从零开始安装 Windows',
471
- 'process/2025/apply-for-credits.md': '申请第二课堂学分',
472
- 'process/2025/borrow-classroom.md': '借教室',
473
- 'process/2025/event-organization.md': '活动举办文档(待完善)',
474
- 'process/2025/nbtca-post.md': '撰写并发布你的第一篇NBTCA博客',
475
- 'process/2025/reimbursement-process.md': '报销流程',
476
- 'repair/checklist.md': '维修日检查单',
477
- 'repair/guide.md': '维修操作指南',
478
- 'repair/repair-day.md': '维修日',
479
- 'repair/tools.md': '软件仓库(校内镜像站)',
480
- 'repair/weekend.md': '维修工单系统 (weekend)',
481
- };
482
- /** Display title for a tutorial/process/repair doc: the real, known title
483
- * when we have one, otherwise the same filename-derived fallback used
484
- * everywhere else (including for every archived/ doc, which never has a
485
- * known-title entry by design). */
486
- export function displayDocTitle(path, name) {
487
- return KNOWN_DOC_TITLES[path] ?? cleanFileName(name);
488
- }
489
- /** Group flat DocItem list into top-level sections. */
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);
590
+ }
490
591
  export function buildSections(all) {
491
592
  const groups = new Map();
492
593
  for (const item of all) {
@@ -494,37 +595,38 @@ export function buildSections(all) {
494
595
  if (parts.length < 2)
495
596
  continue;
496
597
  const rawTop = parts[0];
598
+ if (!rawTop)
599
+ continue;
497
600
  if (TOP_SECTION_SKIP.has(rawTop))
498
601
  continue;
499
602
  const top = SECTION_ALIAS[rawTop] ?? rawTop;
500
603
  if (!TOP_SECTION_ORDER.includes(top))
501
604
  continue;
502
- if (!groups.has(top))
503
- groups.set(top, []);
504
- 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]);
505
611
  }
506
- return localizeDocSections(TOP_SECTION_ORDER
507
- .filter(k => groups.has(k))
508
- .map(k => ({
509
- key: k,
510
- label: k,
511
- count: groups.get(k).length,
512
- files: groups.get(k),
513
- })));
514
- }
515
- /** Group archived files by their second path component (year / manual / etc.). */
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);
617
+ }
516
618
  export function getArchivedGroups(files) {
517
619
  const groups = new Map();
518
620
  for (const item of files) {
519
621
  const group = item.path.split('/')[1] ?? 'other';
520
- if (!groups.has(group))
521
- groups.set(group, []);
522
- 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]);
523
627
  }
524
628
  return groups;
525
629
  }
526
- /** Raw fetch, no spinner/UI — throws on failure. Shared by the classic and
527
- * native-view loaders. */
528
630
  export async function fetchAllDocs() {
529
631
  return docsClient.listAll();
530
632
  }
@@ -544,19 +646,37 @@ async function loadSections() {
544
646
  return null;
545
647
  }
546
648
  }
547
- // ─── Pager layer ──────────────────────────────────────────────────────────────
548
- async function displayWithGlow(cleanedMarkdown) {
549
- const cols = String(Math.min(process.stdout.columns || 80, 80));
550
- return new Promise(resolve => {
551
- const child = spawn('glow', ['--pager', '--width', cols, '-'], {
552
- 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);
664
+ });
665
+ child.stdin.once('error', () => {
666
+ finish(true);
553
667
  });
554
- child.stdin.write(cleanedMarkdown, 'utf-8');
555
- child.stdin.end();
556
- child.on('close', resolve);
557
- child.on('error', resolve);
668
+ try {
669
+ child.stdin.end(content, 'utf8');
670
+ }
671
+ catch {
672
+ finish(false);
673
+ }
558
674
  });
559
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
+ }
560
680
  async function displayWithLess(rendered, title, filePath, readTime, toc) {
561
681
  const trans = t();
562
682
  const cols = Math.min(process.stdout.columns || 80, 80);
@@ -565,7 +685,7 @@ async function displayWithLess(rendered, title, filePath, readTime, toc) {
565
685
  ? [
566
686
  chalk.dim(` ${trans.docs.tocTitle}`),
567
687
  chalk.dim(` ${'─'.repeat(36)}`),
568
- ...toc.map(h => chalk.dim(` ${h}`)),
688
+ ...toc.map((h) => chalk.dim(` ${h}`)),
569
689
  chalk.dim(` ${'─'.repeat(36)}`),
570
690
  '',
571
691
  ].join('\n')
@@ -578,49 +698,39 @@ async function displayWithLess(rendered, title, filePath, readTime, toc) {
578
698
  ...(tocBlock ? [tocBlock] : []),
579
699
  '',
580
700
  ].join('\n');
581
- const footer = [
582
- '',
583
- rule,
584
- chalk.dim(` ${trans.docs.endOfDocument}`),
585
- '',
586
- ].join('\n');
701
+ const footer = ['', rule, chalk.dim(` ${trans.docs.endOfDocument}`), ''].join('\n');
587
702
  const fullContent = header + rendered + footer;
588
- const pagerSetting = (process.env['PAGER'] || 'less').trim();
703
+ const pagerSetting = (process.env['PAGER'] ?? 'less').trim();
589
704
  const [pagerCommand = 'less', ...pagerArgs] = pagerSetting.split(/\s+/).filter(Boolean);
590
- 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;
591
707
  if (!commandExists(pagerCommand)) {
592
708
  console.log(fullContent);
593
709
  return;
594
710
  }
595
- return new Promise(resolve => {
596
- try {
597
- const child = spawn(pagerCommand, args, { stdio: ['pipe', 'inherit', 'inherit'] });
598
- child.stdin.write(fullContent, 'utf-8');
599
- child.stdin.end();
600
- child.on('close', resolve);
601
- child.on('error', () => { console.log(fullContent); resolve(); });
602
- }
603
- catch {
604
- console.log(fullContent);
605
- resolve();
606
- }
607
- });
711
+ if (!(await pipeToPager(pagerCommand, args, fullContent)))
712
+ console.log(fullContent);
608
713
  }
609
- // ─── Section browsers ─────────────────────────────────────────────────────────
610
- /** Show a flat file list for tutorial / process / repair. */
611
714
  async function showDocSection(section) {
612
715
  const trans = t();
613
716
  if (section.key === 'archived') {
614
717
  await showArchivedSection(section.files);
615
718
  return;
616
719
  }
617
- 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.'));
618
724
  if (files.length === 0)
619
725
  return;
620
726
  const selected = await runMenu({
621
- title: section.label,
727
+ title: hydrated.label,
622
728
  options: [
623
- ...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
+ })),
624
734
  { value: '__back__', label: chalk.dim(trans.common.back) },
625
735
  ],
626
736
  footer: menuFooter(),
@@ -629,7 +739,6 @@ async function showDocSection(section) {
629
739
  return;
630
740
  await viewMarkdownFile(selected);
631
741
  }
632
- /** Show archived docs grouped by year, then files within the year. */
633
742
  async function showArchivedSection(files) {
634
743
  const trans = t();
635
744
  const groups = getArchivedGroups(files);
@@ -647,10 +756,10 @@ async function showArchivedSection(files) {
647
756
  const groupKey = await runMenu({
648
757
  title: trans.docs.categoryArchived,
649
758
  options: [
650
- ...sortedKeys.map(k => ({
759
+ ...sortedKeys.map((k) => ({
651
760
  value: k,
652
761
  label: k,
653
- hint: String(groups.get(k).length),
762
+ hint: String(groups.get(k)?.length ?? 0),
654
763
  })),
655
764
  { value: '__back__', label: chalk.dim(trans.common.back) },
656
765
  ],
@@ -658,17 +767,19 @@ async function showArchivedSection(files) {
658
767
  });
659
768
  if (groupKey === null || groupKey === '__back__')
660
769
  return;
661
- const groupFiles = groups.get(groupKey) ?? [];
662
- 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));
663
774
  const fileSelected = await runMenu({
664
775
  title: `${trans.docs.categoryArchived} · ${groupKey}`,
665
776
  options: [
666
- ...groupFiles.map(f => {
777
+ ...groupFiles.map((f) => {
667
778
  const sub = f.path.split('/').slice(2, -1).join('/');
668
779
  return {
669
780
  value: f.path,
670
- label: cleanFileName(f.name),
671
- hint: subDirs.size > 1 ? sub : undefined,
781
+ label: displayDocTitle(f.name, f.title),
782
+ ...(subDirs.size > 1 ? { hint: sanitizeTerminalLine(sub) } : {}),
672
783
  };
673
784
  }),
674
785
  { value: '__back__', label: chalk.dim(trans.common.back) },
@@ -679,31 +790,18 @@ async function showArchivedSection(files) {
679
790
  return;
680
791
  await viewMarkdownFile(fileSelected);
681
792
  }
682
- // ─── Document viewer ──────────────────────────────────────────────────────────
683
- export async function viewMarkdownFile(filePath) {
793
+ async function viewMarkdownFile(filePath) {
684
794
  const trans = t();
685
795
  ensureMarkedConfigured();
686
796
  const s = createSpinner(`${trans.docs.loadingFile}: ${filePath}`);
687
797
  try {
688
- const rawContent = await fetchFileContent(filePath);
689
- const fingerprint = contentFingerprint(rawContent);
690
- const cachedRendered = getFreshRender(filePath);
691
- let renderedDoc;
692
- if (cachedRendered && cachedRendered.fingerprint === fingerprint) {
693
- renderedDoc = cachedRendered;
694
- }
695
- else {
696
- const cleaned = cleanMarkdownContent(rawContent, getTerminalType());
697
- const title = extractDocTitle(rawContent, cleaned) || cleanFileName(filePath.split('/').pop() ?? filePath);
698
- const readTime = estimateReadTime(cleaned);
699
- const rendered = await marked(cleaned);
700
- renderedDoc = { fingerprint, cleaned, rendered, title, readTime };
701
- setRender(filePath, renderedDoc);
702
- }
798
+ const { rawContent, renderedDoc } = await loadRenderedDoc(filePath);
703
799
  s.stop(`${chalk.bold(renderedDoc.title)} ${chalk.dim(renderedDoc.readTime)}`);
704
800
  const toc = extractTOC(renderedDoc.cleaned);
705
801
  if (hasGlow()) {
706
- await displayWithGlow(renderedDoc.cleaned);
802
+ if (!(await displayWithGlow(renderedDoc.cleaned))) {
803
+ await displayWithLess(renderedDoc.rendered, renderedDoc.title, filePath, renderedDoc.readTime, toc);
804
+ }
707
805
  }
708
806
  else {
709
807
  await displayWithLess(renderedDoc.rendered, renderedDoc.title, filePath, renderedDoc.readTime, toc);
@@ -713,8 +811,11 @@ export async function viewMarkdownFile(filePath) {
713
811
  title: trans.docs.chooseAction,
714
812
  options: [
715
813
  { value: 'back', label: trans.docs.backToList },
716
- { value: 'browser', label: trans.docs.openBrowser,
717
- hint: needsBrowser ? trans.docs.tableHint : undefined },
814
+ {
815
+ value: 'browser',
816
+ label: trans.docs.openBrowser,
817
+ ...(needsBrowser ? { hint: trans.docs.tableHint } : {}),
818
+ },
718
819
  ],
719
820
  footer: menuFooter(),
720
821
  });
@@ -724,7 +825,7 @@ export async function viewMarkdownFile(filePath) {
724
825
  }
725
826
  catch (err) {
726
827
  s.error(trans.docs.loadError);
727
- const errMsg = err instanceof Error ? err.message : String(err);
828
+ const errMsg = sanitizeTerminalLine(err instanceof Error ? err.message : String(err));
728
829
  console.log(chalk.gray(` ${trans.docs.errorHint}: ${errMsg}`));
729
830
  const openBrowser = await runConfirm({ message: trans.docs.openBrowserPrompt });
730
831
  if (openBrowser === true) {
@@ -732,14 +833,24 @@ export async function viewMarkdownFile(filePath) {
732
833
  }
733
834
  }
734
835
  }
735
- // ─── Browser fallback ─────────────────────────────────────────────────────────
736
836
  export async function openDocsInBrowser(path) {
737
837
  const trans = t();
738
838
  const s = createSpinner(trans.docs.opening);
739
839
  try {
740
- const url = path
741
- ? `${URLS.docs}/${path.replace(/\.md$/, '')}`
742
- : 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;
743
854
  await open(url);
744
855
  s.stop(trans.docs.browserOpened);
745
856
  }
@@ -749,20 +860,25 @@ export async function openDocsInBrowser(path) {
749
860
  }
750
861
  console.log();
751
862
  }
752
- // ─── 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
+ }
753
871
  async function searchDocs() {
754
872
  const trans = t();
755
873
  const query = await runTextInput({
756
874
  message: trans.docs.searchPrompt,
757
875
  placeholder: trans.docs.searchPlaceholder,
758
876
  });
759
- if (query === null || !query.trim())
877
+ if (!query?.trim())
760
878
  return;
761
- const keyword = query.trim().toLowerCase();
762
879
  const s = createSpinner(trans.docs.searching);
763
880
  try {
764
- const all = await docsClient.listAll();
765
- const results = all.filter(item => item.path.toLowerCase().includes(keyword));
881
+ const results = await searchDocuments(query.trim());
766
882
  s.stop(`${results.length} ${trans.docs.searchResults}`);
767
883
  if (results.length === 0) {
768
884
  warning(trans.docs.searchNoResults);
@@ -771,10 +887,14 @@ async function searchDocs() {
771
887
  const selected = await runMenu({
772
888
  title: trans.docs.chooseDoc,
773
889
  options: [
774
- ...results.map(r => ({
890
+ ...results.map((r) => ({
775
891
  value: r.path,
776
- label: cleanFileName(r.name),
777
- 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),
778
898
  })),
779
899
  { value: '__back__', label: chalk.dim(trans.docs.returnToMenu) },
780
900
  ],
@@ -788,18 +908,17 @@ async function searchDocs() {
788
908
  s.error(trans.docs.loadError);
789
909
  }
790
910
  }
791
- // ─── Menu ─────────────────────────────────────────────────────────────────────
792
911
  export async function showDocsMenu() {
793
912
  await enterScreen(breadcrumb(t().menu.docs));
794
- let sections = await loadSections();
913
+ const sections = await loadSections();
795
914
  if (!sections)
796
915
  return;
797
- while (true) {
916
+ for (;;) {
798
917
  const trans = t();
799
918
  const action = await runMenu({
800
919
  title: trans.docs.chooseCategory,
801
920
  options: [
802
- ...sections.map(sec => ({ value: sec.key, label: sec.label })),
921
+ ...sections.map((sec) => ({ value: sec.key, label: sec.label })),
803
922
  { value: 'search', label: chalk.dim(trans.docs.searchPrompt.replace(':', '')) },
804
923
  { value: 'browser', label: chalk.dim(trans.docs.openBrowser) },
805
924
  ],
@@ -814,7 +933,7 @@ export async function showDocsMenu() {
814
933
  await openDocsInBrowser();
815
934
  }
816
935
  else {
817
- const section = sections.find(s => s.key === action);
936
+ const section = sections.find((s) => s.key === action);
818
937
  if (section)
819
938
  await showDocSection(section);
820
939
  }