@nbtca/prompt 1.2.0 → 1.3.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/dist/features/docs.js +197 -130
- package/dist/i18n/locales/en.json +2 -0
- package/dist/i18n/locales/zh.json +2 -0
- package/package.json +1 -1
package/dist/features/docs.js
CHANGED
|
@@ -9,7 +9,7 @@ import { spawn, execFileSync } from 'child_process';
|
|
|
9
9
|
import { URLS } from '../config/data.js';
|
|
10
10
|
import { t, fmt } from '../i18n/index.js';
|
|
11
11
|
import { setVimKeysActive } from '../core/vim-keys.js';
|
|
12
|
-
import { createDocsClient
|
|
12
|
+
import { createDocsClient } from '@nbtca/docs';
|
|
13
13
|
function detectTerminalType() {
|
|
14
14
|
const term = (process.env['TERM'] || '').toLowerCase();
|
|
15
15
|
const termProgram = (process.env['TERM_PROGRAM'] || '').toLowerCase();
|
|
@@ -57,7 +57,6 @@ function ensureMarkedConfigured() {
|
|
|
57
57
|
}
|
|
58
58
|
// ─── marked-terminal renderer ─────────────────────────────────────────────────
|
|
59
59
|
function getRendererOptions(type) {
|
|
60
|
-
// Cap at 80 columns — optimal prose reading width regardless of terminal size
|
|
61
60
|
const width = Math.min(process.stdout.columns || 80, 80);
|
|
62
61
|
const unicodeTableChars = {
|
|
63
62
|
top: '─', 'top-mid': '┬', 'top-left': '┌', 'top-right': '┐',
|
|
@@ -75,23 +74,16 @@ function getRendererOptions(type) {
|
|
|
75
74
|
width,
|
|
76
75
|
emoji: true,
|
|
77
76
|
unescape: true,
|
|
78
|
-
// Heading hierarchy: h1 cyan, h2+ white bold
|
|
79
77
|
firstHeading: chalk.bold.cyan,
|
|
80
78
|
heading: chalk.bold.white,
|
|
81
|
-
// Inline code: bright yellow, distinct from prose
|
|
82
79
|
codespan: chalk.yellowBright,
|
|
83
|
-
// Block code: yellow (marked-terminal applies per-line)
|
|
84
80
|
code: chalk.yellow,
|
|
85
|
-
// Blockquotes: italic gray, visually recessed
|
|
86
81
|
blockquote: chalk.italic.gray,
|
|
87
|
-
// Prose emphasis
|
|
88
82
|
strong: chalk.bold,
|
|
89
83
|
em: chalk.italic,
|
|
90
84
|
del: chalk.dim.strikethrough,
|
|
91
|
-
// Links: cyan underline
|
|
92
85
|
link: chalk.cyan,
|
|
93
86
|
href: chalk.cyan.underline,
|
|
94
|
-
// Tables with Unicode borders (fallback to ASCII on basic terminals)
|
|
95
87
|
tableOptions: {
|
|
96
88
|
chars: type === 'basic' ? asciiTableChars : unicodeTableChars
|
|
97
89
|
}
|
|
@@ -101,17 +93,6 @@ const RENDER_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
|
101
93
|
const RENDER_CACHE_MAX = 50;
|
|
102
94
|
const renderCache = new Map();
|
|
103
95
|
let docsClient = createDocsClient();
|
|
104
|
-
function getDocCategories() {
|
|
105
|
-
const trans = t();
|
|
106
|
-
return [
|
|
107
|
-
{ name: trans.docs.categoryTutorial, path: 'tutorial' },
|
|
108
|
-
{ name: trans.docs.categoryRepairLogs, path: '维修日' },
|
|
109
|
-
{ name: trans.docs.categoryEvents, path: '相关活动举办' },
|
|
110
|
-
{ name: trans.docs.categoryProcess, path: 'process' },
|
|
111
|
-
{ name: trans.docs.categoryRepair, path: 'repair' },
|
|
112
|
-
{ name: trans.docs.categoryArchived, path: 'archived' },
|
|
113
|
-
];
|
|
114
|
-
}
|
|
115
96
|
function getFreshRender(key) {
|
|
116
97
|
const entry = renderCache.get(key);
|
|
117
98
|
return entry && entry.expiresAt > Date.now() ? entry.value : null;
|
|
@@ -131,18 +112,6 @@ export function clearDocsCache() {
|
|
|
131
112
|
docsClient.clear();
|
|
132
113
|
renderCache.clear();
|
|
133
114
|
}
|
|
134
|
-
async function fetchDirectory(path = '') {
|
|
135
|
-
try {
|
|
136
|
-
return await docsClient.listDir(path);
|
|
137
|
-
}
|
|
138
|
-
catch (err) {
|
|
139
|
-
const trans = t();
|
|
140
|
-
const msg = err instanceof DocsFetchError
|
|
141
|
-
? (err.status === 403 ? `${trans.docs.githubForbidden}\n${trans.docs.githubTokenHint}` : `HTTP ${err.status}`)
|
|
142
|
-
: String(err);
|
|
143
|
-
throw new Error(fmt(trans.docs.fetchDirFailed, { error: msg }));
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
115
|
async function fetchFileContent(path) {
|
|
147
116
|
try {
|
|
148
117
|
return await docsClient.getFile(path);
|
|
@@ -167,14 +136,12 @@ function cleanMarkdownContent(content, type = getTerminalType()) {
|
|
|
167
136
|
c = c.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
|
|
168
137
|
c = c.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
|
|
169
138
|
// 3. VitePress containers → blockquote with icon
|
|
170
|
-
// ::: warning Title\ncontent\n:::
|
|
171
139
|
c = c.replace(/^:::\s*(info|tip|warning|danger|details)\s*(.*?)\n([\s\S]*?)^:::\s*$/gm, (_m, type, title, body) => {
|
|
172
140
|
const label = (title.trim() || type.charAt(0).toUpperCase() + type.slice(1));
|
|
173
141
|
const icon = pickIcon(CONTAINER_ICONS_UNICODE[type] ?? '', CONTAINER_ICONS_ASCII[type] ?? '');
|
|
174
142
|
const quoted = body.trimEnd().split('\n').map(l => `> ${l}`).join('\n');
|
|
175
143
|
return `> ${icon} **${label}**\n>\n${quoted}\n`;
|
|
176
144
|
});
|
|
177
|
-
// Remaining bare ::: markers
|
|
178
145
|
c = c.replace(/^:::\s*\w*.*$/gm, '');
|
|
179
146
|
// 4. GitHub / GitLab callout alerts (> [!NOTE])
|
|
180
147
|
c = c.replace(/^>\s*\[!(NOTE|TIP|WARNING|CAUTION|IMPORTANT)\]\s*$/gim, (_, type) => `> **${type.charAt(0) + type.slice(1).toLowerCase()}:**`);
|
|
@@ -200,32 +167,111 @@ function cleanMarkdownContent(content, type = getTerminalType()) {
|
|
|
200
167
|
return c.trim();
|
|
201
168
|
}
|
|
202
169
|
function extractDocTitle(rawContent, cleanedContent) {
|
|
203
|
-
// 1. Try YAML frontmatter title: field (before it was stripped)
|
|
204
170
|
const fmMatch = rawContent.match(/^---\n[\s\S]*?\n---/m);
|
|
205
171
|
if (fmMatch) {
|
|
206
172
|
const titleMatch = fmMatch[0].match(/^title:\s*['"]?(.+?)['"]?\s*$/m);
|
|
207
173
|
if (titleMatch?.[1])
|
|
208
174
|
return titleMatch[1].trim();
|
|
209
175
|
}
|
|
210
|
-
// 2. Fallback to first # H1 heading in cleaned content
|
|
211
176
|
const h1Match = cleanedContent.match(/^#\s+(.+)$/m);
|
|
212
177
|
return h1Match?.[1]?.trim() ?? null;
|
|
213
178
|
}
|
|
214
179
|
/** Approximate reading time: ~200 words/min for technical Chinese/English prose. */
|
|
215
180
|
function estimateReadTime(text) {
|
|
216
|
-
const cjkChars = (text.match(/[
|
|
217
|
-
const nonCjk = text.replace(/[
|
|
181
|
+
const cjkChars = (text.match(/[㐀-鿿]/g) || []).length;
|
|
182
|
+
const nonCjk = text.replace(/[㐀-鿿]/g, ' ');
|
|
218
183
|
const words = nonCjk.trim().split(/\s+/).filter(Boolean).length;
|
|
219
|
-
// Rough equivalence: 2 CJK chars ~= 1 "word" unit
|
|
220
184
|
const units = words + cjkChars / 2;
|
|
221
185
|
const mins = Math.max(1, Math.ceil(units / 220));
|
|
222
186
|
return mins === 1 ? '~1 min' : `~${mins} min`;
|
|
223
187
|
}
|
|
224
|
-
|
|
188
|
+
/** Extract h2/h3 headings for TOC display (skips the h1 title). */
|
|
189
|
+
function extractTOC(content) {
|
|
190
|
+
const lines = content.split('\n').filter(l => /^#{2,3}\s/.test(l));
|
|
191
|
+
return lines.map(l => {
|
|
192
|
+
const m = l.match(/^(#+)/);
|
|
193
|
+
const level = m?.[1]?.length ?? 2;
|
|
194
|
+
const text = l.replace(/^#+\s+/, '').trim();
|
|
195
|
+
return (level === 3 ? ' ' : '') + text;
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
/** True if the markdown source contains a table (pipe-delimited with separator row). */
|
|
199
|
+
function hasMarkdownTable(content) {
|
|
200
|
+
return /^\|.+\|/m.test(content) && /^\|[-: |]+\|/m.test(content);
|
|
201
|
+
}
|
|
202
|
+
// ─── Document tree ────────────────────────────────────────────────────────────
|
|
203
|
+
const TOP_SECTION_ORDER = ['tutorial', 'process', 'repair', 'archived'];
|
|
204
|
+
const TOP_SECTION_SKIP = new Set(['docs', 'index.md', 'README.md']);
|
|
225
205
|
/**
|
|
226
|
-
*
|
|
227
|
-
*
|
|
206
|
+
* Convert a kebab-case filename to a display-friendly title.
|
|
207
|
+
* Preserves Chinese characters and date prefixes.
|
|
228
208
|
*/
|
|
209
|
+
function cleanFileName(name) {
|
|
210
|
+
const base = name.replace(/\.md$/, '');
|
|
211
|
+
if (/^[\d.]/.test(base))
|
|
212
|
+
return base;
|
|
213
|
+
return base
|
|
214
|
+
.replace(/[-_]/g, ' ')
|
|
215
|
+
.replace(/\b([a-z])/g, (_, c) => c.toUpperCase());
|
|
216
|
+
}
|
|
217
|
+
/** Group flat DocItem list into top-level sections. */
|
|
218
|
+
function buildSections(all) {
|
|
219
|
+
const trans = t();
|
|
220
|
+
const labelMap = {
|
|
221
|
+
tutorial: trans.docs.categoryTutorial,
|
|
222
|
+
process: trans.docs.categoryProcess,
|
|
223
|
+
repair: trans.docs.categoryRepair,
|
|
224
|
+
archived: trans.docs.categoryArchived,
|
|
225
|
+
};
|
|
226
|
+
const groups = new Map();
|
|
227
|
+
for (const item of all) {
|
|
228
|
+
const parts = item.path.split('/');
|
|
229
|
+
if (parts.length < 2)
|
|
230
|
+
continue;
|
|
231
|
+
const top = parts[0];
|
|
232
|
+
if (TOP_SECTION_SKIP.has(top))
|
|
233
|
+
continue;
|
|
234
|
+
if (!TOP_SECTION_ORDER.includes(top))
|
|
235
|
+
continue;
|
|
236
|
+
if (!groups.has(top))
|
|
237
|
+
groups.set(top, []);
|
|
238
|
+
groups.get(top).push(item);
|
|
239
|
+
}
|
|
240
|
+
return TOP_SECTION_ORDER
|
|
241
|
+
.filter(k => groups.has(k))
|
|
242
|
+
.map(k => ({
|
|
243
|
+
key: k,
|
|
244
|
+
label: labelMap[k] ?? k,
|
|
245
|
+
count: groups.get(k).length,
|
|
246
|
+
files: groups.get(k),
|
|
247
|
+
}));
|
|
248
|
+
}
|
|
249
|
+
/** Group archived files by their second path component (year / manual / etc.). */
|
|
250
|
+
function getArchivedGroups(files) {
|
|
251
|
+
const groups = new Map();
|
|
252
|
+
for (const item of files) {
|
|
253
|
+
const group = item.path.split('/')[1] ?? 'other';
|
|
254
|
+
if (!groups.has(group))
|
|
255
|
+
groups.set(group, []);
|
|
256
|
+
groups.get(group).push(item);
|
|
257
|
+
}
|
|
258
|
+
return groups;
|
|
259
|
+
}
|
|
260
|
+
async function loadSections() {
|
|
261
|
+
const trans = t();
|
|
262
|
+
const s = createSpinner(trans.docs.loading);
|
|
263
|
+
try {
|
|
264
|
+
const all = await docsClient.listAll();
|
|
265
|
+
const sections = buildSections(all);
|
|
266
|
+
s.stop();
|
|
267
|
+
return sections;
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
s.error(trans.docs.loadError);
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// ─── Pager layer ──────────────────────────────────────────────────────────────
|
|
229
275
|
async function displayWithGlow(cleanedMarkdown) {
|
|
230
276
|
const cols = String(Math.min(process.stdout.columns || 80, 80));
|
|
231
277
|
return new Promise(resolve => {
|
|
@@ -235,33 +281,34 @@ async function displayWithGlow(cleanedMarkdown) {
|
|
|
235
281
|
child.stdin.write(cleanedMarkdown, 'utf-8');
|
|
236
282
|
child.stdin.end();
|
|
237
283
|
child.on('close', resolve);
|
|
238
|
-
child.on('error', resolve);
|
|
284
|
+
child.on('error', resolve);
|
|
239
285
|
});
|
|
240
286
|
}
|
|
241
|
-
|
|
242
|
-
* Display rendered markdown via `less` with a structured document frame.
|
|
243
|
-
* Flags:
|
|
244
|
-
* -R pass raw ANSI codes through
|
|
245
|
-
* -F exit immediately if content fits on one screen
|
|
246
|
-
* -X don't clear the screen on exit
|
|
247
|
-
* -i case-insensitive search (/ to search)
|
|
248
|
-
* -j4 place search hits 4 lines from the top (less jarring)
|
|
249
|
-
*/
|
|
250
|
-
async function displayWithLess(rendered, title, filePath, readTime) {
|
|
287
|
+
async function displayWithLess(rendered, title, filePath, readTime, toc) {
|
|
251
288
|
const trans = t();
|
|
252
289
|
const cols = Math.min(process.stdout.columns || 80, 80);
|
|
253
|
-
const rule = chalk.dim('
|
|
290
|
+
const rule = chalk.dim('─'.repeat(cols));
|
|
291
|
+
const tocBlock = toc.length >= 3
|
|
292
|
+
? [
|
|
293
|
+
chalk.dim(` ${trans.docs.tocTitle}`),
|
|
294
|
+
chalk.dim(` ${'─'.repeat(36)}`),
|
|
295
|
+
...toc.map(h => chalk.dim(` ${h}`)),
|
|
296
|
+
chalk.dim(` ${'─'.repeat(36)}`),
|
|
297
|
+
'',
|
|
298
|
+
].join('\n')
|
|
299
|
+
: '';
|
|
254
300
|
const header = [
|
|
255
301
|
'',
|
|
256
302
|
chalk.bold.cyan(` ${title}`),
|
|
257
|
-
chalk.dim(` ${filePath}`) + chalk.dim(`
|
|
303
|
+
chalk.dim(` ${filePath}`) + chalk.dim(` · ${readTime}`),
|
|
258
304
|
rule,
|
|
305
|
+
...(tocBlock ? [tocBlock] : []),
|
|
259
306
|
'',
|
|
260
307
|
].join('\n');
|
|
261
308
|
const footer = [
|
|
262
309
|
'',
|
|
263
310
|
rule,
|
|
264
|
-
chalk.dim(` ${trans.docs.endOfDocument}
|
|
311
|
+
chalk.dim(` ${trans.docs.endOfDocument} · / to search`),
|
|
265
312
|
'',
|
|
266
313
|
].join('\n');
|
|
267
314
|
const fullContent = header + rendered + footer;
|
|
@@ -286,66 +333,75 @@ async function displayWithLess(rendered, title, filePath, readTime) {
|
|
|
286
333
|
}
|
|
287
334
|
});
|
|
288
335
|
}
|
|
289
|
-
// ───
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
const s = createSpinner(currentPath ? `${trans.docs.loadingDir}: ${currentPath}` : trans.docs.loading);
|
|
297
|
-
items = await fetchDirectory(currentPath);
|
|
298
|
-
s.stop(currentPath || trans.docs.chooseDoc);
|
|
299
|
-
}
|
|
300
|
-
catch (err) {
|
|
301
|
-
error(trans.docs.loadError);
|
|
302
|
-
const errMsg = err instanceof Error ? err.message : String(err);
|
|
303
|
-
console.log(chalk.gray(` ${trans.docs.errorHint}: ${errMsg}`));
|
|
304
|
-
setVimKeysActive(false);
|
|
305
|
-
const retry = await confirm({ message: trans.docs.retry });
|
|
306
|
-
setVimKeysActive(true);
|
|
307
|
-
if (!isCancel(retry) && retry)
|
|
308
|
-
continue;
|
|
309
|
-
return;
|
|
310
|
-
}
|
|
311
|
-
if (items.length === 0) {
|
|
312
|
-
warning(trans.docs.emptyDir);
|
|
313
|
-
if (currentPath) {
|
|
314
|
-
currentPath = currentPath.split('/').slice(0, -1).join('/');
|
|
315
|
-
continue;
|
|
316
|
-
}
|
|
317
|
-
return;
|
|
318
|
-
}
|
|
319
|
-
const options = [
|
|
320
|
-
...(currentPath ? [{ value: '__back__', label: chalk.dim(trans.docs.upToParent) }] : []),
|
|
321
|
-
...items.map(item => ({
|
|
322
|
-
value: item.path,
|
|
323
|
-
label: item.type === 'dir'
|
|
324
|
-
? chalk.cyan(`${item.name}/`)
|
|
325
|
-
: item.name,
|
|
326
|
-
hint: item.type === 'dir' ? 'dir' : undefined,
|
|
327
|
-
})),
|
|
328
|
-
{ value: '__exit__', label: chalk.dim(trans.docs.returnToMenu) },
|
|
329
|
-
];
|
|
330
|
-
const selected = await select({
|
|
331
|
-
message: currentPath ? `${trans.docs.currentDir}: ${currentPath}` : trans.docs.chooseDoc,
|
|
332
|
-
options,
|
|
333
|
-
});
|
|
334
|
-
if (isCancel(selected) || selected === '__exit__')
|
|
335
|
-
return;
|
|
336
|
-
if (selected === '__back__') {
|
|
337
|
-
currentPath = currentPath.split('/').slice(0, -1).join('/');
|
|
338
|
-
continue;
|
|
339
|
-
}
|
|
340
|
-
const item = items.find(i => i.path === selected);
|
|
341
|
-
if (item?.type === 'dir') {
|
|
342
|
-
currentPath = selected;
|
|
343
|
-
continue;
|
|
344
|
-
}
|
|
345
|
-
if (item?.type === 'file') {
|
|
346
|
-
await viewMarkdownFile(selected);
|
|
347
|
-
}
|
|
336
|
+
// ─── Section browsers ─────────────────────────────────────────────────────────
|
|
337
|
+
/** Show a flat file list for tutorial / process / repair. */
|
|
338
|
+
async function showDocSection(section) {
|
|
339
|
+
const trans = t();
|
|
340
|
+
if (section.key === 'archived') {
|
|
341
|
+
await showArchivedSection(section.files);
|
|
342
|
+
return;
|
|
348
343
|
}
|
|
344
|
+
const files = section.files.filter(f => f.name !== 'index.md' && !f.name.startsWith('index.'));
|
|
345
|
+
if (files.length === 0)
|
|
346
|
+
return;
|
|
347
|
+
const selected = await select({
|
|
348
|
+
message: section.label,
|
|
349
|
+
options: [
|
|
350
|
+
...files.map(f => {
|
|
351
|
+
const parts = f.path.split('/');
|
|
352
|
+
const hint = parts.length > 2 ? parts.slice(1, -1).join('/') : '';
|
|
353
|
+
return { value: f.path, label: cleanFileName(f.name), hint };
|
|
354
|
+
}),
|
|
355
|
+
{ value: '__back__', label: chalk.dim(trans.common.back) },
|
|
356
|
+
],
|
|
357
|
+
});
|
|
358
|
+
if (isCancel(selected) || selected === '__back__')
|
|
359
|
+
return;
|
|
360
|
+
await viewMarkdownFile(selected);
|
|
361
|
+
}
|
|
362
|
+
/** Show archived docs grouped by year, then files within the year. */
|
|
363
|
+
async function showArchivedSection(files) {
|
|
364
|
+
const trans = t();
|
|
365
|
+
const groups = getArchivedGroups(files);
|
|
366
|
+
const sortedKeys = [...groups.keys()].sort((a, b) => {
|
|
367
|
+
const aYear = /^\d{4}$/.test(a);
|
|
368
|
+
const bYear = /^\d{4}$/.test(b);
|
|
369
|
+
if (aYear && bYear)
|
|
370
|
+
return Number(b) - Number(a);
|
|
371
|
+
if (aYear)
|
|
372
|
+
return -1;
|
|
373
|
+
if (bYear)
|
|
374
|
+
return 1;
|
|
375
|
+
return a.localeCompare(b);
|
|
376
|
+
});
|
|
377
|
+
const groupKey = await select({
|
|
378
|
+
message: trans.docs.categoryArchived,
|
|
379
|
+
options: [
|
|
380
|
+
...sortedKeys.map(k => ({
|
|
381
|
+
value: k,
|
|
382
|
+
label: k,
|
|
383
|
+
hint: `${groups.get(k).length} docs`,
|
|
384
|
+
})),
|
|
385
|
+
{ value: '__back__', label: chalk.dim(trans.common.back) },
|
|
386
|
+
],
|
|
387
|
+
});
|
|
388
|
+
if (isCancel(groupKey) || groupKey === '__back__')
|
|
389
|
+
return;
|
|
390
|
+
const groupFiles = groups.get(groupKey) ?? [];
|
|
391
|
+
const fileSelected = await select({
|
|
392
|
+
message: `${trans.docs.categoryArchived} / ${groupKey}`,
|
|
393
|
+
options: [
|
|
394
|
+
...groupFiles.map(f => ({
|
|
395
|
+
value: f.path,
|
|
396
|
+
label: cleanFileName(f.name),
|
|
397
|
+
hint: f.path.split('/').slice(2, -1).join('/'),
|
|
398
|
+
})),
|
|
399
|
+
{ value: '__back__', label: chalk.dim(trans.common.back) },
|
|
400
|
+
],
|
|
401
|
+
});
|
|
402
|
+
if (isCancel(fileSelected) || fileSelected === '__back__')
|
|
403
|
+
return;
|
|
404
|
+
await viewMarkdownFile(fileSelected);
|
|
349
405
|
}
|
|
350
406
|
// ─── Document viewer ──────────────────────────────────────────────────────────
|
|
351
407
|
async function viewMarkdownFile(filePath) {
|
|
@@ -363,28 +419,31 @@ async function viewMarkdownFile(filePath) {
|
|
|
363
419
|
}
|
|
364
420
|
else {
|
|
365
421
|
const cleaned = cleanMarkdownContent(rawContent, getTerminalType());
|
|
366
|
-
const title = extractDocTitle(rawContent, cleaned) || filePath.split('/').pop()
|
|
422
|
+
const title = extractDocTitle(rawContent, cleaned) || cleanFileName(filePath.split('/').pop() ?? filePath);
|
|
367
423
|
const readTime = estimateReadTime(cleaned);
|
|
368
424
|
const rendered = await marked(cleaned);
|
|
369
425
|
renderedDoc = { fingerprint, cleaned, rendered, title, readTime };
|
|
370
426
|
setRender(filePath, renderedDoc);
|
|
371
427
|
}
|
|
372
428
|
s.stop(`${chalk.bold(renderedDoc.title)} ${chalk.dim(renderedDoc.readTime)}`);
|
|
429
|
+
const toc = extractTOC(renderedDoc.cleaned);
|
|
373
430
|
if (hasGlow()) {
|
|
374
431
|
await displayWithGlow(renderedDoc.cleaned);
|
|
375
432
|
}
|
|
376
433
|
else {
|
|
377
|
-
await displayWithLess(renderedDoc.rendered, renderedDoc.title, filePath, renderedDoc.readTime);
|
|
434
|
+
await displayWithLess(renderedDoc.rendered, renderedDoc.title, filePath, renderedDoc.readTime, toc);
|
|
378
435
|
}
|
|
379
436
|
console.log();
|
|
380
437
|
success(trans.docs.docCompleted);
|
|
381
438
|
console.log();
|
|
439
|
+
const hasTable = hasMarkdownTable(rawContent);
|
|
382
440
|
const action = await select({
|
|
383
441
|
message: trans.docs.chooseAction,
|
|
384
442
|
options: [
|
|
385
443
|
{ value: 'back', label: trans.docs.backToList },
|
|
386
444
|
{ value: 'reread', label: trans.docs.reread },
|
|
387
|
-
{ value: 'browser', label: trans.docs.openBrowser
|
|
445
|
+
{ value: 'browser', label: trans.docs.openBrowser,
|
|
446
|
+
hint: hasTable ? trans.docs.tableHint : undefined },
|
|
388
447
|
],
|
|
389
448
|
});
|
|
390
449
|
if (isCancel(action) || action === 'back')
|
|
@@ -393,7 +452,7 @@ async function viewMarkdownFile(filePath) {
|
|
|
393
452
|
await openDocsInBrowser(filePath);
|
|
394
453
|
return;
|
|
395
454
|
}
|
|
396
|
-
//
|
|
455
|
+
// 'reread' → loop
|
|
397
456
|
}
|
|
398
457
|
catch (err) {
|
|
399
458
|
error(trans.docs.loadError);
|
|
@@ -452,7 +511,7 @@ async function searchDocs() {
|
|
|
452
511
|
options: [
|
|
453
512
|
...results.map(r => ({
|
|
454
513
|
value: r.path,
|
|
455
|
-
label: r.name,
|
|
514
|
+
label: cleanFileName(r.name),
|
|
456
515
|
hint: r.path.includes('/') ? r.path.split('/').slice(0, -1).join('/') : '',
|
|
457
516
|
})),
|
|
458
517
|
{ value: '__back__', label: chalk.dim(trans.docs.returnToMenu) },
|
|
@@ -468,19 +527,24 @@ async function searchDocs() {
|
|
|
468
527
|
}
|
|
469
528
|
// ─── Menu ─────────────────────────────────────────────────────────────────────
|
|
470
529
|
export async function showDocsMenu() {
|
|
530
|
+
let sections = await loadSections();
|
|
531
|
+
if (!sections)
|
|
532
|
+
return;
|
|
471
533
|
while (true) {
|
|
472
534
|
const trans = t();
|
|
473
|
-
const categories = getDocCategories();
|
|
474
|
-
const options = [
|
|
475
|
-
...categories.map(cat => ({ value: cat.path, label: cat.name })),
|
|
476
|
-
{ value: 'search', label: chalk.dim(trans.docs.searchPrompt.replace(':', '')) },
|
|
477
|
-
{ value: 'refresh-cache', label: chalk.dim(trans.docs.refreshCache) },
|
|
478
|
-
{ value: 'browser', label: chalk.dim(trans.docs.openBrowser) },
|
|
479
|
-
{ value: 'back', label: chalk.dim(trans.docs.returnToMenu) },
|
|
480
|
-
];
|
|
481
535
|
const action = await select({
|
|
482
536
|
message: trans.docs.chooseCategory,
|
|
483
|
-
options
|
|
537
|
+
options: [
|
|
538
|
+
...sections.map(sec => ({
|
|
539
|
+
value: sec.key,
|
|
540
|
+
label: sec.label,
|
|
541
|
+
hint: `${sec.count} docs`,
|
|
542
|
+
})),
|
|
543
|
+
{ value: 'search', label: chalk.dim(trans.docs.searchPrompt.replace(':', '')) },
|
|
544
|
+
{ value: 'refresh-cache', label: chalk.dim(trans.docs.refreshCache) },
|
|
545
|
+
{ value: 'browser', label: chalk.dim(trans.docs.openBrowser) },
|
|
546
|
+
{ value: 'back', label: chalk.dim(trans.docs.returnToMenu) },
|
|
547
|
+
],
|
|
484
548
|
});
|
|
485
549
|
if (isCancel(action) || action === 'back')
|
|
486
550
|
return;
|
|
@@ -489,13 +553,16 @@ export async function showDocsMenu() {
|
|
|
489
553
|
}
|
|
490
554
|
else if (action === 'refresh-cache') {
|
|
491
555
|
clearDocsCache();
|
|
556
|
+
sections = (await loadSections()) ?? sections;
|
|
492
557
|
success(trans.docs.cacheCleared);
|
|
493
558
|
}
|
|
494
559
|
else if (action === 'browser') {
|
|
495
560
|
await openDocsInBrowser();
|
|
496
561
|
}
|
|
497
562
|
else {
|
|
498
|
-
|
|
563
|
+
const section = sections.find(s => s.key === action);
|
|
564
|
+
if (section)
|
|
565
|
+
await showDocSection(section);
|
|
499
566
|
}
|
|
500
567
|
}
|
|
501
568
|
}
|
|
@@ -92,6 +92,8 @@
|
|
|
92
92
|
"browserError": "Failed to open browser",
|
|
93
93
|
"browserErrorHint": "Please visit manually: https://docs.nbtca.space",
|
|
94
94
|
"retry": "Retry?",
|
|
95
|
+
"tocTitle": "Table of Contents",
|
|
96
|
+
"tableHint": "tables render better in browser",
|
|
95
97
|
"endOfDocument": "End of document - Press q to quit",
|
|
96
98
|
"githubRateLimited": "GitHub API rate limit reached. Resets at {time}.",
|
|
97
99
|
"githubForbidden": "GitHub API access denied (403).",
|
|
@@ -92,6 +92,8 @@
|
|
|
92
92
|
"browserError": "无法打开浏览器",
|
|
93
93
|
"browserErrorHint": "请手动访问: https://docs.nbtca.space",
|
|
94
94
|
"retry": "是否重试?",
|
|
95
|
+
"tocTitle": "目录",
|
|
96
|
+
"tableHint": "表格在浏览器中效果更佳",
|
|
95
97
|
"endOfDocument": "文档结束 - 按 q 退出",
|
|
96
98
|
"githubRateLimited": "GitHub API 速率限制已达上限,将在 {time} 重置。",
|
|
97
99
|
"githubForbidden": "GitHub API 拒绝访问 (403)。",
|