@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.
- package/README.md +27 -58
- package/SECURITY.md +16 -45
- package/dist/app/app.js +167 -64
- package/dist/app/chrome.js +67 -50
- package/dist/app/fields/list-field.js +12 -25
- package/dist/app/fields/text-field.js +3 -8
- package/dist/app/frame.js +16 -23
- package/dist/app/keys.js +110 -2
- package/dist/app/views/docs-render.js +34 -26
- package/dist/app/views/docs.js +280 -68
- package/dist/app/views/events-render.js +21 -27
- package/dist/app/views/events.js +57 -33
- package/dist/app/views/home.js +67 -47
- package/dist/app/views/schedule-grid-cursor.js +9 -18
- package/dist/app/views/schedule-render.js +51 -74
- package/dist/app/views/schedule.js +246 -101
- package/dist/app/views/settings-render.js +8 -19
- package/dist/app/views/settings.js +92 -17
- package/dist/auth/cookie-transport.js +31 -32
- package/dist/auth/errors.js +3 -1
- package/dist/auth/nbt-auth.js +42 -25
- package/dist/auth/session-store.js +17 -9
- package/dist/cli.js +570 -0
- package/dist/config/data.js +9 -11
- package/dist/config/preferences.js +21 -7
- package/dist/core/calendar-day.js +37 -0
- package/dist/core/canvas.js +1 -0
- package/dist/core/capabilities.js +6 -3
- package/dist/core/components/confirm.js +9 -8
- package/dist/core/components/menu.js +64 -38
- package/dist/core/components/messages.js +12 -4
- package/dist/core/components/painter.js +3 -1
- package/dist/core/components/spinner.js +34 -7
- package/dist/core/components/text-input.js +24 -18
- package/dist/core/icons.js +2 -2
- package/dist/core/logo.js +23 -5
- package/dist/core/motion.js +25 -19
- package/dist/core/text.js +186 -69
- package/dist/core/theme.js +0 -28
- package/dist/core/transitions.js +2 -2
- package/dist/core/ui.js +15 -13
- package/dist/core/vim-keys.js +156 -19
- package/dist/features/about.js +23 -0
- package/dist/features/calendar-heatmap.js +16 -40
- package/dist/features/calendar-query.js +1 -2
- package/dist/features/calendar-store.js +27 -0
- package/dist/features/calendar.js +66 -190
- package/dist/features/docs-client.js +225 -0
- package/dist/features/docs.js +615 -298
- package/dist/features/links.js +44 -29
- package/dist/features/schedule-render.js +65 -101
- package/dist/features/schedule-store.js +51 -9
- package/dist/features/schedule-view.js +46 -213
- package/dist/features/status.js +117 -60
- package/dist/features/student-timetable.js +74 -97
- package/dist/features/theme.js +9 -5
- package/dist/features/timetable-sanitize.js +40 -0
- package/dist/features/update.js +12 -29
- package/dist/i18n/index.js +83 -19
- package/dist/i18n/locales/en.json +8 -3
- package/dist/i18n/locales/zh.json +8 -3
- package/dist/index.js +6 -474
- package/dist/logo/ca-dotmatrix.txt +16 -18
- package/dist/main.js +7 -48
- package/package.json +28 -18
- package/bin/nbtca-welcome.js +0 -2
- package/dist/core/components/screen.js +0 -18
- package/dist/core/menu.js +0 -68
- package/dist/features/schedule-query.js +0 -47
- package/dist/features/settings.js +0 -127
- package/dist/logo/ca-logo.png +0 -0
package/dist/features/docs.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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']
|
|
17
|
-
const termProgram = (process.env['TERM_PROGRAM']
|
|
18
|
-
const hasImages = termProgram.includes('iterm') ||
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
term.includes('
|
|
22
|
-
const
|
|
23
|
-
|
|
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
|
-
|
|
44
|
-
_terminalType = detectTerminalType();
|
|
49
|
+
_terminalType ??= detectTerminalType();
|
|
45
50
|
return _terminalType;
|
|
46
51
|
}
|
|
47
52
|
let _hasGlow = null;
|
|
48
53
|
function hasGlow() {
|
|
49
|
-
|
|
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
|
|
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: '─',
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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: '-',
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
177
|
+
cacheGeneration += 1;
|
|
178
|
+
clearDocsClients();
|
|
163
179
|
renderCache.clear();
|
|
180
|
+
metadataCache.clear();
|
|
164
181
|
}
|
|
165
|
-
async function
|
|
182
|
+
async function fetchDocument(path, signal) {
|
|
166
183
|
try {
|
|
167
|
-
return await
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
.
|
|
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]',
|
|
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: 'ℹ️',
|
|
333
|
+
info: 'ℹ️',
|
|
334
|
+
tip: '💡',
|
|
335
|
+
warning: '⚠️',
|
|
336
|
+
danger: '🚨',
|
|
337
|
+
details: '▶️',
|
|
238
338
|
};
|
|
239
|
-
|
|
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 `${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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
273
|
-
|
|
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, (
|
|
278
|
-
const
|
|
279
|
-
|
|
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
|
-
|
|
285
|
-
c = c
|
|
286
|
-
c = c.replace(/<
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
347
|
-
|
|
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 =
|
|
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 === '' ||
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
count:
|
|
473
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
|
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
|
-
|
|
554
|
-
|
|
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
|
|
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:
|
|
855
|
+
title: hydrated.label,
|
|
580
856
|
options: [
|
|
581
|
-
...files.map(
|
|
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)
|
|
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
|
|
620
|
-
const
|
|
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:
|
|
629
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
{
|
|
675
|
-
|
|
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
|
-
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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(` ${
|
|
1001
|
+
console.log(chalk.gray(` ${fmt(t().links.openManually, { url })}`));
|
|
1002
|
+
console.log();
|
|
1003
|
+
return false;
|
|
707
1004
|
}
|
|
708
|
-
console.log();
|
|
709
1005
|
}
|
|
710
|
-
|
|
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 (
|
|
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
|
|
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:
|
|
735
|
-
hint:
|
|
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
|
-
|
|
1069
|
+
const sections = await loadSections();
|
|
753
1070
|
if (!sections)
|
|
754
1071
|
return;
|
|
755
|
-
|
|
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
|
}
|