@nbtca/prompt 1.4.2 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +27 -58
  2. package/SECURITY.md +16 -45
  3. package/dist/app/app.js +167 -64
  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 +16 -23
  8. package/dist/app/keys.js +110 -2
  9. package/dist/app/views/docs-render.js +34 -26
  10. package/dist/app/views/docs.js +280 -68
  11. package/dist/app/views/events-render.js +21 -27
  12. package/dist/app/views/events.js +57 -33
  13. package/dist/app/views/home.js +67 -47
  14. package/dist/app/views/schedule-grid-cursor.js +9 -18
  15. package/dist/app/views/schedule-render.js +51 -74
  16. package/dist/app/views/schedule.js +246 -101
  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/cli.js +570 -0
  24. package/dist/config/data.js +9 -11
  25. package/dist/config/preferences.js +21 -7
  26. package/dist/core/calendar-day.js +37 -0
  27. package/dist/core/canvas.js +1 -0
  28. package/dist/core/capabilities.js +6 -3
  29. package/dist/core/components/confirm.js +9 -8
  30. package/dist/core/components/menu.js +64 -38
  31. package/dist/core/components/messages.js +12 -4
  32. package/dist/core/components/painter.js +3 -1
  33. package/dist/core/components/spinner.js +34 -7
  34. package/dist/core/components/text-input.js +24 -18
  35. package/dist/core/icons.js +2 -2
  36. package/dist/core/logo.js +23 -5
  37. package/dist/core/motion.js +25 -19
  38. package/dist/core/text.js +186 -69
  39. package/dist/core/theme.js +0 -28
  40. package/dist/core/transitions.js +2 -2
  41. package/dist/core/ui.js +15 -13
  42. package/dist/core/vim-keys.js +156 -19
  43. package/dist/features/about.js +23 -0
  44. package/dist/features/calendar-heatmap.js +16 -40
  45. package/dist/features/calendar-query.js +1 -2
  46. package/dist/features/calendar-store.js +27 -0
  47. package/dist/features/calendar.js +66 -190
  48. package/dist/features/docs-client.js +225 -0
  49. package/dist/features/docs.js +615 -298
  50. package/dist/features/links.js +44 -29
  51. package/dist/features/schedule-render.js +65 -101
  52. package/dist/features/schedule-store.js +51 -9
  53. package/dist/features/schedule-view.js +46 -213
  54. package/dist/features/status.js +117 -60
  55. package/dist/features/student-timetable.js +74 -97
  56. package/dist/features/theme.js +9 -5
  57. package/dist/features/timetable-sanitize.js +40 -0
  58. package/dist/features/update.js +12 -29
  59. package/dist/i18n/index.js +83 -19
  60. package/dist/i18n/locales/en.json +8 -3
  61. package/dist/i18n/locales/zh.json +8 -3
  62. package/dist/index.js +6 -474
  63. package/dist/logo/ca-dotmatrix.txt +16 -18
  64. package/dist/main.js +7 -48
  65. package/package.json +28 -18
  66. package/bin/nbtca-welcome.js +0 -2
  67. package/dist/core/components/screen.js +0 -18
  68. package/dist/core/menu.js +0 -68
  69. package/dist/features/schedule-query.js +0 -47
  70. package/dist/features/settings.js +0 -127
  71. package/dist/logo/ca-logo.png +0 -0
@@ -1,7 +1,7 @@
1
1
  import { marked } from 'marked';
2
2
  import { markedTerminal } from 'marked-terminal';
3
3
  import chalk from 'chalk';
4
- import open from 'open';
4
+ import { createHash } from 'node:crypto';
5
5
  import { runMenu, menuFooter } from '../core/components/menu.js';
6
6
  import { runTextInput } from '../core/components/text-input.js';
7
7
  import { runConfirm } from '../core/components/confirm.js';
@@ -9,25 +9,31 @@ import { warning, createSpinner } from '../core/ui.js';
9
9
  import { pickIcon } from '../core/icons.js';
10
10
  import { spawn, execFileSync } from 'child_process';
11
11
  import { URLS } from '../config/data.js';
12
- import { t, fmt } from '../i18n/index.js';
12
+ import { t, fmt, getCurrentLanguage } from '../i18n/index.js';
13
13
  import { enterScreen, breadcrumb } from '../core/transitions.js';
14
- import { createDocsClient } from '@nbtca/docs';
14
+ import { sanitizeTerminalLine, sanitizeTerminalText, stripAnsi, truncate } from '../core/text.js';
15
+ import { clearDocsClients, runDocsClientOperation } from './docs-client.js';
16
+ import { launchBrowserUrl } from './links.js';
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
  }
@@ -63,7 +62,8 @@ export function ensureMarkedConfigured() {
63
62
  if (_markedConfigured)
64
63
  return;
65
64
  _markedConfigured = true;
66
- const extension = markedTerminal(getRendererOptions(getTerminalType()));
65
+ const terminalType = getTerminalType();
66
+ const extension = markedTerminal(getRendererOptions(terminalType));
67
67
  const renderer = extension.renderer ?? (extension.renderer = {});
68
68
  const renderExternalLink = renderer.link;
69
69
  if (renderExternalLink) {
@@ -73,56 +73,58 @@ export function ensureMarkedConfigured() {
73
73
  return renderExternalLink.call(this, token);
74
74
  };
75
75
  }
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
76
  const renderPlainText = renderer.text;
88
77
  if (renderPlainText) {
89
78
  renderer.text = function (token) {
90
79
  const withTokens = token;
91
80
  if (Array.isArray(withTokens.tokens) && withTokens.tokens.length > 0) {
92
- return this
93
- .parser.parseInline(withTokens.tokens);
81
+ return this.parser.parseInline(withTokens.tokens);
94
82
  }
95
83
  return renderPlainText.call(this, token);
96
84
  };
97
85
  }
98
86
  marked.use(extension);
99
87
  }
