@nbtca/prompt 1.3.1 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -0
- package/SECURITY.md +47 -0
- package/dist/app/app.js +202 -0
- package/dist/app/chrome.js +104 -0
- package/dist/app/fields/list-field.js +174 -0
- package/dist/app/fields/text-field.js +38 -0
- package/dist/app/frame.js +48 -0
- package/dist/app/keys.js +20 -0
- package/dist/app/tabs.js +11 -0
- package/dist/app/view.js +1 -0
- package/dist/app/views/docs-render.js +82 -0
- package/dist/app/views/docs.js +457 -0
- package/dist/app/views/events-render.js +111 -0
- package/dist/app/views/events.js +228 -0
- package/dist/app/views/home.js +236 -0
- package/dist/app/views/schedule-grid-cursor.js +52 -0
- package/dist/app/views/schedule-render.js +317 -0
- package/dist/app/views/schedule.js +472 -0
- package/dist/app/views/settings-render.js +53 -0
- package/dist/app/views/settings.js +153 -0
- package/dist/auth/cookie-transport.js +222 -0
- package/dist/auth/errors.js +18 -0
- package/dist/auth/nbt-auth.js +239 -0
- package/dist/auth/session-store.js +118 -0
- package/dist/config/paths.js +22 -2
- package/dist/core/canvas.js +23 -0
- package/dist/core/capabilities.js +42 -0
- package/dist/core/components/confirm.js +75 -0
- package/dist/core/components/input-session.js +24 -0
- package/dist/core/components/menu.js +122 -0
- package/dist/core/components/messages.js +16 -0
- package/dist/core/components/note.js +18 -0
- package/dist/core/components/painter.js +26 -0
- package/dist/core/components/screen.js +18 -0
- package/dist/core/components/spinner.js +47 -0
- package/dist/core/components/text-input.js +98 -0
- package/dist/core/logo.js +40 -15
- package/dist/core/menu.js +30 -13
- package/dist/core/motion.js +86 -0
- package/dist/core/text.js +127 -5
- package/dist/core/theme.js +61 -0
- package/dist/core/transitions.js +19 -0
- package/dist/core/ui.js +5 -29
- package/dist/features/calendar-heatmap.js +29 -27
- package/dist/features/calendar-query.js +50 -0
- package/dist/features/calendar.js +192 -111
- package/dist/features/docs.js +382 -128
- package/dist/features/links.js +7 -5
- package/dist/features/schedule-query.js +47 -0
- package/dist/features/schedule-render.js +574 -0
- package/dist/features/schedule-store.js +73 -0
- package/dist/features/schedule-view.js +260 -0
- package/dist/features/settings.js +43 -33
- package/dist/features/status.js +37 -13
- package/dist/features/student-timetable.js +346 -0
- package/dist/features/update.js +16 -8
- package/dist/i18n/locales/en.json +162 -17
- package/dist/i18n/locales/zh.json +164 -19
- package/dist/index.js +59 -5
- package/dist/logo/ca-dotmatrix-large.txt +26 -0
- package/dist/logo/ca-dotmatrix-small.txt +12 -0
- package/dist/logo/ca-dotmatrix.txt +18 -16
- package/dist/logo/ca-logo.png +0 -0
- package/dist/main.js +33 -13
- package/package.json +10 -7
package/dist/features/docs.js
CHANGED
|
@@ -2,13 +2,15 @@ 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 {
|
|
6
|
-
import {
|
|
5
|
+
import { runMenu, menuFooter } from '../core/components/menu.js';
|
|
6
|
+
import { runTextInput } from '../core/components/text-input.js';
|
|
7
|
+
import { runConfirm } from '../core/components/confirm.js';
|
|
8
|
+
import { warning, createSpinner } from '../core/ui.js';
|
|
7
9
|
import { pickIcon } from '../core/icons.js';
|
|
8
10
|
import { spawn, execFileSync } from 'child_process';
|
|
9
11
|
import { URLS } from '../config/data.js';
|
|
10
12
|
import { t, fmt } from '../i18n/index.js';
|
|
11
|
-
import {
|
|
13
|
+
import { enterScreen, breadcrumb } from '../core/transitions.js';
|
|
12
14
|
import { createDocsClient } from '@nbtca/docs';
|
|
13
15
|
function detectTerminalType() {
|
|
14
16
|
const term = (process.env['TERM'] || '').toLowerCase();
|
|
@@ -48,16 +50,56 @@ function hasGlow() {
|
|
|
48
50
|
_hasGlow = commandExists('glow');
|
|
49
51
|
return _hasGlow;
|
|
50
52
|
}
|
|
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
|
+
function isInternalHref(href) {
|
|
59
|
+
return /^\.{0,2}\/./.test(href);
|
|
60
|
+
}
|
|
51
61
|
let _markedConfigured = false;
|
|
52
|
-
function ensureMarkedConfigured() {
|
|
62
|
+
export function ensureMarkedConfigured() {
|
|
53
63
|
if (_markedConfigured)
|
|
54
64
|
return;
|
|
55
65
|
_markedConfigured = true;
|
|
56
|
-
|
|
66
|
+
const extension = markedTerminal(getRendererOptions(getTerminalType()));
|
|
67
|
+
const renderer = extension.renderer ?? (extension.renderer = {});
|
|
68
|
+
const renderExternalLink = renderer.link;
|
|
69
|
+
if (renderExternalLink) {
|
|
70
|
+
renderer.link = function (token) {
|
|
71
|
+
if (isInternalHref(token.href))
|
|
72
|
+
return chalk.cyan.underline(token.text);
|
|
73
|
+
return renderExternalLink.call(this, token);
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
// marked-terminal's own `text` renderer always uses the token's raw
|
|
77
|
+
// `.text` string, never `.tokens` -- fine for a plain text run, but a
|
|
78
|
+
// *tight* list item (nbtca/documents' convention throughout: no blank
|
|
79
|
+
// line between "- " entries) tokenizes its content as a `text` token
|
|
80
|
+
// with markdown links inside still sitting unparsed in `.tokens`, not
|
|
81
|
+
// resolved into `.text`. Result: every link inside every bullet list
|
|
82
|
+
// rendered as completely raw, un-clickable-looking `[text](url)` syntax
|
|
83
|
+
// (only paragraph-level links, which *do* go through inline-parsing,
|
|
84
|
+
// picked up the `link` override above at all). Recursing into
|
|
85
|
+
// `parser.parseInline` here when `.tokens` exists routes list-item links
|
|
86
|
+
// through the same override, matching how paragraph/heading already do.
|
|
87
|
+
const renderPlainText = renderer.text;
|
|
88
|
+
if (renderPlainText) {
|
|
89
|
+
renderer.text = function (token) {
|
|
90
|
+
const withTokens = token;
|
|
91
|
+
if (Array.isArray(withTokens.tokens) && withTokens.tokens.length > 0) {
|
|
92
|
+
return this
|
|
93
|
+
.parser.parseInline(withTokens.tokens);
|
|
94
|
+
}
|
|
95
|
+
return renderPlainText.call(this, token);
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
marked.use(extension);
|
|
57
99
|
}
|
|
58
100
|
// ─── marked-terminal renderer ─────────────────────────────────────────────────
|
|
59
101
|
function getRendererOptions(type) {
|
|
60
|
-
const width =
|
|
102
|
+
const width = 80;
|
|
61
103
|
const unicodeTableChars = {
|
|
62
104
|
top: '─', 'top-mid': '┬', 'top-left': '┌', 'top-right': '┐',
|
|
63
105
|
bottom: '─', 'bottom-mid': '┴', 'bottom-left': '└', 'bottom-right': '┘',
|
|
@@ -74,6 +116,14 @@ function getRendererOptions(type) {
|
|
|
74
116
|
width,
|
|
75
117
|
emoji: true,
|
|
76
118
|
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
|
+
showSectionPrefix: false,
|
|
77
127
|
firstHeading: chalk.bold.cyan,
|
|
78
128
|
heading: chalk.bold.white,
|
|
79
129
|
codespan: chalk.yellowBright,
|
|
@@ -122,16 +172,76 @@ async function fetchFileContent(path) {
|
|
|
122
172
|
}
|
|
123
173
|
}
|
|
124
174
|
// ─── Content cleaning ─────────────────────────────────────────────────────────
|
|
175
|
+
/**
|
|
176
|
+
* Line-by-line scanner that processes fenced code blocks before marked sees them:
|
|
177
|
+
* - mermaid blocks → styled blockquote placeholder with diagram type
|
|
178
|
+
* - other blocks with a language tag → prepend an inline-code label line
|
|
179
|
+
*/
|
|
180
|
+
function processFencedCodeBlocks(content) {
|
|
181
|
+
const trans = t();
|
|
182
|
+
const lines = content.split('\n');
|
|
183
|
+
const result = [];
|
|
184
|
+
let inBlock = false;
|
|
185
|
+
let fence = '';
|
|
186
|
+
let blockLang = '';
|
|
187
|
+
let blockBody = [];
|
|
188
|
+
for (const line of lines) {
|
|
189
|
+
if (!inBlock) {
|
|
190
|
+
// Accept VitePress code meta after language: ```js{1,3} or ```ts [file.ts] :line-numbers
|
|
191
|
+
const m = line.match(/^(`{3,})(\w+)?[^`\n]*$/);
|
|
192
|
+
if (m) {
|
|
193
|
+
inBlock = true;
|
|
194
|
+
fence = m[1];
|
|
195
|
+
blockLang = (m[2] ?? '').toLowerCase();
|
|
196
|
+
blockBody = [];
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
result.push(line);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
if (line.startsWith(fence) && /^`+\s*$/.test(line)) {
|
|
204
|
+
inBlock = false;
|
|
205
|
+
const body = blockBody.join('\n');
|
|
206
|
+
if (blockLang === 'mermaid') {
|
|
207
|
+
// Skip %%{ init: ... }%% config directives to find the actual diagram type
|
|
208
|
+
const meaningfulLine = body.trim().split('\n')
|
|
209
|
+
.find(l => !l.trimStart().startsWith('%%') && l.trim()) ?? '';
|
|
210
|
+
const firstToken = meaningfulLine.trim().split(/\s+/)[0] ?? 'diagram';
|
|
211
|
+
const icon = pickIcon('📊', '[diagram]');
|
|
212
|
+
result.push(`> ${icon} **${firstToken}** — _${trans.docs.mermaidHint}_`);
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
if (blockLang)
|
|
216
|
+
result.push(`\`${blockLang}\``);
|
|
217
|
+
result.push(fence);
|
|
218
|
+
result.push(...blockBody);
|
|
219
|
+
result.push(fence);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
blockBody.push(line);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (inBlock) {
|
|
228
|
+
result.push(`${fence}${blockLang}`);
|
|
229
|
+
result.push(...blockBody);
|
|
230
|
+
}
|
|
231
|
+
return result.join('\n');
|
|
232
|
+
}
|
|
125
233
|
const CONTAINER_ICONS_ASCII = {
|
|
126
234
|
info: '[INFO]', tip: '[TIP]', warning: '[WARN]', danger: '[DANGER]', details: '[DETAIL]'
|
|
127
235
|
};
|
|
128
236
|
const CONTAINER_ICONS_UNICODE = {
|
|
129
237
|
info: 'ℹ️', tip: '💡', warning: '⚠️', danger: '🚨', details: '▶️'
|
|
130
238
|
};
|
|
131
|
-
function cleanMarkdownContent(content, type = getTerminalType()) {
|
|
239
|
+
export function cleanMarkdownContent(content, type = getTerminalType()) {
|
|
132
240
|
let c = content;
|
|
133
241
|
// 1. YAML frontmatter
|
|
134
242
|
c = c.replace(/^---\n[\s\S]*?\n---\n?/m, '');
|
|
243
|
+
// 1.5. Fenced code blocks: mermaid → placeholder, other langs → label prefix
|
|
244
|
+
c = processFencedCodeBlocks(c);
|
|
135
245
|
// 2. VitePress script / style blocks
|
|
136
246
|
c = c.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
|
|
137
247
|
c = c.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
|
|
@@ -143,10 +253,22 @@ function cleanMarkdownContent(content, type = getTerminalType()) {
|
|
|
143
253
|
return `> ${icon} **${label}**\n>\n${quoted}\n`;
|
|
144
254
|
});
|
|
145
255
|
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.
|
|
146
264
|
// 4. GitHub / GitLab callout alerts (> [!NOTE])
|
|
147
265
|
c = c.replace(/^>\s*\[!(NOTE|TIP|WARNING|CAUTION|IMPORTANT)\]\s*$/gim, (_, type) => `> **${type.charAt(0) + type.slice(1).toLowerCase()}:**`);
|
|
148
266
|
// 5. [[toc]] — no value in terminal
|
|
149
267
|
c = c.replace(/\[\[toc\]\]/gi, '');
|
|
268
|
+
// 5.5. VitePress heading anchors {#custom-id} — no value in terminal
|
|
269
|
+
c = c.replace(/^(#{1,6}\s+[^\n]*?)\s*\{#[^}]+\}\s*$/gm, '$1');
|
|
270
|
+
// 5.6. ==highlight== → bold (VitePress extended syntax)
|
|
271
|
+
c = c.replace(/==([^=\n]+)==/g, '**$1**');
|
|
150
272
|
// 6. Images — adapt to terminal capability
|
|
151
273
|
if (type === 'basic') {
|
|
152
274
|
c = c.replace(/!\[([^\]]*)\]\([^)]+\)/g, (_, alt) => `${pickIcon('📎', '[image]')} ${alt || 'image'}`);
|
|
@@ -160,8 +282,13 @@ function cleanMarkdownContent(content, type = getTerminalType()) {
|
|
|
160
282
|
// 7. HTML comments
|
|
161
283
|
c = c.replace(/<!--[\s\S]*?-->/g, '');
|
|
162
284
|
// 8. Strip HTML tags, keep inner text
|
|
285
|
+
c = c.replace(/<br\s*\/?>/gi, '\n'); // void: line break
|
|
286
|
+
c = c.replace(/<(?:hr|input|link|meta)\b[^>]*\/?>/gi, ''); // void: discard
|
|
163
287
|
c = c.replace(/<([a-z][a-z0-9]*)\b[^>]*>([\s\S]*?)<\/\1>/gi, '$2');
|
|
164
288
|
c = c.replace(/<[a-z][a-z0-9]*\b[^>]*\/>/gi, '');
|
|
289
|
+
// 8.5. Task list checkboxes
|
|
290
|
+
c = c.replace(/^(\s*[-*+] )\[x\] /gim, '$1☑ ');
|
|
291
|
+
c = c.replace(/^(\s*[-*+] )\[ \] /gm, '$1☐ ');
|
|
165
292
|
// 9. Collapse runs of 3+ blank lines
|
|
166
293
|
c = c.replace(/\n{3,}/g, '\n\n');
|
|
167
294
|
return c.trim();
|
|
@@ -195,18 +322,116 @@ function extractTOC(content) {
|
|
|
195
322
|
return (level === 3 ? ' ' : '') + text;
|
|
196
323
|
});
|
|
197
324
|
}
|
|
198
|
-
/** True if the markdown source contains a
|
|
325
|
+
/** True if the markdown source contains a pipe table. */
|
|
199
326
|
function hasMarkdownTable(content) {
|
|
200
327
|
return /^\|.+\|/m.test(content) && /^\|[-: |]+\|/m.test(content);
|
|
201
328
|
}
|
|
329
|
+
/** True if the markdown source contains a mermaid diagram block. */
|
|
330
|
+
function hasMermaidBlock(content) {
|
|
331
|
+
return /^```mermaid\b/m.test(content);
|
|
332
|
+
}
|
|
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
|
+
function extractInternalLinks(markdown) {
|
|
336
|
+
const links = [];
|
|
337
|
+
const re = /\[([^\]]+)\]\(([^)]+)\)/g;
|
|
338
|
+
let m;
|
|
339
|
+
while ((m = re.exec(markdown))) {
|
|
340
|
+
const href = m[2] ?? '';
|
|
341
|
+
if (isInternalHref(href))
|
|
342
|
+
links.push({ text: m[1] ?? '', href });
|
|
343
|
+
}
|
|
344
|
+
return links;
|
|
345
|
+
}
|
|
346
|
+
/** Resolves a wiki-style href (relative to the *linking* document, VitePress
|
|
347
|
+
* conventions: no .md extension, trailing '/' means that dir's index) into
|
|
348
|
+
* a real repo-relative path matching DocItem.path -- e.g. './what-is-nbtca'
|
|
349
|
+
* from within 'about/index.md' -> 'about/what-is-nbtca.md'; '/concepts/'
|
|
350
|
+
* (root-relative, works from anywhere) -> 'concepts/index.md'. */
|
|
351
|
+
function resolveInternalHref(href, fromPath) {
|
|
352
|
+
const fromDir = fromPath.includes('/') ? fromPath.slice(0, fromPath.lastIndexOf('/')) : '';
|
|
353
|
+
const combined = href.startsWith('/') ? href.slice(1) : (fromDir ? `${fromDir}/${href}` : href);
|
|
354
|
+
const stack = [];
|
|
355
|
+
for (const part of combined.split('/')) {
|
|
356
|
+
if (part === '' || part === '.')
|
|
357
|
+
continue;
|
|
358
|
+
if (part === '..') {
|
|
359
|
+
stack.pop();
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
stack.push(part);
|
|
363
|
+
}
|
|
364
|
+
let target = stack.join('/');
|
|
365
|
+
if (target === '' || href.endsWith('/'))
|
|
366
|
+
target += (target ? '/' : '') + 'index';
|
|
367
|
+
if (!target.endsWith('.md'))
|
|
368
|
+
target += '.md';
|
|
369
|
+
return target;
|
|
370
|
+
}
|
|
371
|
+
/** Loads and renders a doc for the native in-app reader -- the same
|
|
372
|
+
* fetch/clean/render/cache pipeline viewMarkdownFile uses, minus the
|
|
373
|
+
* spinner/pager/post-read menu, which belong to the classic-pager
|
|
374
|
+
* presentation layer, not this one. */
|
|
375
|
+
export async function loadDocForReader(filePath) {
|
|
376
|
+
ensureMarkedConfigured();
|
|
377
|
+
const rawContent = await fetchFileContent(filePath);
|
|
378
|
+
const fingerprint = contentFingerprint(rawContent);
|
|
379
|
+
const cached = getFreshRender(filePath);
|
|
380
|
+
let renderedDoc;
|
|
381
|
+
if (cached && cached.fingerprint === fingerprint) {
|
|
382
|
+
renderedDoc = cached;
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
const cleaned = cleanMarkdownContent(rawContent, getTerminalType());
|
|
386
|
+
const title = extractDocTitle(rawContent, cleaned) || cleanFileName(filePath.split('/').pop() ?? filePath);
|
|
387
|
+
const readTime = estimateReadTime(cleaned);
|
|
388
|
+
const rendered = await marked(cleaned);
|
|
389
|
+
renderedDoc = { fingerprint, cleaned, rendered, title, readTime };
|
|
390
|
+
setRender(filePath, renderedDoc);
|
|
391
|
+
}
|
|
392
|
+
const seen = new Set();
|
|
393
|
+
const links = [];
|
|
394
|
+
for (const raw of extractInternalLinks(renderedDoc.cleaned)) {
|
|
395
|
+
const resolved = resolveInternalHref(raw.href, filePath);
|
|
396
|
+
if (seen.has(resolved))
|
|
397
|
+
continue;
|
|
398
|
+
seen.add(resolved);
|
|
399
|
+
links.push({ text: raw.text, href: resolved });
|
|
400
|
+
}
|
|
401
|
+
return { path: filePath, title: renderedDoc.title, lines: renderedDoc.rendered.split('\n'), links };
|
|
402
|
+
}
|
|
202
403
|
// ─── Document tree ────────────────────────────────────────────────────────────
|
|
203
|
-
|
|
404
|
+
// Sourced from a live audit of nbtca/documents (2026-07-18): `about` and
|
|
405
|
+
// `concepts` are two whole new top-level sections added in the repo's wiki
|
|
406
|
+
// reconstruction (5abcc4d, 5beee27) -- omitted here, buildSections() below
|
|
407
|
+
// silently drops every file under them, which is exactly what happened
|
|
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.
|
|
411
|
+
const TOP_SECTION_ORDER = ['about', 'guide', 'repair', 'concepts', 'archived'];
|
|
204
412
|
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
|
+
const SECTION_ALIAS = { tutorial: 'guide', process: 'guide' };
|
|
420
|
+
export function localizeDocSections(sections, trans = t()) {
|
|
421
|
+
const labels = {
|
|
422
|
+
about: trans.docs.categoryAbout,
|
|
423
|
+
guide: trans.docs.categoryGuide,
|
|
424
|
+
repair: trans.docs.categoryRepair,
|
|
425
|
+
concepts: trans.docs.categoryConcepts,
|
|
426
|
+
archived: trans.docs.categoryArchived,
|
|
427
|
+
};
|
|
428
|
+
return sections.map((section) => ({ ...section, label: labels[section.key] ?? section.label }));
|
|
429
|
+
}
|
|
205
430
|
/**
|
|
206
431
|
* Convert a kebab-case filename to a display-friendly title.
|
|
207
432
|
* Preserves Chinese characters and date prefixes.
|
|
208
433
|
*/
|
|
209
|
-
function cleanFileName(name) {
|
|
434
|
+
export function cleanFileName(name) {
|
|
210
435
|
const base = name.replace(/\.md$/, '');
|
|
211
436
|
if (/^[\d.]/.test(base))
|
|
212
437
|
return base;
|
|
@@ -214,40 +439,81 @@ function cleanFileName(name) {
|
|
|
214
439
|
.replace(/[-_]/g, ' ')
|
|
215
440
|
.replace(/\b([a-z])/g, (_, c) => c.toUpperCase());
|
|
216
441
|
}
|
|
442
|
+
/**
|
|
443
|
+
* Real titles (each document's own top-level `# heading`) for the
|
|
444
|
+
* curated tutorial/process/repair sections, keyed by repo-relative path.
|
|
445
|
+
* These are hand-authored English-filename docs with Chinese content —
|
|
446
|
+
* mechanically title-casing the filename ("Clean Drive C") reads as a
|
|
447
|
+
* different, lower-quality product than the document's own title ("C盘
|
|
448
|
+
* 清理标准化流程"). Deliberately scoped to these three sections only:
|
|
449
|
+
* `archived/`'s meeting notes are informal and often share the same
|
|
450
|
+
* generic real heading across many different dates (e.g. five different
|
|
451
|
+
* files all titled just "维修日") — there, the current filename-derived,
|
|
452
|
+
* date-prefixed label is more useful for telling entries apart than the
|
|
453
|
+
* real heading would be, so it is intentionally left as-is.
|
|
454
|
+
*
|
|
455
|
+
* Pulled from a live audit of the actual nbtca/documents content
|
|
456
|
+
* (2026-07-16). A doc added later without an entry here simply falls
|
|
457
|
+
* back to `cleanFileName` — never an error, never a blank label.
|
|
458
|
+
*/
|
|
459
|
+
const KNOWN_DOC_TITLES = {
|
|
460
|
+
'tutorial/2025/clean-drive-c.md': 'C盘清理标准化流程',
|
|
461
|
+
'tutorial/2025/edu-email.md': '教育邮箱用途',
|
|
462
|
+
'tutorial/2025/github-education-verification.md': 'Github Education 认证指南',
|
|
463
|
+
'tutorial/2025/github-workflow.md': '快速上手社团目前的Github工作流',
|
|
464
|
+
'tutorial/2025/google-calendar.md': '谷歌日历使用指南',
|
|
465
|
+
'tutorial/2025/nginx-usage.md': '快速上手你的nginx',
|
|
466
|
+
'tutorial/2025/tailscale-usage.md': '社团自建 Tailscale 使用指南',
|
|
467
|
+
'tutorial/manual/hardware-establish.md': '计算机硬件系统的搭建与维护',
|
|
468
|
+
'tutorial/manual/net-usage.md': '国际互联网的使用',
|
|
469
|
+
'tutorial/manual/os-skills.md': '基础操作系统的使用技术',
|
|
470
|
+
'tutorial/manual/windows-from-scratch.md': '从零开始安装 Windows',
|
|
471
|
+
'process/2025/apply-for-credits.md': '申请第二课堂学分',
|
|
472
|
+
'process/2025/borrow-classroom.md': '借教室',
|
|
473
|
+
'process/2025/event-organization.md': '活动举办文档(待完善)',
|
|
474
|
+
'process/2025/nbtca-post.md': '撰写并发布你的第一篇NBTCA博客',
|
|
475
|
+
'process/2025/reimbursement-process.md': '报销流程',
|
|
476
|
+
'repair/checklist.md': '维修日检查单',
|
|
477
|
+
'repair/guide.md': '维修操作指南',
|
|
478
|
+
'repair/repair-day.md': '维修日',
|
|
479
|
+
'repair/tools.md': '软件仓库(校内镜像站)',
|
|
480
|
+
'repair/weekend.md': '维修工单系统 (weekend)',
|
|
481
|
+
};
|
|
482
|
+
/** Display title for a tutorial/process/repair doc: the real, known title
|
|
483
|
+
* when we have one, otherwise the same filename-derived fallback used
|
|
484
|
+
* everywhere else (including for every archived/ doc, which never has a
|
|
485
|
+
* known-title entry by design). */
|
|
486
|
+
export function displayDocTitle(path, name) {
|
|
487
|
+
return KNOWN_DOC_TITLES[path] ?? cleanFileName(name);
|
|
488
|
+
}
|
|
217
489
|
/** 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
|
-
};
|
|
490
|
+
export function buildSections(all) {
|
|
226
491
|
const groups = new Map();
|
|
227
492
|
for (const item of all) {
|
|
228
493
|
const parts = item.path.split('/');
|
|
229
494
|
if (parts.length < 2)
|
|
230
495
|
continue;
|
|
231
|
-
const
|
|
232
|
-
if (TOP_SECTION_SKIP.has(
|
|
496
|
+
const rawTop = parts[0];
|
|
497
|
+
if (TOP_SECTION_SKIP.has(rawTop))
|
|
233
498
|
continue;
|
|
499
|
+
const top = SECTION_ALIAS[rawTop] ?? rawTop;
|
|
234
500
|
if (!TOP_SECTION_ORDER.includes(top))
|
|
235
501
|
continue;
|
|
236
502
|
if (!groups.has(top))
|
|
237
503
|
groups.set(top, []);
|
|
238
504
|
groups.get(top).push(item);
|
|
239
505
|
}
|
|
240
|
-
return TOP_SECTION_ORDER
|
|
506
|
+
return localizeDocSections(TOP_SECTION_ORDER
|
|
241
507
|
.filter(k => groups.has(k))
|
|
242
508
|
.map(k => ({
|
|
243
509
|
key: k,
|
|
244
|
-
label:
|
|
510
|
+
label: k,
|
|
245
511
|
count: groups.get(k).length,
|
|
246
512
|
files: groups.get(k),
|
|
247
|
-
}));
|
|
513
|
+
})));
|
|
248
514
|
}
|
|
249
515
|
/** Group archived files by their second path component (year / manual / etc.). */
|
|
250
|
-
function getArchivedGroups(files) {
|
|
516
|
+
export function getArchivedGroups(files) {
|
|
251
517
|
const groups = new Map();
|
|
252
518
|
for (const item of files) {
|
|
253
519
|
const group = item.path.split('/')[1] ?? 'other';
|
|
@@ -257,12 +523,19 @@ function getArchivedGroups(files) {
|
|
|
257
523
|
}
|
|
258
524
|
return groups;
|
|
259
525
|
}
|
|
526
|
+
/** Raw fetch, no spinner/UI — throws on failure. Shared by the classic and
|
|
527
|
+
* native-view loaders. */
|
|
528
|
+
export async function fetchAllDocs() {
|
|
529
|
+
return docsClient.listAll();
|
|
530
|
+
}
|
|
531
|
+
export async function fetchSections() {
|
|
532
|
+
return buildSections(await fetchAllDocs());
|
|
533
|
+
}
|
|
260
534
|
async function loadSections() {
|
|
261
535
|
const trans = t();
|
|
262
536
|
const s = createSpinner(trans.docs.loading);
|
|
263
537
|
try {
|
|
264
|
-
const
|
|
265
|
-
const sections = buildSections(all);
|
|
538
|
+
const sections = await fetchSections();
|
|
266
539
|
s.stop();
|
|
267
540
|
return sections;
|
|
268
541
|
}
|
|
@@ -308,7 +581,7 @@ async function displayWithLess(rendered, title, filePath, readTime, toc) {
|
|
|
308
581
|
const footer = [
|
|
309
582
|
'',
|
|
310
583
|
rule,
|
|
311
|
-
chalk.dim(` ${trans.docs.endOfDocument}
|
|
584
|
+
chalk.dim(` ${trans.docs.endOfDocument}`),
|
|
312
585
|
'',
|
|
313
586
|
].join('\n');
|
|
314
587
|
const fullContent = header + rendered + footer;
|
|
@@ -344,18 +617,15 @@ async function showDocSection(section) {
|
|
|
344
617
|
const files = section.files.filter(f => f.name !== 'index.md' && !f.name.startsWith('index.'));
|
|
345
618
|
if (files.length === 0)
|
|
346
619
|
return;
|
|
347
|
-
const selected = await
|
|
348
|
-
|
|
620
|
+
const selected = await runMenu({
|
|
621
|
+
title: section.label,
|
|
349
622
|
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
|
-
}),
|
|
623
|
+
...files.map(f => ({ value: f.path, label: cleanFileName(f.name) })),
|
|
355
624
|
{ value: '__back__', label: chalk.dim(trans.common.back) },
|
|
356
625
|
],
|
|
626
|
+
footer: menuFooter(),
|
|
357
627
|
});
|
|
358
|
-
if (
|
|
628
|
+
if (selected === null || selected === '__back__')
|
|
359
629
|
return;
|
|
360
630
|
await viewMarkdownFile(selected);
|
|
361
631
|
}
|
|
@@ -374,97 +644,91 @@ async function showArchivedSection(files) {
|
|
|
374
644
|
return 1;
|
|
375
645
|
return a.localeCompare(b);
|
|
376
646
|
});
|
|
377
|
-
const groupKey = await
|
|
378
|
-
|
|
647
|
+
const groupKey = await runMenu({
|
|
648
|
+
title: trans.docs.categoryArchived,
|
|
379
649
|
options: [
|
|
380
650
|
...sortedKeys.map(k => ({
|
|
381
651
|
value: k,
|
|
382
652
|
label: k,
|
|
383
|
-
hint:
|
|
653
|
+
hint: String(groups.get(k).length),
|
|
384
654
|
})),
|
|
385
655
|
{ value: '__back__', label: chalk.dim(trans.common.back) },
|
|
386
656
|
],
|
|
657
|
+
footer: menuFooter(),
|
|
387
658
|
});
|
|
388
|
-
if (
|
|
659
|
+
if (groupKey === null || groupKey === '__back__')
|
|
389
660
|
return;
|
|
390
661
|
const groupFiles = groups.get(groupKey) ?? [];
|
|
391
|
-
const
|
|
392
|
-
|
|
662
|
+
const subDirs = new Set(groupFiles.map(f => f.path.split('/')[2]).filter(Boolean));
|
|
663
|
+
const fileSelected = await runMenu({
|
|
664
|
+
title: `${trans.docs.categoryArchived} · ${groupKey}`,
|
|
393
665
|
options: [
|
|
394
|
-
...groupFiles.map(f =>
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
666
|
+
...groupFiles.map(f => {
|
|
667
|
+
const sub = f.path.split('/').slice(2, -1).join('/');
|
|
668
|
+
return {
|
|
669
|
+
value: f.path,
|
|
670
|
+
label: cleanFileName(f.name),
|
|
671
|
+
hint: subDirs.size > 1 ? sub : undefined,
|
|
672
|
+
};
|
|
673
|
+
}),
|
|
399
674
|
{ value: '__back__', label: chalk.dim(trans.common.back) },
|
|
400
675
|
],
|
|
676
|
+
footer: menuFooter(),
|
|
401
677
|
});
|
|
402
|
-
if (
|
|
678
|
+
if (fileSelected === null || fileSelected === '__back__')
|
|
403
679
|
return;
|
|
404
680
|
await viewMarkdownFile(fileSelected);
|
|
405
681
|
}
|
|
406
682
|
// ─── Document viewer ──────────────────────────────────────────────────────────
|
|
407
|
-
async function viewMarkdownFile(filePath) {
|
|
683
|
+
export async function viewMarkdownFile(filePath) {
|
|
408
684
|
const trans = t();
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
renderedDoc = cachedRendered;
|
|
419
|
-
}
|
|
420
|
-
else {
|
|
421
|
-
const cleaned = cleanMarkdownContent(rawContent, getTerminalType());
|
|
422
|
-
const title = extractDocTitle(rawContent, cleaned) || cleanFileName(filePath.split('/').pop() ?? filePath);
|
|
423
|
-
const readTime = estimateReadTime(cleaned);
|
|
424
|
-
const rendered = await marked(cleaned);
|
|
425
|
-
renderedDoc = { fingerprint, cleaned, rendered, title, readTime };
|
|
426
|
-
setRender(filePath, renderedDoc);
|
|
427
|
-
}
|
|
428
|
-
s.stop(`${chalk.bold(renderedDoc.title)} ${chalk.dim(renderedDoc.readTime)}`);
|
|
429
|
-
const toc = extractTOC(renderedDoc.cleaned);
|
|
430
|
-
if (hasGlow()) {
|
|
431
|
-
await displayWithGlow(renderedDoc.cleaned);
|
|
432
|
-
}
|
|
433
|
-
else {
|
|
434
|
-
await displayWithLess(renderedDoc.rendered, renderedDoc.title, filePath, renderedDoc.readTime, toc);
|
|
435
|
-
}
|
|
436
|
-
console.log();
|
|
437
|
-
success(trans.docs.docCompleted);
|
|
438
|
-
console.log();
|
|
439
|
-
const hasTable = hasMarkdownTable(rawContent);
|
|
440
|
-
const action = await select({
|
|
441
|
-
message: trans.docs.chooseAction,
|
|
442
|
-
options: [
|
|
443
|
-
{ value: 'back', label: trans.docs.backToList },
|
|
444
|
-
{ value: 'reread', label: trans.docs.reread },
|
|
445
|
-
{ value: 'browser', label: trans.docs.openBrowser,
|
|
446
|
-
hint: hasTable ? trans.docs.tableHint : undefined },
|
|
447
|
-
],
|
|
448
|
-
});
|
|
449
|
-
if (isCancel(action) || action === 'back')
|
|
450
|
-
return;
|
|
451
|
-
if (action === 'browser') {
|
|
452
|
-
await openDocsInBrowser(filePath);
|
|
453
|
-
return;
|
|
454
|
-
}
|
|
455
|
-
// 'reread' → loop
|
|
685
|
+
ensureMarkedConfigured();
|
|
686
|
+
const s = createSpinner(`${trans.docs.loadingFile}: ${filePath}`);
|
|
687
|
+
try {
|
|
688
|
+
const rawContent = await fetchFileContent(filePath);
|
|
689
|
+
const fingerprint = contentFingerprint(rawContent);
|
|
690
|
+
const cachedRendered = getFreshRender(filePath);
|
|
691
|
+
let renderedDoc;
|
|
692
|
+
if (cachedRendered && cachedRendered.fingerprint === fingerprint) {
|
|
693
|
+
renderedDoc = cachedRendered;
|
|
456
694
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
+
}
|
|
703
|
+
s.stop(`${chalk.bold(renderedDoc.title)} ${chalk.dim(renderedDoc.readTime)}`);
|
|
704
|
+
const toc = extractTOC(renderedDoc.cleaned);
|
|
705
|
+
if (hasGlow()) {
|
|
706
|
+
await displayWithGlow(renderedDoc.cleaned);
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
await displayWithLess(renderedDoc.rendered, renderedDoc.title, filePath, renderedDoc.readTime, toc);
|
|
710
|
+
}
|
|
711
|
+
const needsBrowser = hasMarkdownTable(rawContent) || hasMermaidBlock(rawContent);
|
|
712
|
+
const action = await runMenu({
|
|
713
|
+
title: trans.docs.chooseAction,
|
|
714
|
+
options: [
|
|
715
|
+
{ value: 'back', label: trans.docs.backToList },
|
|
716
|
+
{ value: 'browser', label: trans.docs.openBrowser,
|
|
717
|
+
hint: needsBrowser ? trans.docs.tableHint : undefined },
|
|
718
|
+
],
|
|
719
|
+
footer: menuFooter(),
|
|
720
|
+
});
|
|
721
|
+
if (action === 'browser') {
|
|
722
|
+
await openDocsInBrowser(filePath);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
catch (err) {
|
|
726
|
+
s.error(trans.docs.loadError);
|
|
727
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
728
|
+
console.log(chalk.gray(` ${trans.docs.errorHint}: ${errMsg}`));
|
|
729
|
+
const openBrowser = await runConfirm({ message: trans.docs.openBrowserPrompt });
|
|
730
|
+
if (openBrowser === true) {
|
|
731
|
+
await openDocsInBrowser(filePath);
|
|
468
732
|
}
|
|
469
733
|
}
|
|
470
734
|
}
|
|
@@ -488,13 +752,11 @@ export async function openDocsInBrowser(path) {
|
|
|
488
752
|
// ─── Search ────────────────────────────────────────────────────────────────────
|
|
489
753
|
async function searchDocs() {
|
|
490
754
|
const trans = t();
|
|
491
|
-
|
|
492
|
-
const query = await text({
|
|
755
|
+
const query = await runTextInput({
|
|
493
756
|
message: trans.docs.searchPrompt,
|
|
494
757
|
placeholder: trans.docs.searchPlaceholder,
|
|
495
758
|
});
|
|
496
|
-
|
|
497
|
-
if (isCancel(query) || !query.trim())
|
|
759
|
+
if (query === null || !query.trim())
|
|
498
760
|
return;
|
|
499
761
|
const keyword = query.trim().toLowerCase();
|
|
500
762
|
const s = createSpinner(trans.docs.searching);
|
|
@@ -506,8 +768,8 @@ async function searchDocs() {
|
|
|
506
768
|
warning(trans.docs.searchNoResults);
|
|
507
769
|
return;
|
|
508
770
|
}
|
|
509
|
-
const selected = await
|
|
510
|
-
|
|
771
|
+
const selected = await runMenu({
|
|
772
|
+
title: trans.docs.chooseDoc,
|
|
511
773
|
options: [
|
|
512
774
|
...results.map(r => ({
|
|
513
775
|
value: r.path,
|
|
@@ -516,8 +778,9 @@ async function searchDocs() {
|
|
|
516
778
|
})),
|
|
517
779
|
{ value: '__back__', label: chalk.dim(trans.docs.returnToMenu) },
|
|
518
780
|
],
|
|
781
|
+
footer: menuFooter(),
|
|
519
782
|
});
|
|
520
|
-
if (
|
|
783
|
+
if (selected === null || selected === '__back__')
|
|
521
784
|
return;
|
|
522
785
|
await viewMarkdownFile(selected);
|
|
523
786
|
}
|
|
@@ -527,35 +790,26 @@ async function searchDocs() {
|
|
|
527
790
|
}
|
|
528
791
|
// ─── Menu ─────────────────────────────────────────────────────────────────────
|
|
529
792
|
export async function showDocsMenu() {
|
|
793
|
+
await enterScreen(breadcrumb(t().menu.docs));
|
|
530
794
|
let sections = await loadSections();
|
|
531
795
|
if (!sections)
|
|
532
796
|
return;
|
|
533
797
|
while (true) {
|
|
534
798
|
const trans = t();
|
|
535
|
-
const action = await
|
|
536
|
-
|
|
799
|
+
const action = await runMenu({
|
|
800
|
+
title: trans.docs.chooseCategory,
|
|
537
801
|
options: [
|
|
538
|
-
...sections.map(sec => ({
|
|
539
|
-
value: sec.key,
|
|
540
|
-
label: sec.label,
|
|
541
|
-
hint: `${sec.count} docs`,
|
|
542
|
-
})),
|
|
802
|
+
...sections.map(sec => ({ value: sec.key, label: sec.label })),
|
|
543
803
|
{ value: 'search', label: chalk.dim(trans.docs.searchPrompt.replace(':', '')) },
|
|
544
|
-
{ value: 'refresh-cache', label: chalk.dim(trans.docs.refreshCache) },
|
|
545
804
|
{ value: 'browser', label: chalk.dim(trans.docs.openBrowser) },
|
|
546
|
-
{ value: 'back', label: chalk.dim(trans.docs.returnToMenu) },
|
|
547
805
|
],
|
|
806
|
+
footer: menuFooter(),
|
|
548
807
|
});
|
|
549
|
-
if (
|
|
808
|
+
if (action === null)
|
|
550
809
|
return;
|
|
551
810
|
if (action === 'search') {
|
|
552
811
|
await searchDocs();
|
|
553
812
|
}
|
|
554
|
-
else if (action === 'refresh-cache') {
|
|
555
|
-
clearDocsCache();
|
|
556
|
-
sections = (await loadSections()) ?? sections;
|
|
557
|
-
success(trans.docs.cacheCleared);
|
|
558
|
-
}
|
|
559
813
|
else if (action === 'browser') {
|
|
560
814
|
await openDocsInBrowser();
|
|
561
815
|
}
|