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