100
- // ─── marked-terminal renderer ─────────────────────────────────────────────────
101
88
  function getRendererOptions(type) {
102
89
  const width = 80;
103
90
  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: '│'
91
+ top: '─',
92
+ 'top-mid': '┬',
93
+ 'top-left': '┌',
94
+ 'top-right': '┐',
95
+ bottom: '─',
96
+ 'bottom-mid': '┴',
97
+ 'bottom-left': '└',
98
+ 'bottom-right': '┘',
99
+ left: '│',
100
+ 'left-mid': '├',
101
+ mid: '─',
102
+ 'mid-mid': '┼',
103
+ right: '│',
104
+ 'right-mid': '┤',
105
+ middle: '│',
108
106
  };
109
107
  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: '|'
108
+ top: '-',
109
+ 'top-mid': '+',
110
+ 'top-left': '+',
111
+ 'top-right': '+',
112
+ bottom: '-',
113
+ 'bottom-mid': '+',
114
+ 'bottom-left': '+',
115
+ 'bottom-right': '+',
116
+ left: '|',
117
+ 'left-mid': '+',
118
+ mid: '-',
119
+ 'mid-mid': '+',
120
+ right: '|',
121
+ 'right-mid': '+',
122
+ middle: '|',
114
123
  };
115
124
  return {
116
125
  width,
117
126
  emoji: true,
118
127
  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
128
  showSectionPrefix: false,
127
129
  firstHeading: chalk.bold.cyan,
128
130
  heading: chalk.bold.white,
@@ -135,14 +137,18 @@ function getRendererOptions(type) {
135
137
  link: chalk.cyan,
136
138
  href: chalk.cyan.underline,
137
139
  tableOptions: {
138
- chars: type === 'basic' ? asciiTableChars : unicodeTableChars
139
- }
140
+ chars: type === 'basic' ? asciiTableChars : unicodeTableChars,
141
+ },
140
142
  };
141
143
  }
142
144
  const RENDER_CACHE_TTL_MS = 10 * 60 * 1000;
143
145
  const RENDER_CACHE_MAX = 50;
144
146
  const renderCache = new Map();
145
- let docsClient = createDocsClient();
147
+ const METADATA_CACHE_TTL_MS = 10 * 60 * 1000;
148
+ const METADATA_CACHE_MAX = 200;
149
+ const METADATA_CONCURRENCY = 4;
150
+ const metadataCache = new Map();
151
+ let cacheGeneration = 0;
146
152
  function getFreshRender(key) {
147
153
  const entry = renderCache.get(key);
148
154
  return entry && entry.expiresAt > Date.now() ? entry.value : null;
@@ -156,27 +162,108 @@ function setRender(key, value) {
156
162
  }
157
163
  }
158
164
  function contentFingerprint(content) {
159
- return `${content.length}:${content.slice(0, 80)}:${content.slice(-80)}`;
165
+ return createHash('sha256').update(content).digest('base64url');
166
+ }
167
+ function renderCacheKey(filePath) {
168
+ return [
169
+ filePath,
170
+ getCurrentLanguage(),
171
+ getTerminalType(),
172
+ pickIcon('unicode', 'ascii'),
173
+ chalk.level,
174
+ ].join('\0');
160
175
  }
161
176
  export function clearDocsCache() {
162
- docsClient.clear();
177
+ cacheGeneration += 1;
178
+ clearDocsClients();
163
179
  renderCache.clear();
180
+ metadataCache.clear();
164
181
  }
