@nbtca/prompt 1.4.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- 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 -67
- package/dist/app/views/events-render.js +19 -26
- package/dist/app/views/events.js +44 -31
- package/dist/app/views/home.js +33 -76
- 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 -90
- package/dist/app/views/settings-render.js +8 -19
- package/dist/app/views/settings.js +93 -18
- 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 +10 -13
- 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 +25 -21
- package/dist/core/motion.js +25 -19
- package/dist/core/text.js +182 -75
- package/dist/core/theme.js +0 -28
- package/dist/core/transitions.js +2 -2
- package/dist/core/ui.js +15 -30
- 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 +439 -320
- package/dist/features/schedule-render.js +65 -102
- package/dist/features/schedule-store.js +51 -9
- package/dist/features/schedule-view.js +46 -220
- package/dist/features/status.js +44 -59
- package/dist/features/student-timetable.js +73 -95
- package/dist/features/theme.js +6 -5
- package/dist/features/timetable-sanitize.js +40 -0
- package/dist/features/update.js +9 -37
- package/dist/i18n/index.js +87 -65
- package/dist/i18n/locales/en.json +1 -1
- package/dist/i18n/locales/zh.json +1 -1
- package/dist/index.js +85 -64
- package/dist/logo/ca-dotmatrix.txt +16 -18
- package/dist/main.js +7 -48
- package/package.json +30 -18
- package/bin/nbtca-welcome.js +0 -2
- package/dist/core/components/screen.js +0 -18
- package/dist/core/menu.js +0 -71
- package/dist/features/links.js +0 -39
- package/dist/features/schedule-query.js +0 -47
- package/dist/features/settings.js +0 -130
- 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,26 +504,17 @@ export async function loadDocForReader(filePath) {
|
|
|
396
504
|
if (seen.has(resolved))
|
|
397
505
|
continue;
|
|
398
506
|
seen.add(resolved);
|
|
399
|
-
links.push({ text: raw.text, href: resolved });
|
|
507
|
+
links.push({ text: sanitizeTerminalLine(raw.text), href: resolved });
|
|
400
508
|
}
|
|
401
|
-
return {
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
// before this fix caught up to the upstream restructuring. `about` leads
|
|
409
|
-
// (org intro for newcomers) and `concepts` sits after the practical guide
|
|
410
|
-
// as reference material.
|
|
509
|
+
return {
|
|
510
|
+
path: filePath,
|
|
511
|
+
title: renderedDoc.title,
|
|
512
|
+
lines: renderedDoc.rendered.split('\n'),
|
|
513
|
+
links,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
411
516
|
const TOP_SECTION_ORDER = ['about', 'guide', 'repair', 'concepts', 'archived'];
|
|
412
517
|
const TOP_SECTION_SKIP = new Set(['docs', 'index.md', 'README.md']);
|
|
413
|
-
// tutorial/ and process/ are two folders on disk but one section everywhere
|
|
414
|
-
// a reader actually sees them: nbtca/documents' own site nav collapses both
|
|
415
|
-
// under a single "指南/Guide" entry, and tutorial/sidebar.ts spells out why
|
|
416
|
-
// ("「指南」= 教程(学技术)+流程(办社务)高内聚合并为一栏") -- presenting
|
|
417
|
-
// them as two separate top-level categories in the terminal was true to the
|
|
418
|
-
// folder layout but false to how the content is actually meant to be read.
|
|
419
518
|
const SECTION_ALIAS = { tutorial: 'guide', process: 'guide' };
|
|
420
519
|
export function localizeDocSections(sections, trans = t()) {
|
|
421
520
|
const labels = {
|
|
@@ -427,66 +526,68 @@ export function localizeDocSections(sections, trans = t()) {
|
|
|
427
526
|
};
|
|
428
527
|
return sections.map((section) => ({ ...section, label: labels[section.key] ?? section.label }));
|
|
429
528
|
}
|
|
430
|
-
/**
|
|
431
|
-
* Convert a kebab-case filename to a display-friendly title.
|
|
432
|
-
* Preserves Chinese characters and date prefixes.
|
|
433
|
-
*/
|
|
434
529
|
export function cleanFileName(name) {
|
|
435
|
-
const base = name.replace(/\.md$/, '');
|
|
530
|
+
const base = sanitizeTerminalLine(name).replace(/\.md$/, '');
|
|
436
531
|
if (/^[\d.]/.test(base))
|
|
437
532
|
return base;
|
|
438
|
-
return base
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
533
|
+
return base.replace(/[-_]/g, ' ').replace(/\b([a-z])/g, (_, c) => c.toUpperCase());
|
|
534
|
+
}
|
|
535
|
+
export function displayDocTitle(name, title) {
|
|
536
|
+
const candidate = title?.trim();
|
|
537
|
+
return sanitizeTerminalLine(candidate?.length ? candidate : cleanFileName(name));
|
|
538
|
+
}
|
|
539
|
+
function listedDoc(item, metadata) {
|
|
540
|
+
return {
|
|
541
|
+
...item,
|
|
542
|
+
name: sanitizeTerminalLine(item.name),
|
|
543
|
+
title: displayDocTitle(item.name, metadata?.title),
|
|
544
|
+
summary: sanitizeTerminalLine(metadata?.summary ?? ''),
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
export async function fetchDocMetadata(items) {
|
|
548
|
+
const results = items.map((item) => listedDoc(item));
|
|
549
|
+
let nextIndex = 0;
|
|
550
|
+
async function worker() {
|
|
551
|
+
for (;;) {
|
|
552
|
+
const index = nextIndex;
|
|
553
|
+
nextIndex += 1;
|
|
554
|
+
if (index >= items.length)
|
|
555
|
+
return;
|
|
556
|
+
const item = items[index];
|
|
557
|
+
if (!item)
|
|
558
|
+
continue;
|
|
559
|
+
try {
|
|
560
|
+
results[index] = listedDoc(item, await loadDocMetadata(item.path));
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
results[index] = listedDoc(item);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
const workerCount = Math.min(METADATA_CONCURRENCY, items.length);
|
|
568
|
+
await Promise.all(Array.from({ length: workerCount }, worker));
|
|
569
|
+
return results;
|
|
570
|
+
}
|
|
571
|
+
export async function fetchSectionMetadata(section) {
|
|
572
|
+
const files = await fetchDocMetadata(section.files);
|
|
573
|
+
return { ...section, count: files.length, files };
|
|
574
|
+
}
|
|
575
|
+
function searchDoc(result) {
|
|
576
|
+
return {
|
|
577
|
+
name: sanitizeTerminalLine(result.name),
|
|
578
|
+
path: result.path,
|
|
579
|
+
type: 'file',
|
|
580
|
+
title: displayDocTitle(result.name, result.title),
|
|
581
|
+
summary: sanitizeTerminalLine(result.summary),
|
|
582
|
+
excerpt: sanitizeTerminalLine(result.excerpt),
|
|
583
|
+
route: result.route,
|
|
584
|
+
score: result.score,
|
|
585
|
+
section: result.section,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
export async function searchDocuments(query) {
|
|
589
|
+
return (await docsClient.search(query, { limit: 20 })).map(searchDoc);
|
|
590
|
+
}
|
|
490
591
|
export function buildSections(all) {
|
|
491
592
|
const groups = new Map();
|
|
492
593
|
for (const item of all) {
|
|
@@ -494,37 +595,38 @@ export function buildSections(all) {
|
|
|
494
595
|
if (parts.length < 2)
|
|
495
596
|
continue;
|
|
496
597
|
const rawTop = parts[0];
|
|
598
|
+
if (!rawTop)
|
|
599
|
+
continue;
|
|
497
600
|
if (TOP_SECTION_SKIP.has(rawTop))
|
|
498
601
|
continue;
|
|
499
602
|
const top = SECTION_ALIAS[rawTop] ?? rawTop;
|
|
500
603
|
if (!TOP_SECTION_ORDER.includes(top))
|
|
501
604
|
continue;
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
605
|
+
const items = groups.get(top);
|
|
606
|
+
const file = listedDoc(item);
|
|
607
|
+
if (items)
|
|
608
|
+
items.push(file);
|
|
609
|
+
else
|
|
610
|
+
groups.set(top, [file]);
|
|
505
611
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
.
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
files: groups.get(k),
|
|
513
|
-
})));
|
|
514
|
-
}
|
|
515
|
-
/** Group archived files by their second path component (year / manual / etc.). */
|
|
612
|
+
const orderedGroups = TOP_SECTION_ORDER.flatMap((key) => {
|
|
613
|
+
const files = groups.get(key);
|
|
614
|
+
return files ? [{ key, label: key, count: files.length, files }] : [];
|
|
615
|
+
});
|
|
616
|
+
return localizeDocSections(orderedGroups);
|
|
617
|
+
}
|
|
516
618
|
export function getArchivedGroups(files) {
|
|
517
619
|
const groups = new Map();
|
|
518
620
|
for (const item of files) {
|
|
519
621
|
const group = item.path.split('/')[1] ?? 'other';
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
622
|
+
const items = groups.get(group);
|
|
623
|
+
if (items)
|
|
624
|
+
items.push(item);
|
|
625
|
+
else
|
|
626
|
+
groups.set(group, [item]);
|
|
523
627
|
}
|
|
524
628
|
return groups;
|
|
525
629
|
}
|
|
526
|
-
/** Raw fetch, no spinner/UI — throws on failure. Shared by the classic and
|
|
527
|
-
* native-view loaders. */
|
|
528
630
|
export async function fetchAllDocs() {
|
|
529
631
|
return docsClient.listAll();
|
|
530
632
|
}
|
|
@@ -544,19 +646,37 @@ async function loadSections() {
|
|
|
544
646
|
return null;
|
|
545
647
|
}
|
|
546
648
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
const
|
|
552
|
-
|
|
649
|
+
function pipeToPager(command, args, content) {
|
|
650
|
+
return new Promise((resolve) => {
|
|
651
|
+
const child = spawn(command, args, { stdio: ['pipe', 'inherit', 'inherit'] });
|
|
652
|
+
let settled = false;
|
|
653
|
+
const finish = (started) => {
|
|
654
|
+
if (settled)
|
|
655
|
+
return;
|
|
656
|
+
settled = true;
|
|
657
|
+
resolve(started);
|
|
658
|
+
};
|
|
659
|
+
child.once('close', () => {
|
|
660
|
+
finish(true);
|
|
661
|
+
});
|
|
662
|
+
child.once('error', () => {
|
|
663
|
+
finish(false);
|
|
664
|
+
});
|
|
665
|
+
child.stdin.once('error', () => {
|
|
666
|
+
finish(true);
|
|
553
667
|
});
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
668
|
+
try {
|
|
669
|
+
child.stdin.end(content, 'utf8');
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
finish(false);
|
|
673
|
+
}
|
|
558
674
|
});
|
|
559
675
|
}
|
|
676
|
+
async function displayWithGlow(cleanedMarkdown) {
|
|
677
|
+
const cols = String(Math.min(process.stdout.columns || 80, 80));
|
|
678
|
+
return pipeToPager('glow', ['--pager', '--width', cols, '-'], cleanedMarkdown);
|
|
679
|
+
}
|
|
560
680
|
async function displayWithLess(rendered, title, filePath, readTime, toc) {
|
|
561
681
|
const trans = t();
|
|
562
682
|
const cols = Math.min(process.stdout.columns || 80, 80);
|
|
@@ -565,7 +685,7 @@ async function displayWithLess(rendered, title, filePath, readTime, toc) {
|
|
|
565
685
|
? [
|
|
566
686
|
chalk.dim(` ${trans.docs.tocTitle}`),
|
|
567
687
|
chalk.dim(` ${'─'.repeat(36)}`),
|
|
568
|
-
...toc.map(h => chalk.dim(` ${h}`)),
|
|
688
|
+
...toc.map((h) => chalk.dim(` ${h}`)),
|
|
569
689
|
chalk.dim(` ${'─'.repeat(36)}`),
|
|
570
690
|
'',
|
|
571
691
|
].join('\n')
|
|
@@ -578,49 +698,39 @@ async function displayWithLess(rendered, title, filePath, readTime, toc) {
|
|
|
578
698
|
...(tocBlock ? [tocBlock] : []),
|
|
579
699
|
'',
|
|
580
700
|
].join('\n');
|
|
581
|
-
const footer = [
|
|
582
|
-
'',
|
|
583
|
-
rule,
|
|
584
|
-
chalk.dim(` ${trans.docs.endOfDocument}`),
|
|
585
|
-
'',
|
|
586
|
-
].join('\n');
|
|
701
|
+
const footer = ['', rule, chalk.dim(` ${trans.docs.endOfDocument}`), ''].join('\n');
|
|
587
702
|
const fullContent = header + rendered + footer;
|
|
588
|
-
const pagerSetting = (process.env['PAGER']
|
|
703
|
+
const pagerSetting = (process.env['PAGER'] ?? 'less').trim();
|
|
589
704
|
const [pagerCommand = 'less', ...pagerArgs] = pagerSetting.split(/\s+/).filter(Boolean);
|
|
590
|
-
const
|
|
705
|
+
const isLess = /(?:^|[\\/])less(?:\.exe)?$/i.test(pagerCommand);
|
|
706
|
+
const args = isLess ? [...pagerArgs, '-R', '-F', '-X', '-i', '-j4'] : pagerArgs;
|
|
591
707
|
if (!commandExists(pagerCommand)) {
|
|
592
708
|
console.log(fullContent);
|
|
593
709
|
return;
|
|
594
710
|
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
const child = spawn(pagerCommand, args, { stdio: ['pipe', 'inherit', 'inherit'] });
|
|
598
|
-
child.stdin.write(fullContent, 'utf-8');
|
|
599
|
-
child.stdin.end();
|
|
600
|
-
child.on('close', resolve);
|
|
601
|
-
child.on('error', () => { console.log(fullContent); resolve(); });
|
|
602
|
-
}
|
|
603
|
-
catch {
|
|
604
|
-
console.log(fullContent);
|
|
605
|
-
resolve();
|
|
606
|
-
}
|
|
607
|
-
});
|
|
711
|
+
if (!(await pipeToPager(pagerCommand, args, fullContent)))
|
|
712
|
+
console.log(fullContent);
|
|
608
713
|
}
|
|
609
|
-
// ─── Section browsers ─────────────────────────────────────────────────────────
|
|
610
|
-
/** Show a flat file list for tutorial / process / repair. */
|
|
611
714
|
async function showDocSection(section) {
|
|
612
715
|
const trans = t();
|
|
613
716
|
if (section.key === 'archived') {
|
|
614
717
|
await showArchivedSection(section.files);
|
|
615
718
|
return;
|
|
616
719
|
}
|
|
617
|
-
const
|
|
720
|
+
const spinner = createSpinner(trans.docs.loading);
|
|
721
|
+
const hydrated = await fetchSectionMetadata(section);
|
|
722
|
+
spinner.stop();
|
|
723
|
+
const files = hydrated.files.filter((file) => file.name !== 'index.md' && !file.name.startsWith('index.'));
|
|
618
724
|
if (files.length === 0)
|
|
619
725
|
return;
|
|
620
726
|
const selected = await runMenu({
|
|
621
|
-
title:
|
|
727
|
+
title: hydrated.label,
|
|
622
728
|
options: [
|
|
623
|
-
...files.map(
|
|
729
|
+
...files.map((file) => ({
|
|
730
|
+
value: file.path,
|
|
731
|
+
label: displayDocTitle(file.name, file.title),
|
|
732
|
+
...(!file.summary ? {} : { hint: file.summary }),
|
|
733
|
+
})),
|
|
624
734
|
{ value: '__back__', label: chalk.dim(trans.common.back) },
|
|
625
735
|
],
|
|
626
736
|
footer: menuFooter(),
|
|
@@ -629,7 +739,6 @@ async function showDocSection(section) {
|
|
|
629
739
|
return;
|
|
630
740
|
await viewMarkdownFile(selected);
|
|
631
741
|
}
|
|
632
|
-
/** Show archived docs grouped by year, then files within the year. */
|
|
633
742
|
async function showArchivedSection(files) {
|
|
634
743
|
const trans = t();
|
|
635
744
|
const groups = getArchivedGroups(files);
|
|
@@ -647,10 +756,10 @@ async function showArchivedSection(files) {
|
|
|
647
756
|
const groupKey = await runMenu({
|
|
648
757
|
title: trans.docs.categoryArchived,
|
|
649
758
|
options: [
|
|
650
|
-
...sortedKeys.map(k => ({
|
|
759
|
+
...sortedKeys.map((k) => ({
|
|
651
760
|
value: k,
|
|
652
761
|
label: k,
|
|
653
|
-
hint: String(groups.get(k)
|
|
762
|
+
hint: String(groups.get(k)?.length ?? 0),
|
|
654
763
|
})),
|
|
655
764
|
{ value: '__back__', label: chalk.dim(trans.common.back) },
|
|
656
765
|
],
|
|
@@ -658,17 +767,19 @@ async function showArchivedSection(files) {
|
|
|
658
767
|
});
|
|
659
768
|
if (groupKey === null || groupKey === '__back__')
|
|
660
769
|
return;
|
|
661
|
-
const
|
|
662
|
-
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));
|
|
663
774
|
const fileSelected = await runMenu({
|
|
664
775
|
title: `${trans.docs.categoryArchived} · ${groupKey}`,
|
|
665
776
|
options: [
|
|
666
|
-
...groupFiles.map(f => {
|
|
777
|
+
...groupFiles.map((f) => {
|
|
667
778
|
const sub = f.path.split('/').slice(2, -1).join('/');
|
|
668
779
|
return {
|
|
669
780
|
value: f.path,
|
|
670
|
-
label:
|
|
671
|
-
|
|
781
|
+
label: displayDocTitle(f.name, f.title),
|
|
782
|
+
...(subDirs.size > 1 ? { hint: sanitizeTerminalLine(sub) } : {}),
|
|
672
783
|
};
|
|
673
784
|
}),
|
|
674
785
|
{ value: '__back__', label: chalk.dim(trans.common.back) },
|
|
@@ -679,31 +790,18 @@ async function showArchivedSection(files) {
|
|
|
679
790
|
return;
|
|
680
791
|
await viewMarkdownFile(fileSelected);
|
|
681
792
|
}
|
|
682
|
-
|
|
683
|
-
export async function viewMarkdownFile(filePath) {
|
|
793
|
+
async function viewMarkdownFile(filePath) {
|
|
684
794
|
const trans = t();
|
|
685
795
|
ensureMarkedConfigured();
|
|
686
796
|
const s = createSpinner(`${trans.docs.loadingFile}: ${filePath}`);
|
|
687
797
|
try {
|
|
688
|
-
const rawContent = await
|
|
689
|
-
const fingerprint = contentFingerprint(rawContent);
|
|
690
|
-
const cachedRendered = getFreshRender(filePath);
|
|
691
|
-
let renderedDoc;
|
|
692
|
-
if (cachedRendered && cachedRendered.fingerprint === fingerprint) {
|
|
693
|
-
renderedDoc = cachedRendered;
|
|
694
|
-
}
|
|
695
|
-
else {
|
|
696
|
-
const cleaned = cleanMarkdownContent(rawContent, getTerminalType());
|
|
697
|
-
const title = extractDocTitle(rawContent, cleaned) || cleanFileName(filePath.split('/').pop() ?? filePath);
|
|
698
|
-
const readTime = estimateReadTime(cleaned);
|
|
699
|
-
const rendered = await marked(cleaned);
|
|
700
|
-
renderedDoc = { fingerprint, cleaned, rendered, title, readTime };
|
|
701
|
-
setRender(filePath, renderedDoc);
|
|
702
|
-
}
|
|
798
|
+
const { rawContent, renderedDoc } = await loadRenderedDoc(filePath);
|
|
703
799
|
s.stop(`${chalk.bold(renderedDoc.title)} ${chalk.dim(renderedDoc.readTime)}`);
|
|
704
800
|
const toc = extractTOC(renderedDoc.cleaned);
|
|
705
801
|
if (hasGlow()) {
|
|
706
|
-
await displayWithGlow(renderedDoc.cleaned)
|
|
802
|
+
if (!(await displayWithGlow(renderedDoc.cleaned))) {
|
|
803
|
+
await displayWithLess(renderedDoc.rendered, renderedDoc.title, filePath, renderedDoc.readTime, toc);
|
|
804
|
+
}
|
|
707
805
|
}
|
|
708
806
|
else {
|
|
709
807
|
await displayWithLess(renderedDoc.rendered, renderedDoc.title, filePath, renderedDoc.readTime, toc);
|
|
@@ -713,8 +811,11 @@ export async function viewMarkdownFile(filePath) {
|
|
|
713
811
|
title: trans.docs.chooseAction,
|
|
714
812
|
options: [
|
|
715
813
|
{ value: 'back', label: trans.docs.backToList },
|
|
716
|
-
{
|
|
717
|
-
|
|
814
|
+
{
|
|
815
|
+
value: 'browser',
|
|
816
|
+
label: trans.docs.openBrowser,
|
|
817
|
+
...(needsBrowser ? { hint: trans.docs.tableHint } : {}),
|
|
818
|
+
},
|
|
718
819
|
],
|
|
719
820
|
footer: menuFooter(),
|
|
720
821
|
});
|
|
@@ -724,7 +825,7 @@ export async function viewMarkdownFile(filePath) {
|
|
|
724
825
|
}
|
|
725
826
|
catch (err) {
|
|
726
827
|
s.error(trans.docs.loadError);
|
|
727
|
-
const errMsg = err instanceof Error ? err.message : String(err);
|
|
828
|
+
const errMsg = sanitizeTerminalLine(err instanceof Error ? err.message : String(err));
|
|
728
829
|
console.log(chalk.gray(` ${trans.docs.errorHint}: ${errMsg}`));
|
|
729
830
|
const openBrowser = await runConfirm({ message: trans.docs.openBrowserPrompt });
|
|
730
831
|
if (openBrowser === true) {
|
|
@@ -732,14 +833,24 @@ export async function viewMarkdownFile(filePath) {
|
|
|
732
833
|
}
|
|
733
834
|
}
|
|
734
835
|
}
|
|
735
|
-
// ─── Browser fallback ─────────────────────────────────────────────────────────
|
|
736
836
|
export async function openDocsInBrowser(path) {
|
|
737
837
|
const trans = t();
|
|
738
838
|
const s = createSpinner(trans.docs.opening);
|
|
739
839
|
try {
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
840
|
+
let route = path ? docsRouteFromPath(path) : '';
|
|
841
|
+
if (path) {
|
|
842
|
+
try {
|
|
843
|
+
route = (await loadDocMetadata(path)).route;
|
|
844
|
+
}
|
|
845
|
+
catch {
|
|
846
|
+
route = docsRouteFromPath(path);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
const encodedRoute = route
|
|
850
|
+
.split('/')
|
|
851
|
+
.map((segment) => encodeURIComponent(segment))
|
|
852
|
+
.join('/');
|
|
853
|
+
const url = path ? `${URLS.docs}${encodedRoute}` : URLS.docs;
|
|
743
854
|
await open(url);
|
|
744
855
|
s.stop(trans.docs.browserOpened);
|
|
745
856
|
}
|
|
@@ -749,20 +860,25 @@ export async function openDocsInBrowser(path) {
|
|
|
749
860
|
}
|
|
750
861
|
console.log();
|
|
751
862
|
}
|
|
752
|
-
|
|
863
|
+
export function docsRouteFromPath(path) {
|
|
864
|
+
const withoutExtension = path.replace(/\.md$/i, '');
|
|
865
|
+
if (withoutExtension === 'index')
|
|
866
|
+
return '/';
|
|
867
|
+
if (withoutExtension.endsWith('/index'))
|
|
868
|
+
return `/${withoutExtension.slice(0, -5)}`;
|
|
869
|
+
return `/${withoutExtension}`;
|
|
870
|
+
}
|
|
753
871
|
async function searchDocs() {
|
|
754
872
|
const trans = t();
|
|
755
873
|
const query = await runTextInput({
|
|
756
874
|
message: trans.docs.searchPrompt,
|
|
757
875
|
placeholder: trans.docs.searchPlaceholder,
|
|
758
876
|
});
|
|
759
|
-
if (
|
|
877
|
+
if (!query?.trim())
|
|
760
878
|
return;
|
|
761
|
-
const keyword = query.trim().toLowerCase();
|
|
762
879
|
const s = createSpinner(trans.docs.searching);
|
|
763
880
|
try {
|
|
764
|
-
const
|
|
765
|
-
const results = all.filter(item => item.path.toLowerCase().includes(keyword));
|
|
881
|
+
const results = await searchDocuments(query.trim());
|
|
766
882
|
s.stop(`${results.length} ${trans.docs.searchResults}`);
|
|
767
883
|
if (results.length === 0) {
|
|
768
884
|
warning(trans.docs.searchNoResults);
|
|
@@ -771,10 +887,14 @@ async function searchDocs() {
|
|
|
771
887
|
const selected = await runMenu({
|
|
772
888
|
title: trans.docs.chooseDoc,
|
|
773
889
|
options: [
|
|
774
|
-
...results.map(r => ({
|
|
890
|
+
...results.map((r) => ({
|
|
775
891
|
value: r.path,
|
|
776
|
-
label:
|
|
777
|
-
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),
|
|
778
898
|
})),
|
|
779
899
|
{ value: '__back__', label: chalk.dim(trans.docs.returnToMenu) },
|
|
780
900
|
],
|
|
@@ -788,18 +908,17 @@ async function searchDocs() {
|
|
|
788
908
|
s.error(trans.docs.loadError);
|
|
789
909
|
}
|
|
790
910
|
}
|
|
791
|
-
// ─── Menu ─────────────────────────────────────────────────────────────────────
|
|
792
911
|
export async function showDocsMenu() {
|
|
793
912
|
await enterScreen(breadcrumb(t().menu.docs));
|
|
794
|
-
|
|
913
|
+
const sections = await loadSections();
|
|
795
914
|
if (!sections)
|
|
796
915
|
return;
|
|
797
|
-
|
|
916
|
+
for (;;) {
|
|
798
917
|
const trans = t();
|
|
799
918
|
const action = await runMenu({
|
|
800
919
|
title: trans.docs.chooseCategory,
|
|
801
920
|
options: [
|
|
802
|
-
...sections.map(sec => ({ value: sec.key, label: sec.label })),
|
|
921
|
+
...sections.map((sec) => ({ value: sec.key, label: sec.label })),
|
|
803
922
|
{ value: 'search', label: chalk.dim(trans.docs.searchPrompt.replace(':', '')) },
|
|
804
923
|
{ value: 'browser', label: chalk.dim(trans.docs.openBrowser) },
|
|
805
924
|
],
|
|
@@ -814,7 +933,7 @@ export async function showDocsMenu() {
|
|
|
814
933
|
await openDocsInBrowser();
|
|
815
934
|
}
|
|
816
935
|
else {
|
|
817
|
-
const section = sections.find(s => s.key === action);
|
|
936
|
+
const section = sections.find((s) => s.key === action);
|
|
818
937
|
if (section)
|
|
819
938
|
await showDocSection(section);
|
|
820
939
|
}
|