165
- async function fetchFileContent(path) {
182
+ async function fetchDocument(path, signal) {
166
183
  try {
167
- return await docsClient.getFile(path);
184
+ return await runDocsClientOperation(signal, (client) => client.getDocument(path));
168
185
  }
169
186
  catch (err) {
170
187
  const trans = t();
171
- throw new Error(fmt(trans.docs.fetchFileFailed, { error: String(err) }));
188
+ throw new Error(fmt(trans.docs.fetchFileFailed, { error: sanitizeTerminalLine(String(err)) }));
172
189
  }
173
190
  }
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
- */
191
+ function metadataFromPage(page) {
192
+ return {
193
+ title: sanitizeTerminalLine(page.title),
194
+ summary: sanitizeTerminalLine(page.summary),
195
+ route: page.route,
196
+ };
197
+ }
198
+ function getFreshMetadata(path) {
199
+ const entry = metadataCache.get(path);
200
+ if (!entry)
201
+ return null;
202
+ if (entry.expiresAt <= Date.now()) {
203
+ metadataCache.delete(path);
204
+ return null;
205
+ }
206
+ metadataCache.delete(path);
207
+ metadataCache.set(path, entry);
208
+ return entry.value;
209
+ }
210
+ function setMetadata(path, value) {
211
+ metadataCache.delete(path);
212
+ metadataCache.set(path, { value, expiresAt: Date.now() + METADATA_CACHE_TTL_MS });
213
+ if (metadataCache.size > METADATA_CACHE_MAX) {
214
+ const oldest = metadataCache.keys().next().value;
215
+ if (oldest)
216
+ metadataCache.delete(oldest);
217
+ }
218
+ }
219
+ function loadDocMetadata(path, signal) {
220
+ const cached = getFreshMetadata(path);
221
+ if (cached)
222
+ return Promise.resolve(cached);
223
+ const generation = cacheGeneration;
224
+ return fetchDocument(path, signal).then((page) => {
225
+ const metadata = metadataFromPage(page);
226
+ if (generation === cacheGeneration)
227
+ setMetadata(path, metadata);
228
+ return metadata;
229
+ });
230
+ }
231
+ function normalizeRenderedTasks(content, type) {
232
+ if (type !== 'basic')
233
+ return content;
234
+ return content
235
+ .split('\n')
236
+ .map((line) => /^\s*(?:[-*+]|\d+[.)])\s+\[X\](?:\s|$)/.test(stripAnsi(line))
237
+ ? line.replace('[X]', '[x]')
238
+ : line)
239
+ .join('\n');
240
+ }
241
+ async function loadRenderedDoc(filePath, signal) {
242
+ const generation = cacheGeneration;
243
+ const page = await fetchDocument(filePath, signal);
244
+ if (generation === cacheGeneration)
245
+ setMetadata(filePath, metadataFromPage(page));
246
+ const rawContent = page.content;
247
+ const fingerprint = contentFingerprint(rawContent);
248
+ const cacheKey = renderCacheKey(filePath);
249
+ const cached = getFreshRender(cacheKey);
250
+ if (cached?.fingerprint === fingerprint)
251
+ return { rawContent, renderedDoc: cached };
252
+ const terminalType = getTerminalType();
253
+ const cleaned = cleanMarkdownContent(rawContent, terminalType);
254
+ const title = sanitizeTerminalLine(page.title) || cleanFileName(filePath.split('/').pop() ?? filePath);
255
+ const markedOutput = normalizeRenderedTasks(await marked(cleaned), terminalType);
256
+ const renderedDoc = {
257
+ fingerprint,
258
+ cleaned,
259
+ rendered: chalk.level === 0 ? sanitizeTerminalText(markedOutput) : markedOutput,
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,245 @@ 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
  };
239
- export function cleanMarkdownContent(content, type = getTerminalType()) {
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
+ }
395
+ function replaceHtmlCheckboxes(content, type) {
396
+ return content.replace(/<input\b([^>]*)\/?\s*>/gi, (tag, attributes) => {
397
+ const typeMatch = /(?:^|\s)type\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>/]+))/i.exec(attributes);
398
+ const inputType = (typeMatch?.[1] ?? typeMatch?.[2] ?? typeMatch?.[3] ?? '')
399
+ .trim()
400
+ .toLowerCase();
401
+ if (inputType !== 'checkbox')
402
+ return tag;
403
+ const checked = /(?:^|\s)checked(?=\s|=|\/|$)/i.test(attributes);
404
+ if (type === 'basic')
405
+ return checked ? '[x]' : '[ ]';
406
+ return checked ? '☑' : '☐';
407
+ });
408
+ }
409
+ function expandHtmlDetails(content) {
410
+ return content
411
+ .replace(/<summary\b[^>]*>([\s\S]*?)<\/summary\s*>/gi, (_match, label) => {
412
+ const summary = sanitizeTerminalLine(label);
413
+ return summary ? `\n\n### ${summary}\n\n` : '\n';
414
+ })
415
+ .replace(/<\/?(?:details|summary)\b[^>]*>/gi, '');
416
+ }
417
+ function transformOutsideFencedCodeBlocks(content, transform) {
418
+ const output = [];
419
+ let plain = [];
420
+ let fenceCharacter = '';
421
+ let fenceWidth = 0;
422
+ let listIndent = 0;
423
+ let listQuoteDepth = 0;
424
+ const flushPlain = () => {
425
+ if (plain.length === 0)
426
+ return;
427
+ output.push(transform(plain.join('\n')));
428
+ plain = [];
429
+ };
430
+ const lineContext = (line) => {
431
+ let rest = line;
432
+ let quoteDepth = 0;
433
+ for (;;) {
434
+ const quote = /^[ \t]{0,3}>[ \t]?/.exec(rest)?.[0];
435
+ if (!quote)
436
+ break;
437
+ quoteDepth += 1;
438
+ rest = rest.slice(quote.length);
439
+ }
440
+ return { quoteDepth, rest };
441
+ };
442
+ const fenceMarker = (line) => {
443
+ let { rest } = lineContext(line);
444
+ const list = /^[ \t]*(?:[-+*]|\d+[.)])[ \t]+/.exec(rest)?.[0];
445
+ if (list)
446
+ rest = rest.slice(list.length);
447
+ else if (listIndent > 0 && rest.startsWith(' '.repeat(listIndent))) {
448
+ rest = rest.slice(listIndent);
449
+ }
450
+ return /^ {0,3}(`{3,}|~{3,})/.exec(rest)?.[1];
451
+ };
452
+ for (const line of content.split('\n')) {
453
+ if (fenceWidth === 0) {
454
+ const { quoteDepth, rest } = lineContext(line);
455
+ const list = /^([ ]*)(?:[-+*]|\d+[.)])([ \t]+)/.exec(rest);
456
+ if (list) {
457
+ listIndent = list[0].length;
458
+ listQuoteDepth = quoteDepth;
459
+ }
460
+ else if (rest.trim() && (quoteDepth !== listQuoteDepth || rest.search(/\S/) < listIndent)) {
461
+ listIndent = 0;
462
+ listQuoteDepth = quoteDepth;
463
+ }
464
+ const opening = fenceMarker(line);
465
+ if (!opening) {
466
+ plain.push(line);
467
+ continue;
468
+ }
469
+ flushPlain();
470
+ fenceCharacter = opening[0] ?? '';
471
+ fenceWidth = opening.length;
472
+ output.push(line);
473
+ continue;
474
+ }
475
+ output.push(line);
476
+ const closing = fenceMarker(line);
477
+ const closingSuffix = closing ? line.slice(line.lastIndexOf(closing) + closing.length) : line;
478
+ if (closing?.[0] === fenceCharacter &&
479
+ closing.length >= fenceWidth &&
480
+ closingSuffix.trim() === '') {
481
+ fenceCharacter = '';
482
+ fenceWidth = 0;
483
+ }
484
+ }
485
+ flushPlain();
486
+ return output.join('\n');
487
+ }
488
+ function cleanMarkdownOutsideFences(content, terminalType) {
240
489
  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
244
- c = processFencedCodeBlocks(c);
245
- // 2. VitePress script / style blocks
246
490
  c = c.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
247
491
  c = c.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
248
- // 3. VitePress containers → blockquote with icon
492
+ c = replaceDocumentComponents(c);
249
493
  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));
494
+ const label = title.trim() || type.charAt(0).toUpperCase() + type.slice(1);
251
495
  const icon = pickIcon(CONTAINER_ICONS_UNICODE[type] ?? '', CONTAINER_ICONS_ASCII[type] ?? '');
252
- const quoted = body.trimEnd().split('\n').map(l => `> ${l}`).join('\n');
496
+ const quoted = body
497
+ .trimEnd()
498
+ .split('\n')
499
+ .map((l) => `> ${l}`)
500
+ .join('\n');
253
501
  return `> ${icon} **${label}**\n>\n${quoted}\n`;
254
502
  });
255
503
  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
504
  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
505
  c = c.replace(/\[\[toc\]\]/gi, '');
268
- // 5.5. VitePress heading anchors {#custom-id} — no value in terminal
269
506
  c = c.replace(/^(#{1,6}\s+[^\n]*?)\s*\{#[^}]+\}\s*$/gm, '$1');
270
- // 5.6. ==highlight== → bold (VitePress extended syntax)
271
507
  c = c.replace(/==([^=\n]+)==/g, '**$1**');
272
- // 6. Images — adapt to terminal capability
273
- if (type === 'basic') {
274
- c = c.replace(/!\[([^\]]*)\]\([^)]+\)/g, (_, alt) => `${pickIcon('📎', '[image]')} ${alt || 'image'}`);
508
+ if (terminalType === 'basic') {
509
+ c = c.replace(/!\[([^\]]*)\]\([^)]+\)/g, (_match, alt) => `${pickIcon('📎', '[image]')} ${alt.length > 0 ? alt : 'image'}`);
275
510
  }
276
511
  else {
277
- c = c.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) => {
278
- const filename = url.split('/').pop() || url;
279
- return `${pickIcon('🖼️', '[image]')} **${alt || 'image'}** _(${filename})_`;
512
+ c = c.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, url) => {
513
+ const basename = url.split('/').pop();
514
+ const filename = basename?.length ? basename : url;
515
+ return `${pickIcon('🖼️', '[image]')} **${alt.length > 0 ? alt : 'image'}** _(${filename})_`;
280
516
  });
281
517
  }
282
- // 7. HTML comments
283
518
  c = c.replace(/<!--[\s\S]*?-->/g, '');
284
- // 8. Strip HTML tags, keep inner text
285
- c = c.replace(/<br\s*\/?>/gi, '\n'); // void: line break
286
- c = c.replace(/<(?:hr|input|link|meta)\b[^>]*\/?>/gi, ''); // void: discard
519
+ c = replaceHtmlCheckboxes(c, terminalType);
520
+ c = expandHtmlDetails(c);
521
+ c = c.replace(/<br\s*\/?>/gi, '\n');
522
+ c = c.replace(/<(?:hr|input|link|meta)\b[^>]*\/?>/gi, '');
287
523
  c = c.replace(/<([a-z][a-z0-9]*)\b[^>]*>([\s\S]*?)<\/\1>/gi, '$2');
288
524
  c = c.replace(/<[a-z][a-z0-9]*\b[^>]*\/>/gi, '');
289
- // 8.5. Task list checkboxes
290
- c = c.replace(/^(\s*[-*+] )\[x\] /gim, '$1☑ ');
291
- c = c.replace(/^(\s*[-*+] )\[ \] /gm, '$1☐ ');
292
- // 9. Collapse runs of 3+ blank lines
293
- c = c.replace(/\n{3,}/g, '\n\n');
294
- return c.trim();
295
- }
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();
525
+ c = c.replace(/<\/(?:Split|TimelineEntry)>/gi, '');
526
+ if (terminalType === 'basic') {
527
+ c = c.replace(/^(\s*(?:[-*+]|\d+[.)]) )\[x\]/gim, '$1[x]');
302
528
  }
303
- const h1Match = cleanedContent.match(/^#\s+(.+)$/m);
304
- return h1Match?.[1]?.trim() ?? null;
529
+ else {
530
+ c = c.replace(/^(\s*(?:[-*+]|\d+[.)]) )\[x\] /gim, '$1☑ ');
531
+ c = c.replace(/^(\s*(?:[-*+]|\d+[.)]) )\[ \] /gm, '$1☐ ');
532
+ }
533
+ return c.replace(/\n{3,}/g, '\n\n');
534
+ }
535
+ export function cleanMarkdownContent(content, type = getTerminalType()) {
536
+ let c = sanitizeTerminalText(content);
537
+ c = c.replace(/^---\n[\s\S]*?\n---(?:\n|$)/, '');
538
+ c = processFencedCodeBlocks(c);
539
+ c = transformOutsideFencedCodeBlocks(c, (value) => cleanMarkdownOutsideFences(value, type));
540
+ return c.trim();
305
541
  }
306
- /** Approximate reading time: ~200 words/min for technical Chinese/English prose. */
307
542
  function estimateReadTime(text) {
308
- const cjkChars = (text.match(/[㐀-鿿]/g) || []).length;
543
+ const cjkChars = [...text.matchAll(/[㐀-鿿]/g)].length;
309
544
  const nonCjk = text.replace(/[㐀-鿿]/g, ' ');
310
545
  const words = nonCjk.trim().split(/\s+/).filter(Boolean).length;
311
546
  const units = words + cjkChars / 2;
312
547
  const mins = Math.max(1, Math.ceil(units / 220));
313
548
  return mins === 1 ? '~1 min' : `~${mins} min`;
314
549
  }
315
- /** Extract h2/h3 headings for TOC display (skips the h1 title). */
316
550
  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(/^(#+)/);
551
+ const lines = content.split('\n').filter((l) => /^#{2,3}\s/.test(l));
552
+ return lines.map((l) => {
553
+ const m = /^(#+)/.exec(l);
320
554
  const level = m?.[1]?.length ?? 2;
321
555
  const text = l.replace(/^#+\s+/, '').trim();
322
556
  return (level === 3 ? ' ' : '') + text;
323
557
  });
324
558
  }
325
- /** True if the markdown source contains a pipe table. */
326
559
  function hasMarkdownTable(content) {
327
560
  return /^\|.+\|/m.test(content) && /^\|[-: |]+\|/m.test(content);
328
561
  }
329
- /** True if the markdown source contains a mermaid diagram block. */
330
562
  function hasMermaidBlock(content) {
331
563
  return /^```mermaid\b/m.test(content);
332
564
  }
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
565
  function extractInternalLinks(markdown) {
336
566
  const links = [];
337
567
  const re = /\[([^\]]+)\]\(([^)]+)\)/g;
@@ -343,14 +573,14 @@ function extractInternalLinks(markdown) {
343
573
  }
344
574
  return links;
345
575
  }
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) {
576
+ export function resolveInternalHref(href, fromPath) {
577
+ const normalizedHref = href.split(/[?#]/, 1)[0] ?? '';
352
578
  const fromDir = fromPath.includes('/') ? fromPath.slice(0, fromPath.lastIndexOf('/')) : '';
353
- const combined = href.startsWith('/') ? href.slice(1) : (fromDir ? `${fromDir}/${href}` : href);
579
+ const combined = normalizedHref.startsWith('/')
580
+ ? normalizedHref.slice(1)
581
+ : fromDir
582
+ ? `${fromDir}/${normalizedHref}`
583
+ : normalizedHref;
354
584
  const stack = [];
355
585
  for (const part of combined.split('/')) {
356
586
  if (part === '' || part === '.')
@@ -362,33 +592,15 @@ function resolveInternalHref(href, fromPath) {
362
592
  stack.push(part);
363
593
  }
364
594
  let target = stack.join('/');
365
- if (target === '' || href.endsWith('/'))
595
+ if (target === '' || normalizedHref.endsWith('/'))
366
596
  target += (target ? '/' : '') + 'index';
367
597
  if (!target.endsWith('.md'))
368
598
  target += '.md';
369
599
  return target;
370
600
  }
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
- export async function loadDocForReader(filePath) {
601
+ export async function loadDocForReader(filePath, signal) {
376
602
  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
- }
603
+ const { renderedDoc } = await loadRenderedDoc(filePath, signal);
392
604
  const seen = new Set();
393
605
  const links = [];
394
606
  for (const raw of extractInternalLinks(renderedDoc.cleaned)) {
@@ -396,11 +608,15 @@ export async function loadDocForReader(filePath) {
396
608
  if (seen.has(resolved))
397
609
  continue;
398
610
  seen.add(resolved);
399
- links.push({ text: raw.text, href: resolved });
611
+ links.push({ text: sanitizeTerminalLine(raw.text), href: resolved });
400
612
  }
401
- return { path: filePath, title: renderedDoc.title, lines: renderedDoc.rendered.split('\n'), links };
613
+ return {
614
+ path: filePath,
615
+ title: renderedDoc.title,
616
+ lines: renderedDoc.rendered.split('\n'),
617
+ links,
618
+ };
402
619
  }
403
- // ─── Document tree ────────────────────────────────────────────────────────────
404
620
  const TOP_SECTION_ORDER = ['about', 'guide', 'repair', 'concepts', 'archived'];
405
621
  const TOP_SECTION_SKIP = new Set(['docs', 'index.md', 'README.md']);
406
622
  const SECTION_ALIAS = { tutorial: 'guide', process: 'guide' };
@@ -415,38 +631,73 @@ export function localizeDocSections(sections, trans = t()) {
415
631
  return sections.map((section) => ({ ...section, label: labels[section.key] ?? section.label }));
416
632
  }
417
633
  export function cleanFileName(name) {
418
- const base = name.replace(/\.md$/, '');
634
+ const base = sanitizeTerminalLine(name).replace(/\.md$/, '');
419
635
  if (/^[\d.]/.test(base))
420
636
  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);
637
+ return base.replace(/[-_]/g, ' ').replace(/\b([a-z])/g, (_, c) => c.toUpperCase());
638
+ }
639
+ export function displayDocTitle(name, title) {
640
+ const candidate = title?.trim();
641
+ return sanitizeTerminalLine(candidate?.length ? candidate : cleanFileName(name));
642
+ }
643
+ function listedDoc(item, metadata) {
644
+ return {
645
+ ...item,
646
+ name: sanitizeTerminalLine(item.name),
647
+ title: displayDocTitle(item.name, metadata?.title),
648
+ summary: sanitizeTerminalLine(metadata?.summary ?? ''),
649
+ };
650
+ }
651
+ export async function fetchDocMetadata(items, signal) {
652
+ const results = items.map((item) => listedDoc(item));
653
+ let nextIndex = 0;
654
+ async function worker() {
655
+ for (;;) {
656
+ const index = nextIndex;
657
+ nextIndex += 1;
658
+ if (index >= items.length)
659
+ return;
660
+ const item = items[index];
661
+ if (!item)
662
+ continue;
663
+ if (signal?.aborted) {
664
+ throw signal.reason instanceof Error
665
+ ? signal.reason
666
+ : new DOMException('Aborted', 'AbortError');
667
+ }
668
+ try {
669
+ results[index] = listedDoc(item, await loadDocMetadata(item.path, signal));
670
+ }
671
+ catch (error) {
672
+ if (signal?.aborted)
673
+ throw error;
674
+ results[index] = listedDoc(item);
675
+ }
676
+ }
677
+ }
678
+ const workerCount = Math.min(METADATA_CONCURRENCY, items.length);
679
+ await Promise.all(Array.from({ length: workerCount }, worker));
680
+ return results;
681
+ }
682
+ export async function fetchSectionMetadata(section, signal) {
683
+ const files = await fetchDocMetadata(section.files, signal);
684
+ return { ...section, count: files.length, files };
685
+ }
686
+ function searchDoc(result) {
687
+ return {
688
+ name: sanitizeTerminalLine(result.name),
689
+ path: result.path,
690
+ type: 'file',
691
+ title: displayDocTitle(result.name, result.title),
692
+ summary: sanitizeTerminalLine(result.summary),
693
+ excerpt: sanitizeTerminalLine(result.excerpt),
694
+ route: result.route,
695
+ score: result.score,
696
+ section: result.section,
697
+ };
698
+ }
699
+ export async function searchDocuments(query, signal) {
700
+ return (await runDocsClientOperation(signal, (client) => client.search(query, { limit: 20 }))).map(searchDoc);
450
701
  }
451
702
  export function buildSections(all) {
452
703
  const groups = new Map();
@@ -455,39 +706,43 @@ export function buildSections(all) {
455
706
  if (parts.length < 2)
456
707
  continue;
457
708
  const rawTop = parts[0];
709
+ if (!rawTop)
710
+ continue;
458
711
  if (TOP_SECTION_SKIP.has(rawTop))
459
712
  continue;
460
713
  const top = SECTION_ALIAS[rawTop] ?? rawTop;
461
714
  if (!TOP_SECTION_ORDER.includes(top))
462
715
  continue;
463
- if (!groups.has(top))
464
- groups.set(top, []);
465
- groups.get(top).push(item);
466
- }
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
- })));
716
+ const items = groups.get(top);
717
+ const file = listedDoc(item);
718
+ if (items)
719
+ items.push(file);
720
+ else
721
+ groups.set(top, [file]);
722
+ }
723
+ const orderedGroups = TOP_SECTION_ORDER.flatMap((key) => {
724
+ const files = groups.get(key);
725
+ return files ? [{ key, label: key, count: files.length, files }] : [];
726
+ });
727
+ return localizeDocSections(orderedGroups);
475
728
  }
476
729
  export function getArchivedGroups(files) {
477
730
  const groups = new Map();
478
731
  for (const item of files) {
479
732
  const group = item.path.split('/')[1] ?? 'other';
480
- if (!groups.has(group))
481
- groups.set(group, []);
482
- groups.get(group).push(item);
733
+ const items = groups.get(group);
734
+ if (items)
735
+ items.push(item);
736
+ else
737
+ groups.set(group, [item]);
483
738
  }
484
739
  return groups;
485
740
  }
486
- export async function fetchAllDocs() {
487
- return docsClient.listAll();
741
+ export async function fetchAllDocs(signal) {
742
+ return runDocsClientOperation(signal, (client) => client.listAll());
488
743
  }
489
- export async function fetchSections() {
490
- return buildSections(await fetchAllDocs());
744
+ export async function fetchSections(signal) {
745
+ return buildSections(await fetchAllDocs(signal));
491
746
  }
492
747
  async function loadSections() {
493
748
  const trans = t();
@@ -502,19 +757,49 @@ async function loadSections() {
502
757
  return null;
503
758
  }
504
759
  }
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']
760
+ function pipeToPager(command, args, content, plain = false) {
761
+ return new Promise((resolve) => {
762
+ const env = { ...process.env };
763
+ if (plain) {
764
+ delete env['FORCE_COLOR'];
765
+ env['NO_COLOR'] = '1';
766
+ env['CLICOLOR'] = '0';
767
+ env['CLICOLOR_FORCE'] = '0';
768
+ }
769
+ const child = spawn(command, args, {
770
+ stdio: ['pipe', 'inherit', 'inherit'],
771
+ ...(plain ? { env } : {}),
511
772
  });
512
- child.stdin.write(cleanedMarkdown, 'utf-8');
513
- child.stdin.end();
514
- child.on('close', resolve);
515
- child.on('error', resolve);
773
+ let settled = false;
774
+ const finish = (started) => {
775
+ if (settled)
776
+ return;
777
+ settled = true;
778
+ resolve(started);
779
+ };
780
+ child.once('close', () => {
781
+ finish(true);
782
+ });
783
+ child.once('error', () => {
784
+ finish(false);
785
+ });
786
+ child.stdin.once('error', () => {
787
+ finish(true);
788
+ });
789
+ try {
790
+ child.stdin.end(content, 'utf8');
791
+ }
792
+ catch {
793
+ finish(false);
794
+ }
516
795
  });
517
796
  }
797
+ export async function displayWithGlow(cleanedMarkdown) {
798
+ if (chalk.level === 0)
799
+ return false;
800
+ const cols = String(Math.min(process.stdout.columns || 80, 80));
801
+ return pipeToPager('glow', ['--pager', '--width', cols, '-'], cleanedMarkdown);
802
+ }
518
803
  async function displayWithLess(rendered, title, filePath, readTime, toc) {
519
804
  const trans = t();
520
805
  const cols = Math.min(process.stdout.columns || 80, 80);
@@ -523,7 +808,7 @@ async function displayWithLess(rendered, title, filePath, readTime, toc) {
523
808
  ? [
524
809
  chalk.dim(` ${trans.docs.tocTitle}`),
525
810
  chalk.dim(` ${'─'.repeat(36)}`),
526
- ...toc.map(h => chalk.dim(` ${h}`)),
811
+ ...toc.map((h) => chalk.dim(` ${h}`)),
527
812
  chalk.dim(` ${'─'.repeat(36)}`),
528
813
  '',
529
814
  ].join('\n')
@@ -536,49 +821,44 @@ async function displayWithLess(rendered, title, filePath, readTime, toc) {
536
821
  ...(tocBlock ? [tocBlock] : []),
537
822
  '',
538
823
  ].join('\n');
539
- const footer = [
540
- '',
541
- rule,
542
- chalk.dim(` ${trans.docs.endOfDocument}`),
543
- '',
544
- ].join('\n');
545
- const fullContent = header + rendered + footer;
546
- const pagerSetting = (process.env['PAGER'] || 'less').trim();
824
+ const footer = ['', rule, chalk.dim(` ${trans.docs.endOfDocument}`), ''].join('\n');
825
+ const plain = chalk.level === 0;
826
+ const fullContent = plain
827
+ ? sanitizeTerminalText(header + rendered + footer)
828
+ : header + rendered + footer;
829
+ const pagerSetting = (process.env['PAGER'] ?? 'less').trim();
547
830
  const [pagerCommand = 'less', ...pagerArgs] = pagerSetting.split(/\s+/).filter(Boolean);
548
- const args = [...pagerArgs, '-R', '-F', '-X', '-i', '-j4'];
831
+ const isLess = /(?:^|[\\/])less(?:\.exe)?$/i.test(pagerCommand);
832
+ const args = isLess
833
+ ? [...pagerArgs, ...(plain ? [] : ['-R']), '-F', '-X', '-i', '-j4']
834
+ : pagerArgs;
549
835
  if (!commandExists(pagerCommand)) {
550
836
  console.log(fullContent);
551
837
  return;
552
838
  }
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
- });
839
+ if (!(await pipeToPager(pagerCommand, args, fullContent, plain)))
840
+ console.log(fullContent);
566
841
  }
567
- // ─── Section browsers ─────────────────────────────────────────────────────────
568
- /** Show a flat file list for tutorial / process / repair. */
569
842
  async function showDocSection(section) {
570
843
  const trans = t();
571
844
  if (section.key === 'archived') {
572
845
  await showArchivedSection(section.files);
573
846
  return;
574
847
  }
575
- const files = section.files.filter(f => f.name !== 'index.md' && !f.name.startsWith('index.'));
848
+ const spinner = createSpinner(trans.docs.loading);
849
+ const hydrated = await fetchSectionMetadata(section);
850
+ spinner.stop();
851
+ const files = hydrated.files.filter((file) => file.name !== 'index.md' && !file.name.startsWith('index.'));
576
852
  if (files.length === 0)
577
853
  return;
578
854
  const selected = await runMenu({
579
- title: section.label,
855
+ title: hydrated.label,
580
856
  options: [
581
- ...files.map(f => ({ value: f.path, label: cleanFileName(f.name) })),
857
+ ...files.map((file) => ({
858
+ value: file.path,
859
+ label: displayDocTitle(file.name, file.title),
860
+ ...(!file.summary ? {} : { hint: file.summary }),
861
+ })),
582
862
  { value: '__back__', label: chalk.dim(trans.common.back) },
583
863
  ],
584
864
  footer: menuFooter(),
@@ -587,7 +867,6 @@ async function showDocSection(section) {
587
867
  return;
588
868
  await viewMarkdownFile(selected);
589
869
  }
590
- /** Show archived docs grouped by year, then files within the year. */
591
870
  async function showArchivedSection(files) {
592
871
  const trans = t();
593
872
  const groups = getArchivedGroups(files);
@@ -605,10 +884,10 @@ async function showArchivedSection(files) {
605
884
  const groupKey = await runMenu({
606
885
  title: trans.docs.categoryArchived,
607
886
  options: [
608
- ...sortedKeys.map(k => ({
887
+ ...sortedKeys.map((k) => ({
609
888
  value: k,
610
889
  label: k,
611
- hint: String(groups.get(k).length),
890
+ hint: String(groups.get(k)?.length ?? 0),
612
891
  })),
613
892
  { value: '__back__', label: chalk.dim(trans.common.back) },
614
893
  ],
@@ -616,17 +895,19 @@ async function showArchivedSection(files) {
616
895
  });
617
896
  if (groupKey === null || groupKey === '__back__')
618
897
  return;
619
- const groupFiles = groups.get(groupKey) ?? [];
620
- const subDirs = new Set(groupFiles.map(f => f.path.split('/')[2]).filter(Boolean));
898
+ const spinner = createSpinner(trans.docs.loading);
899
+ const groupFiles = await fetchDocMetadata(groups.get(groupKey) ?? []);
900
+ spinner.stop();
901
+ const subDirs = new Set(groupFiles.map((f) => f.path.split('/')[2]).filter(Boolean));
621
902
  const fileSelected = await runMenu({
622
903
  title: `${trans.docs.categoryArchived} · ${groupKey}`,
623
904
  options: [
624
- ...groupFiles.map(f => {
905
+ ...groupFiles.map((f) => {
625
906
  const sub = f.path.split('/').slice(2, -1).join('/');
626
907
  return {
627
908
  value: f.path,
628
- label: cleanFileName(f.name),
629
- hint: subDirs.size > 1 ? sub : undefined,
909
+ label: displayDocTitle(f.name, f.title),
910
+ ...(subDirs.size > 1 ? { hint: sanitizeTerminalLine(sub) } : {}),
630
911
  };
631
912
  }),
632
913
  { value: '__back__', label: chalk.dim(trans.common.back) },
@@ -637,31 +918,18 @@ async function showArchivedSection(files) {
637
918
  return;
638
919
  await viewMarkdownFile(fileSelected);
639
920
  }
640
- // ─── Document viewer ──────────────────────────────────────────────────────────
641
- export async function viewMarkdownFile(filePath) {
921
+ async function viewMarkdownFile(filePath) {
642
922
  const trans = t();
643
923
  ensureMarkedConfigured();
644
924
  const s = createSpinner(`${trans.docs.loadingFile}: ${filePath}`);
645
925
  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
- }
926
+ const { rawContent, renderedDoc } = await loadRenderedDoc(filePath);
661
927
  s.stop(`${chalk.bold(renderedDoc.title)} ${chalk.dim(renderedDoc.readTime)}`);
662
928
  const toc = extractTOC(renderedDoc.cleaned);
663
- if (hasGlow()) {
664
- await displayWithGlow(renderedDoc.cleaned);
929
+ if (chalk.level > 0 && hasGlow()) {
930
+ if (!(await displayWithGlow(renderedDoc.cleaned))) {
931
+ await displayWithLess(renderedDoc.rendered, renderedDoc.title, filePath, renderedDoc.readTime, toc);
932
+ }
665
933
  }
666
934
  else {
667
935
  await displayWithLess(renderedDoc.rendered, renderedDoc.title, filePath, renderedDoc.readTime, toc);
@@ -671,8 +939,11 @@ export async function viewMarkdownFile(filePath) {
671
939
  title: trans.docs.chooseAction,
672
940
  options: [
673
941
  { value: 'back', label: trans.docs.backToList },
674
- { value: 'browser', label: trans.docs.openBrowser,
675
- hint: needsBrowser ? trans.docs.tableHint : undefined },
942
+ {
943
+ value: 'browser',
944
+ label: trans.docs.openBrowser,
945
+ ...(needsBrowser ? { hint: trans.docs.tableHint } : {}),
946
+ },
676
947
  ],
677
948
  footer: menuFooter(),
678
949
  });
@@ -682,7 +953,7 @@ export async function viewMarkdownFile(filePath) {
682
953
  }
683
954
  catch (err) {
684
955
  s.error(trans.docs.loadError);
685
- const errMsg = err instanceof Error ? err.message : String(err);
956
+ const errMsg = sanitizeTerminalLine(err instanceof Error ? err.message : String(err));
686
957
  console.log(chalk.gray(` ${trans.docs.errorHint}: ${errMsg}`));
687
958
  const openBrowser = await runConfirm({ message: trans.docs.openBrowserPrompt });
688
959
  if (openBrowser === true) {
@@ -690,37 +961,80 @@ export async function viewMarkdownFile(filePath) {
690
961
  }
691
962
  }
692
963
  }
693
- // ─── Browser fallback ─────────────────────────────────────────────────────────
694
- export async function openDocsInBrowser(path) {
964
+ export async function openDocsInBrowser(path, signal) {
695
965
  const trans = t();
696
966
  const s = createSpinner(trans.docs.opening);
967
+ let url = docsUrlFromPath(path);
697
968
  try {
698
- const url = path
699
- ? `${URLS.docs}/${path.replace(/\.md$/, '')}`
700
- : URLS.docs;
701
- await open(url);
969
+ if (path) {
970
+ try {
971
+ url = docsUrlFromRoute((await loadDocMetadata(path, signal)).route);
972
+ }
973
+ catch {
974
+ if (signal?.aborted) {
975
+ s.stop();
976
+ return false;
977
+ }
978
+ url = docsUrlFromPath(path);
979
+ }
980
+ }
981
+ if (signal?.aborted) {
982
+ s.stop();
983
+ return false;
984
+ }
985
+ if (!(await launchBrowserUrl(url)))
986
+ throw new Error('Browser launcher failed');
987
+ if (signal?.aborted) {
988
+ s.stop();
989
+ return false;
990
+ }
702
991
  s.stop(trans.docs.browserOpened);
992
+ console.log();
993
+ return true;
703
994
  }
704
995
  catch {
996
+ if (signal?.aborted) {
997
+ s.stop();
998
+ return false;
999
+ }
705
1000
  s.error(trans.docs.browserError);
706
- console.log(chalk.gray(` ${trans.docs.browserErrorHint}`));
1001
+ console.log(chalk.gray(` ${fmt(t().links.openManually, { url })}`));
1002
+ console.log();
1003
+ return false;
707
1004
  }
708
- console.log();
709
1005
  }
710
- // ─── Search ────────────────────────────────────────────────────────────────────
1006
+ function docsUrlFromRoute(route) {
1007
+ const safeRoute = sanitizeTerminalLine(route);
1008
+ if (!safeRoute)
1009
+ return URLS.docs;
1010
+ const encodedRoute = safeRoute
1011
+ .split('/')
1012
+ .map((segment) => encodeURIComponent(segment))
1013
+ .join('/');
1014
+ return `${URLS.docs}${encodedRoute}`;
1015
+ }
1016
+ export function docsUrlFromPath(path) {
1017
+ return path ? docsUrlFromRoute(docsRouteFromPath(sanitizeTerminalLine(path))) : URLS.docs;
1018
+ }
1019
+ export function docsRouteFromPath(path) {
1020
+ const withoutExtension = path.replace(/\.md$/i, '');
1021
+ if (withoutExtension === 'index')
1022
+ return '/';
1023
+ if (withoutExtension.endsWith('/index'))
1024
+ return `/${withoutExtension.slice(0, -5)}`;
1025
+ return `/${withoutExtension}`;
1026
+ }
711
1027
  async function searchDocs() {
712
1028
  const trans = t();
713
1029
  const query = await runTextInput({
714
1030
  message: trans.docs.searchPrompt,
715
1031
  placeholder: trans.docs.searchPlaceholder,
716
1032
  });
717
- if (query === null || !query.trim())
1033
+ if (!query?.trim())
718
1034
  return;
719
- const keyword = query.trim().toLowerCase();
720
1035
  const s = createSpinner(trans.docs.searching);
721
1036
  try {
722
- const all = await docsClient.listAll();
723
- const results = all.filter(item => item.path.toLowerCase().includes(keyword));
1037
+ const results = await searchDocuments(query.trim());
724
1038
  s.stop(`${results.length} ${trans.docs.searchResults}`);
725
1039
  if (results.length === 0) {
726
1040
  warning(trans.docs.searchNoResults);
@@ -729,10 +1043,14 @@ async function searchDocs() {
729
1043
  const selected = await runMenu({
730
1044
  title: trans.docs.chooseDoc,
731
1045
  options: [
732
- ...results.map(r => ({
1046
+ ...results.map((r) => ({
733
1047
  value: r.path,
734
- label: cleanFileName(r.name),
735
- hint: r.path.includes('/') ? r.path.split('/').slice(0, -1).join('/') : '',
1048
+ label: r.title,
1049
+ hint: truncate(r.excerpt ||
1050
+ r.summary ||
1051
+ (r.path.includes('/')
1052
+ ? sanitizeTerminalLine(r.path.split('/').slice(0, -1).join('/'))
1053
+ : ''), 44),
736
1054
  })),
737
1055
  { value: '__back__', label: chalk.dim(trans.docs.returnToMenu) },
738
1056
  ],
@@ -746,18 +1064,17 @@ async function searchDocs() {
746
1064
  s.error(trans.docs.loadError);
747
1065
  }
748
1066
  }
749
- // ─── Menu ─────────────────────────────────────────────────────────────────────
750
1067
  export async function showDocsMenu() {
751
1068
  await enterScreen(breadcrumb(t().menu.docs));
752
- let sections = await loadSections();
1069
+ const sections = await loadSections();
753
1070
  if (!sections)
754
1071
  return;
755
- while (true) {
1072
+ for (;;) {
756
1073
  const trans = t();
757
1074
  const action = await runMenu({
758
1075
  title: trans.docs.chooseCategory,
759
1076
  options: [
760
- ...sections.map(sec => ({ value: sec.key, label: sec.label })),
1077
+ ...sections.map((sec) => ({ value: sec.key, label: sec.label })),
761
1078
  { value: 'search', label: chalk.dim(trans.docs.searchPrompt.replace(':', '')) },
762
1079
  { value: 'browser', label: chalk.dim(trans.docs.openBrowser) },
763
1080
  ],
@@ -772,7 +1089,7 @@ export async function showDocsMenu() {
772
1089
  await openDocsInBrowser();
773
1090
  }
774
1091
  else {
775
- const section = sections.find(s => s.key === action);
1092
+ const section = sections.find((s) => s.key === action);
776
1093
  if (section)
777
1094
  await showDocSection(section);
778
1095
  }