mdzilla 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -59
- package/dist/_chunks/exporter.mjs +173 -49
- package/dist/_chunks/server.mjs +406 -378
- package/dist/cli/main.mjs +149 -604
- package/dist/index.d.mts +105 -64
- package/dist/index.mjs +2 -2
- package/package.json +5 -5
package/dist/cli/main.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { l as extractSnippets, n as writeCollection, r as resolveSource, u as Collection } from "../_chunks/exporter.mjs";
|
|
3
|
+
import { parseMeta, renderToAnsi, renderToText } from "md4x";
|
|
3
4
|
import { readFile } from "node:fs/promises";
|
|
4
5
|
import { basename } from "node:path";
|
|
5
|
-
import { parseMeta, renderToAnsi, renderToText } from "md4x";
|
|
6
6
|
import { parseArgs } from "node:util";
|
|
7
7
|
import { isAgent } from "std-env";
|
|
8
8
|
import { highlightText } from "@speed-highlight/core/terminal";
|
|
@@ -11,33 +11,13 @@ import { exec, execFile } from "node:child_process";
|
|
|
11
11
|
const noColor = !!(process.env.NO_COLOR || process.env.TERM === "dumb" || !process.stdout.isTTY || isAgent);
|
|
12
12
|
const ESC = "\x1B[";
|
|
13
13
|
const ANSI_RE = /\x1B(?:\[[0-9;]*[a-zA-Z]|\][^\x07\x1B]*(?:\x07|\x1B\\))/g;
|
|
14
|
-
const clear = () => process.stdout.write(`${ESC}2J${ESC}3J${ESC}H`);
|
|
15
|
-
const enterAltScreen = () => process.stdout.write(`${ESC}?1049h`);
|
|
16
|
-
const leaveAltScreen = () => process.stdout.write(`${ESC}?1049l`);
|
|
17
|
-
const hideCursor = () => process.stdout.write(`${ESC}?25l`);
|
|
18
|
-
const showCursor = () => process.stdout.write(`${ESC}?25h`);
|
|
19
14
|
const bold = (s) => noColor ? s : `${ESC}1m${s}${ESC}0m`;
|
|
20
15
|
const dim = (s) => noColor ? s : `${ESC}2m${s}${ESC}0m`;
|
|
21
16
|
const cyan = (s) => noColor ? s : `${ESC}36m${s}${ESC}0m`;
|
|
22
17
|
const yellow = (s) => noColor ? s : `${ESC}33m${s}${ESC}0m`;
|
|
23
|
-
const bgGray = (s) => {
|
|
24
|
-
if (noColor) return s;
|
|
25
|
-
const bg = `${ESC}48;5;237m`;
|
|
26
|
-
return `${bg}${s.replaceAll(`${ESC}0m`, `${ESC}0m${bg}`)}${ESC}0m`;
|
|
27
|
-
};
|
|
28
18
|
const stripAnsi = (s) => s.replace(ANSI_RE, "");
|
|
29
|
-
const visibleLength = (s) => stripAnsi(s).length;
|
|
30
|
-
function padTo(s, width) {
|
|
31
|
-
const visible = visibleLength(s);
|
|
32
|
-
if (visible >= width) return truncateTo(s, width);
|
|
33
|
-
return s + " ".repeat(width - visible);
|
|
34
|
-
}
|
|
35
|
-
function truncateTo(s, width) {
|
|
36
|
-
if (visibleLength(s) <= width) return s;
|
|
37
|
-
return wrapAnsi(s, width)[0] || "";
|
|
38
|
-
}
|
|
39
19
|
function wrapAnsi(s, width) {
|
|
40
|
-
if (width <= 0 ||
|
|
20
|
+
if (width <= 0 || stripAnsi(s).length <= width) return [s];
|
|
41
21
|
const tokens = [];
|
|
42
22
|
let last = 0;
|
|
43
23
|
const re = new RegExp(ANSI_RE.source, "g");
|
|
@@ -83,74 +63,18 @@ function wrapAnsi(s, width) {
|
|
|
83
63
|
}
|
|
84
64
|
return lines;
|
|
85
65
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const re = new RegExp(ANSI_RE.source, "g");
|
|
90
|
-
const visibleChars = [];
|
|
91
|
-
let last = 0;
|
|
92
|
-
let match;
|
|
93
|
-
while ((match = re.exec(s)) !== null) {
|
|
94
|
-
for (let i = last; i < match.index; i++) visibleChars.push({
|
|
95
|
-
char: s[i],
|
|
96
|
-
rawIdx: i
|
|
97
|
-
});
|
|
98
|
-
last = match.index + match[0].length;
|
|
99
|
-
}
|
|
100
|
-
for (let i = last; i < s.length; i++) visibleChars.push({
|
|
101
|
-
char: s[i],
|
|
102
|
-
rawIdx: i
|
|
103
|
-
});
|
|
104
|
-
const lowerVisible = visibleChars.map((c) => c.char).join("").toLowerCase();
|
|
66
|
+
function highlight(text, query) {
|
|
67
|
+
if (!query) return text;
|
|
68
|
+
const lowerText = text.toLowerCase();
|
|
105
69
|
const lowerQuery = query.toLowerCase();
|
|
106
|
-
const matches = [];
|
|
107
|
-
let pos = 0;
|
|
108
|
-
while ((pos = lowerVisible.indexOf(lowerQuery, pos)) >= 0) {
|
|
109
|
-
matches.push([pos, pos + query.length]);
|
|
110
|
-
pos += 1;
|
|
111
|
-
}
|
|
112
|
-
if (matches.length === 0) return s;
|
|
113
|
-
const hlRanges = [];
|
|
114
|
-
for (const [start, end] of matches) {
|
|
115
|
-
const rawStart = visibleChars[start].rawIdx;
|
|
116
|
-
const rawEnd = end < visibleChars.length ? visibleChars[end].rawIdx : s.length;
|
|
117
|
-
hlRanges.push([rawStart, rawEnd]);
|
|
118
|
-
}
|
|
119
|
-
const hlOn = `${ESC}7m`;
|
|
120
|
-
const hlOff = `${ESC}27m`;
|
|
121
|
-
const resetSeq = `${ESC}0m`;
|
|
122
70
|
let result = "";
|
|
123
|
-
let
|
|
124
|
-
let
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
while ((escMatch = escRe.exec(s)) !== null) escapes.push({
|
|
129
|
-
idx: escMatch.index,
|
|
130
|
-
seq: escMatch[0]
|
|
131
|
-
});
|
|
132
|
-
let escIdx = 0;
|
|
133
|
-
for (rawIdx = 0; rawIdx <= s.length; rawIdx++) {
|
|
134
|
-
const wasIn = inHighlight;
|
|
135
|
-
inHighlight = hlRanges.some(([a, b]) => rawIdx >= a && rawIdx < b);
|
|
136
|
-
if (inHighlight && !wasIn) result += hlOn;
|
|
137
|
-
if (!inHighlight && wasIn) result += hlOff;
|
|
138
|
-
if (rawIdx >= s.length) break;
|
|
139
|
-
if (escIdx < escapes.length && escapes[escIdx].idx === rawIdx) {
|
|
140
|
-
const esc = escapes[escIdx];
|
|
141
|
-
result += esc.seq;
|
|
142
|
-
if (inHighlight && esc.seq === resetSeq) result += hlOn;
|
|
143
|
-
rawIdx += esc.seq.length - 1;
|
|
144
|
-
escIdx++;
|
|
145
|
-
} else result += s[rawIdx];
|
|
71
|
+
let last = 0;
|
|
72
|
+
let pos = 0;
|
|
73
|
+
while ((pos = lowerText.indexOf(lowerQuery, last)) >= 0) {
|
|
74
|
+
result += text.slice(last, pos) + bold(yellow(text.slice(pos, pos + query.length)));
|
|
75
|
+
last = pos + query.length;
|
|
146
76
|
}
|
|
147
|
-
return result;
|
|
148
|
-
}
|
|
149
|
-
function highlight(text, query) {
|
|
150
|
-
if (!query) return text;
|
|
151
|
-
const idx = text.toLowerCase().indexOf(query.toLowerCase());
|
|
152
|
-
if (idx < 0) return text;
|
|
153
|
-
return text.slice(0, idx) + bold(yellow(text.slice(idx, idx + query.length))) + text.slice(idx + query.length);
|
|
77
|
+
return last === 0 ? text : result + text.slice(last);
|
|
154
78
|
}
|
|
155
79
|
//#endregion
|
|
156
80
|
//#region src/cli/_usage.ts
|
|
@@ -175,31 +99,34 @@ function printUsage(hasInput) {
|
|
|
175
99
|
` ${bold("mdzilla")} ${dim("— Markdown browser for humans and agents")}`,
|
|
176
100
|
"",
|
|
177
101
|
`${bold("Usage:")}`,
|
|
178
|
-
` ${bin} ${cyan("<
|
|
179
|
-
` ${bin} ${cyan("<
|
|
180
|
-
` ${bin} ${cyan("
|
|
181
|
-
` ${bin} ${cyan("
|
|
182
|
-
|
|
102
|
+
` ${bin} ${cyan("<source>")} ${dim("Open docs in browser")}`,
|
|
103
|
+
` ${bin} ${cyan("<source>")} ${cyan("<path>")} ${dim("Render a specific page")}`,
|
|
104
|
+
` ${bin} ${cyan("<source>")} ${cyan("<query>")} ${dim("Search docs")}`,
|
|
105
|
+
` ${bin} ${cyan("<file.md>")} ${dim("Render a single markdown file")}`,
|
|
106
|
+
"",
|
|
107
|
+
`${bold("Sources:")}`,
|
|
108
|
+
` ${cyan("./docs")} Local directory`,
|
|
109
|
+
` ${cyan("gh:owner/repo")} GitHub repo (looks for docs/ directory)`,
|
|
110
|
+
` ${cyan("npm:package-name")} npm package docs`,
|
|
111
|
+
` ${cyan("https://example.com")} Remote docs via HTTP / llms.txt`,
|
|
183
112
|
"",
|
|
184
113
|
`${bold("Options:")}`,
|
|
185
114
|
` ${cyan("--export")} ${dim("<dir>")} Export docs to flat .md files`,
|
|
186
|
-
` ${cyan("--
|
|
187
|
-
` ${cyan("--plain")} Plain text output (no TUI)`,
|
|
188
|
-
` ${cyan("--headless")} Alias for --plain`,
|
|
115
|
+
` ${cyan("--plain")} Plain text output (auto-enabled for agents / non-TTY)`,
|
|
189
116
|
` ${cyan("-h, --help")} Show this help message`,
|
|
190
117
|
"",
|
|
191
|
-
`${bold("
|
|
192
|
-
` ${dim("
|
|
193
|
-
` ${
|
|
194
|
-
` ${
|
|
195
|
-
` ${
|
|
118
|
+
`${bold("Examples:")}`,
|
|
119
|
+
` ${bin} ${cyan("gh:unjs/h3")} ${dim("Open H3 docs in browser")}`,
|
|
120
|
+
` ${bin} ${cyan("gh:unjs/h3 /guide/basics")} ${dim("Render a specific page")}`,
|
|
121
|
+
` ${bin} ${cyan("gh:unjs/h3 router")} ${dim("Search for 'router'")}`,
|
|
122
|
+
` ${bin} ${cyan("gh:unjs/h3 --export ./h3-docs")} ${dim("Export to flat files")}`
|
|
196
123
|
].join("\n"));
|
|
197
124
|
process.exit(hasInput ? 0 : 1);
|
|
198
125
|
}
|
|
199
126
|
//#endregion
|
|
200
127
|
//#region src/cli/content.ts
|
|
201
|
-
async function renderContent(content, entry
|
|
202
|
-
const
|
|
128
|
+
async function renderContent(content, entry) {
|
|
129
|
+
const cols = process.stdout.columns || 80;
|
|
203
130
|
const codeBlocks = [];
|
|
204
131
|
const codeRe = /```(\w+)[^\n]*\n([\s\S]*?)```/g;
|
|
205
132
|
let m;
|
|
@@ -230,18 +157,27 @@ async function renderContent(content, entry, navWidth) {
|
|
|
230
157
|
if (hl) {
|
|
231
158
|
const hlLines = hl.split("\n");
|
|
232
159
|
if (hlLines.length > 0 && hlLines[hlLines.length - 1] === "") hlLines.pop();
|
|
233
|
-
for (const hlLine of hlLines) for (const w of wrapAnsi(" " + hlLine,
|
|
234
|
-
} else for (const dl of dimLines) for (const w of wrapAnsi(dl,
|
|
160
|
+
for (const hlLine of hlLines) for (const w of wrapAnsi(" " + hlLine, cols)) lines.push(w + "\x1B[0m");
|
|
161
|
+
} else for (const dl of dimLines) for (const w of wrapAnsi(dl, cols)) lines.push(w + "\x1B[0m");
|
|
235
162
|
blockIdx++;
|
|
236
163
|
lines.push("");
|
|
237
164
|
} else dimLines.push(rawLine);
|
|
238
165
|
continue;
|
|
239
166
|
}
|
|
240
|
-
for (const w of wrapAnsi(rawLine,
|
|
167
|
+
for (const w of wrapAnsi(rawLine, cols)) lines.push(w + "\x1B[0m");
|
|
241
168
|
}
|
|
242
169
|
return lines;
|
|
243
170
|
}
|
|
244
171
|
//#endregion
|
|
172
|
+
//#region src/cli/_utils.ts
|
|
173
|
+
function openInBrowser(url) {
|
|
174
|
+
const parsed = new URL(url);
|
|
175
|
+
if (parsed.hostname === "[::]" || parsed.hostname === "[::1]" || parsed.hostname === "127.0.0.1") parsed.hostname = "localhost";
|
|
176
|
+
url = parsed.href;
|
|
177
|
+
if (process.platform === "win32") exec(`start "" ${JSON.stringify(url)}`, () => {});
|
|
178
|
+
else execFile(process.platform === "darwin" ? "open" : "xdg-open", [url], () => {});
|
|
179
|
+
}
|
|
180
|
+
//#endregion
|
|
245
181
|
//#region src/cli/render.ts
|
|
246
182
|
async function singleFileMode(filePath, plain, isURL) {
|
|
247
183
|
const raw = isURL ? await fetch(filePath, { headers: { accept: "text/markdown, text/plain;q=0.9, text/html;q=0.8" } }).then((r) => r.text()) : await readFile(filePath, "utf8");
|
|
@@ -256,16 +192,16 @@ async function singleFileMode(filePath, plain, isURL) {
|
|
|
256
192
|
path: "/" + slug,
|
|
257
193
|
title: meta.title || slug,
|
|
258
194
|
order: 0
|
|
259
|
-
}
|
|
195
|
+
});
|
|
260
196
|
process.stdout.write(lines.join("\n") + "\n");
|
|
261
197
|
}
|
|
262
|
-
async function
|
|
263
|
-
const
|
|
264
|
-
const { entry, raw } = await docs.resolvePage(normalized);
|
|
198
|
+
async function renderPage(docs, resolved, pagePath, plain) {
|
|
199
|
+
const { entry, raw } = resolved;
|
|
265
200
|
if (!raw) {
|
|
266
|
-
|
|
201
|
+
printNotFound(docs, pagePath);
|
|
267
202
|
process.exit(1);
|
|
268
203
|
}
|
|
204
|
+
const normalized = pagePath.startsWith("/") ? pagePath : "/" + pagePath;
|
|
269
205
|
const slug = (entry?.entry.path || normalized).split("/").pop() || "";
|
|
270
206
|
const navEntry = entry?.entry || {
|
|
271
207
|
slug,
|
|
@@ -277,38 +213,81 @@ async function pageMode(docs, pagePath, plain) {
|
|
|
277
213
|
process.stdout.write(renderToText(raw) + "\n");
|
|
278
214
|
if (isAgent) process.stdout.write(agentTrailer(docs, normalized));
|
|
279
215
|
} else {
|
|
280
|
-
const lines = await renderContent(raw, navEntry
|
|
216
|
+
const lines = await renderContent(raw, navEntry);
|
|
281
217
|
process.stdout.write(lines.join("\n") + "\n");
|
|
282
218
|
}
|
|
283
219
|
}
|
|
284
|
-
async function
|
|
220
|
+
async function searchMode(docs, query, plain) {
|
|
221
|
+
let count = 0;
|
|
222
|
+
const matchedPaths = [];
|
|
223
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
224
|
+
for await (const { flat: f, titleMatch, heading } of docs.search(query)) {
|
|
225
|
+
if (count === 0) process.stdout.write(bold(`Search results for "${query}":\n\n`));
|
|
226
|
+
count++;
|
|
227
|
+
matchedPaths.push(f.entry.path);
|
|
228
|
+
const raw = await docs.getContent(f);
|
|
229
|
+
const plainText = raw ? renderToText(raw) : "";
|
|
230
|
+
const snippets = plainText ? extractSnippets(plainText, terms) : [];
|
|
231
|
+
if (plain) {
|
|
232
|
+
const desc = f.entry.description ? ` — ${f.entry.description}` : "";
|
|
233
|
+
const badge = titleMatch && snippets.length > 0 ? " (title + content)" : titleMatch ? " (title)" : heading ? ` (heading: ${heading})` : ` (${snippets.length} match${snippets.length !== 1 ? "es" : ""})`;
|
|
234
|
+
process.stdout.write(`- **${f.entry.title}** \`${f.entry.path}\`${desc}${badge}\n`);
|
|
235
|
+
for (const s of snippets) process.stdout.write(` > ${s}\n`);
|
|
236
|
+
} else {
|
|
237
|
+
const subtitle = heading ? ` › ${cyan(heading)}` : "";
|
|
238
|
+
process.stdout.write(`${bold(cyan(f.entry.title))}${subtitle} ${dim(f.entry.path)}\n`);
|
|
239
|
+
for (const s of snippets) process.stdout.write(` ${highlight(s, query)}\n`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (count === 0) {
|
|
243
|
+
console.log(`No results for "${query}".`);
|
|
244
|
+
const suggestions = docs.suggest(query);
|
|
245
|
+
if (suggestions.length > 0) {
|
|
246
|
+
console.log("");
|
|
247
|
+
console.log(plain ? "Related pages:" : bold("Related pages:"));
|
|
248
|
+
for (const s of suggestions) if (plain) console.log(` - [${s.entry.title}](${s.entry.path})`);
|
|
249
|
+
else console.log(` ${bold(cyan(s.entry.title))} ${dim(s.entry.path)}`);
|
|
250
|
+
if (isAgent) {
|
|
251
|
+
console.log("");
|
|
252
|
+
console.log("To read a specific page, run this command again with the page path as second argument.");
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
} else if (isAgent) process.stdout.write([
|
|
256
|
+
"",
|
|
257
|
+
"---",
|
|
258
|
+
"",
|
|
259
|
+
`Found ${count} page${count > 1 ? "s" : ""} matching "${query}".`,
|
|
260
|
+
"To read a specific page, run this command again with the page path as second argument, for example:",
|
|
261
|
+
...[...new Set(matchedPaths)].slice(0, 3).map((p) => ` mdzilla <source> ${p}`),
|
|
262
|
+
""
|
|
263
|
+
].join("\n"));
|
|
264
|
+
}
|
|
265
|
+
async function tocMode(docs) {
|
|
285
266
|
const navigable = docs.pages;
|
|
286
267
|
if (navigable.length === 0) {
|
|
287
268
|
console.log("No pages found.");
|
|
288
269
|
return;
|
|
289
270
|
}
|
|
290
|
-
if (isAgent && pagePath) {
|
|
291
|
-
const normalized = pagePath.startsWith("/") ? pagePath : "/" + pagePath;
|
|
292
|
-
const resolved = await docs.resolvePage(normalized);
|
|
293
|
-
if (resolved.raw) process.stdout.write(renderToText(resolved.raw) + "\n");
|
|
294
|
-
else process.stdout.write(`Page not found: ${pagePath}\n`);
|
|
295
|
-
process.stdout.write(agentTrailer(docs, normalized));
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
271
|
const tocLines = ["Table of Contents", ""];
|
|
299
272
|
for (const f of navigable) {
|
|
300
273
|
const indent = " ".repeat(f.depth);
|
|
301
274
|
tocLines.push(`${indent}- [${f.entry.title}](${f.entry.path})`);
|
|
302
275
|
}
|
|
303
276
|
process.stdout.write(tocLines.join("\n") + "\n");
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
}
|
|
311
|
-
|
|
277
|
+
const raw = await docs.getContent(navigable[0]);
|
|
278
|
+
if (raw) process.stdout.write("\n" + renderToText(raw) + "\n");
|
|
279
|
+
if (isAgent && navigable.length > 1) process.stdout.write("\n---\n\nTo read a specific page, run this command again with the page path as second argument.\nTo search within pages, pass a search query as second argument.\n");
|
|
280
|
+
}
|
|
281
|
+
async function serverMode(source, _docs) {
|
|
282
|
+
const { serve } = await import("srvx");
|
|
283
|
+
const { createDocsServer } = await import("../_chunks/server.mjs");
|
|
284
|
+
const server = serve({
|
|
285
|
+
fetch: (await createDocsServer({ source })).fetch,
|
|
286
|
+
gracefulShutdown: false
|
|
287
|
+
});
|
|
288
|
+
await server.ready();
|
|
289
|
+
await server.fetch(new Request(new URL("/api/meta", server.url)));
|
|
290
|
+
openInBrowser(server.url);
|
|
312
291
|
}
|
|
313
292
|
function agentTrailer(docs, currentPath) {
|
|
314
293
|
const pages = docs.pages;
|
|
@@ -322,446 +301,19 @@ function agentTrailer(docs, currentPath) {
|
|
|
322
301
|
"Other available pages:",
|
|
323
302
|
...otherPages.map((p) => ` - [${p.entry.title}](${p.entry.path})`),
|
|
324
303
|
"",
|
|
325
|
-
"To read a specific page, run this command again with
|
|
326
|
-
"To
|
|
304
|
+
"To read a specific page, run this command again with the page path as second argument.",
|
|
305
|
+
"To search within pages, pass a search query as second argument.",
|
|
327
306
|
""
|
|
328
307
|
].join("\n");
|
|
329
308
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
if (process.platform === "win32") exec(`start "" ${JSON.stringify(url)}`, () => {});
|
|
337
|
-
else execFile(process.platform === "darwin" ? "open" : "xdg-open", [url], () => {});
|
|
338
|
-
}
|
|
339
|
-
//#endregion
|
|
340
|
-
//#region src/cli/interactive/nav.ts
|
|
341
|
-
function calcNavWidth(flat) {
|
|
342
|
-
const cols = process.stdout.columns || 80;
|
|
343
|
-
let max = 6;
|
|
344
|
-
for (const { entry, depth } of flat) {
|
|
345
|
-
const w = depth === 0 ? 3 + entry.title.length : 2 + 2 * depth + entry.title.length;
|
|
346
|
-
if (w > max) max = w;
|
|
347
|
-
}
|
|
348
|
-
return Math.min(max + 2, Math.floor(cols * .2), 56);
|
|
349
|
-
}
|
|
350
|
-
function renderNavPanel(flat, cursor, maxRows, width, search, searchMatches) {
|
|
351
|
-
const lines = [];
|
|
352
|
-
lines.push(search !== void 0 ? bold(" mdzilla") + " " + dim("/") + search + "▌" : bold(" mdzilla"));
|
|
353
|
-
lines.push("");
|
|
354
|
-
const listRows = maxRows - 2;
|
|
355
|
-
let start = 0;
|
|
356
|
-
if (flat.length > listRows) start = Math.max(0, Math.min(cursor - Math.floor(listRows / 2), flat.length - listRows));
|
|
357
|
-
const end = Math.min(start + listRows, flat.length);
|
|
358
|
-
const treePrefixes = computeTreePrefixes(flat, computeIsLastChild(flat));
|
|
359
|
-
for (let i = start; i < end; i++) {
|
|
360
|
-
const { entry, depth } = flat[i];
|
|
361
|
-
const isPage = entry.page !== false;
|
|
362
|
-
const active = i === cursor;
|
|
363
|
-
const maxTitle = width - (depth === 0 ? 3 : 2 + 2 * depth) - 1;
|
|
364
|
-
let displayTitle = entry.title;
|
|
365
|
-
if (displayTitle.length > maxTitle && maxTitle > 1) displayTitle = displayTitle.slice(0, maxTitle - 1) + "…";
|
|
366
|
-
const isMatch = searchMatches ? searchMatches.has(i) : false;
|
|
367
|
-
const title = search ? highlight(displayTitle, search) : displayTitle;
|
|
368
|
-
let label;
|
|
369
|
-
if (depth === 0) label = `${isPage ? dim("◆") : dim("◇")} ${search ? isMatch ? title : dim(displayTitle) : active || isPage ? title : dim(title)}`;
|
|
370
|
-
else label = ` ${dim(treePrefixes[i])}${search ? isMatch ? title : dim(displayTitle) : active || isPage ? title : dim(title)}`;
|
|
371
|
-
lines.push(active ? bgGray(padTo(label, width)) : label);
|
|
372
|
-
}
|
|
373
|
-
if (flat.length > listRows) lines.push(dim(` ${start > 0 ? "↑" : " "} ${end < flat.length ? "↓" : " "} ${cursor + 1}/${flat.length}`));
|
|
374
|
-
return lines;
|
|
375
|
-
}
|
|
376
|
-
function computeIsLastChild(flat) {
|
|
377
|
-
const result = Array.from({ length: flat.length });
|
|
378
|
-
for (let i = 0; i < flat.length; i++) {
|
|
379
|
-
const d = flat[i].depth;
|
|
380
|
-
let last = true;
|
|
381
|
-
for (let j = i + 1; j < flat.length; j++) {
|
|
382
|
-
if (flat[j].depth < d) break;
|
|
383
|
-
if (flat[j].depth === d) {
|
|
384
|
-
last = false;
|
|
385
|
-
break;
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
result[i] = last;
|
|
389
|
-
}
|
|
390
|
-
return result;
|
|
391
|
-
}
|
|
392
|
-
function computeTreePrefixes(flat, isLast) {
|
|
393
|
-
const prefixes = Array.from({ length: flat.length });
|
|
394
|
-
const continues = [];
|
|
395
|
-
for (let i = 0; i < flat.length; i++) {
|
|
396
|
-
const d = flat[i].depth;
|
|
397
|
-
if (d === 0) {
|
|
398
|
-
prefixes[i] = "";
|
|
399
|
-
continues.length = 1;
|
|
400
|
-
continues[0] = !isLast[i];
|
|
401
|
-
continue;
|
|
402
|
-
}
|
|
403
|
-
let prefix = "";
|
|
404
|
-
for (let level = 1; level < d; level++) prefix += continues[level] ? "│ " : " ";
|
|
405
|
-
prefix += isLast[i] ? "╰─" : "├─";
|
|
406
|
-
prefixes[i] = prefix;
|
|
407
|
-
continues[d] = !isLast[i];
|
|
408
|
-
continues.length = d + 1;
|
|
409
|
-
}
|
|
410
|
-
return prefixes;
|
|
411
|
-
}
|
|
412
|
-
//#endregion
|
|
413
|
-
//#region src/cli/interactive/render.ts
|
|
414
|
-
function renderSplit(flat, cursor, contentLines, contentScroll, search, focus, contentSearch, searchMatches, sidebarVisible = true) {
|
|
415
|
-
const rows = process.stdout.rows || 24;
|
|
416
|
-
const navWidth = sidebarVisible ? calcNavWidth(flat) : 0;
|
|
417
|
-
const bodyRows = rows - 1;
|
|
418
|
-
const navLines = sidebarVisible ? renderNavPanel(flat, cursor, bodyRows, navWidth, search, searchMatches) : [];
|
|
419
|
-
const rawRight = contentLines.slice(contentScroll, contentScroll + bodyRows);
|
|
420
|
-
const rightLines = contentSearch ? rawRight.map((l) => highlightAnsi(l, contentSearch)) : rawRight;
|
|
421
|
-
const cols = process.stdout.columns || 80;
|
|
422
|
-
const contentWidth = sidebarVisible ? cols - navWidth - 3 : cols - 2;
|
|
423
|
-
const isFocusContent = focus === "content" || focus === "content-search";
|
|
424
|
-
const reset = "\x1B[0m";
|
|
425
|
-
const output = [];
|
|
426
|
-
for (let i = 0; i < bodyRows; i++) {
|
|
427
|
-
const right = rightLines[i] || "";
|
|
428
|
-
if (sidebarVisible) output.push(reset + padTo(navLines[i] || "", navWidth) + reset + (isFocusContent ? cyan("│") : dim("│")) + reset + " " + truncateTo(right, contentWidth) + reset);
|
|
429
|
-
else output.push(reset + " " + truncateTo(right, contentWidth) + reset);
|
|
430
|
-
}
|
|
431
|
-
output.push(search !== void 0 ? dim(" esc") + " cancel " + dim("enter") + " go" : focus === "content-search" ? dim(" /") + contentSearch + "▌ " + dim("esc") + " cancel " + dim("enter") + " confirm" : focus === "content" ? dim(" ↑↓") + " scroll " + dim("tab") + " links " + dim("⏎") + " open " + dim("/") + " search" + (contentSearch ? " " + dim("n") + "/" + dim("N") + " next/prev" : "") + " " + dim("t") + " sidebar " + dim("⌫") + " back " + dim("q") + " quit" : dim(" ↑↓") + " navigate " + dim("⏎") + " read " + dim("space") + " page ↓ " + dim("/") + " search " + dim("t") + " sidebar " + dim("q") + " quit");
|
|
432
|
-
const eol = "\x1B[K";
|
|
433
|
-
return output.map((l) => l + eol).join("\n");
|
|
434
|
-
}
|
|
435
|
-
//#endregion
|
|
436
|
-
//#region src/cli/interactive/index.ts
|
|
437
|
-
async function interactiveMode(docs) {
|
|
438
|
-
const flat = docs.flat;
|
|
439
|
-
if (flat.length === 0) {
|
|
440
|
-
console.log("No pages found.");
|
|
441
|
-
process.exit(0);
|
|
442
|
-
}
|
|
443
|
-
const isNavigable = (list, i) => list[i]?.entry.page !== false;
|
|
444
|
-
const nextNavigable = (list, from, dir) => {
|
|
445
|
-
let i = from + dir;
|
|
446
|
-
while (i >= 0 && i < list.length) {
|
|
447
|
-
if (isNavigable(list, i)) return i;
|
|
448
|
-
i += dir;
|
|
449
|
-
}
|
|
450
|
-
return from;
|
|
451
|
-
};
|
|
452
|
-
const firstNavigable = (list) => {
|
|
453
|
-
for (let i = 0; i < list.length; i++) if (isNavigable(list, i)) return i;
|
|
454
|
-
return 0;
|
|
455
|
-
};
|
|
456
|
-
let cursor = firstNavigable(flat);
|
|
457
|
-
let searching = false;
|
|
458
|
-
let searchQuery = "";
|
|
459
|
-
let searchMatches = [];
|
|
460
|
-
let contentScroll = 0;
|
|
461
|
-
let contentLines = [];
|
|
462
|
-
let loadedPath = "";
|
|
463
|
-
let focusContent = false;
|
|
464
|
-
let contentSearching = false;
|
|
465
|
-
let contentSearchQuery = "";
|
|
466
|
-
let contentMatches = [];
|
|
467
|
-
let contentMatchIdx = 0;
|
|
468
|
-
let sidebarVisible = true;
|
|
469
|
-
let contentLinks = [];
|
|
470
|
-
let linkIdx = -1;
|
|
471
|
-
const extractLinks = (lines) => {
|
|
472
|
-
const links = [];
|
|
473
|
-
const re = /\x1B\]8;;([^\x07\x1B]+?)(?:\x07|\x1B\\)/g;
|
|
474
|
-
for (let i = 0; i < lines.length; i++) {
|
|
475
|
-
re.lastIndex = 0;
|
|
476
|
-
let m;
|
|
477
|
-
let occ = 0;
|
|
478
|
-
while ((m = re.exec(lines[i])) !== null) links.push({
|
|
479
|
-
line: i,
|
|
480
|
-
url: m[1],
|
|
481
|
-
occurrence: occ++
|
|
482
|
-
});
|
|
483
|
-
}
|
|
484
|
-
return links;
|
|
485
|
-
};
|
|
486
|
-
const highlightLinkOnLine = (line, occurrence) => {
|
|
487
|
-
const oscRe = /\x1B\]8;;([^\x07\x1B]*?)(?:\x07|\x1B\\)/g;
|
|
488
|
-
let occ = 0;
|
|
489
|
-
let m;
|
|
490
|
-
while ((m = oscRe.exec(line)) !== null) {
|
|
491
|
-
if (!m[1]) continue;
|
|
492
|
-
if (occ === occurrence) {
|
|
493
|
-
const openerEnd = m.index + m[0].length;
|
|
494
|
-
const closer = oscRe.exec(line);
|
|
495
|
-
if (!closer) break;
|
|
496
|
-
return line.slice(0, openerEnd) + "\x1B[7m" + line.slice(openerEnd, closer.index) + "\x1B[27m" + line.slice(closer.index);
|
|
497
|
-
}
|
|
498
|
-
occ++;
|
|
499
|
-
}
|
|
500
|
-
return line;
|
|
501
|
-
};
|
|
502
|
-
const findContentMatches = (query) => {
|
|
503
|
-
if (!query) return [];
|
|
504
|
-
const lower = query.toLowerCase();
|
|
505
|
-
const matches = [];
|
|
506
|
-
for (let i = 0; i < contentLines.length; i++) if (stripAnsi(contentLines[i]).toLowerCase().includes(lower)) matches.push(i);
|
|
507
|
-
return matches;
|
|
508
|
-
};
|
|
509
|
-
const scrollToMatch = () => {
|
|
510
|
-
if (contentMatches.length === 0) return;
|
|
511
|
-
const rows = process.stdout.rows || 24;
|
|
512
|
-
const line = contentMatches[contentMatchIdx];
|
|
513
|
-
contentScroll = Math.max(0, Math.min(line - Math.floor(rows / 2), contentLines.length - rows + 2));
|
|
514
|
-
};
|
|
515
|
-
process.stdin.setRawMode(true);
|
|
516
|
-
process.stdin.resume();
|
|
517
|
-
enterAltScreen();
|
|
518
|
-
hideCursor();
|
|
519
|
-
const draw = () => {
|
|
520
|
-
let displayLines = contentLines;
|
|
521
|
-
if (linkIdx >= 0 && linkIdx < contentLinks.length) {
|
|
522
|
-
const link = contentLinks[linkIdx];
|
|
523
|
-
displayLines = [...contentLines];
|
|
524
|
-
displayLines[link.line] = highlightLinkOnLine(displayLines[link.line], link.occurrence);
|
|
525
|
-
}
|
|
526
|
-
const frame = renderSplit(flat, cursor, displayLines, contentScroll, searching ? searchQuery : void 0, contentSearching ? "content-search" : focusContent ? "content" : "nav", contentSearching ? contentSearchQuery : contentSearchQuery || void 0, searching ? new Set(searchMatches) : void 0, sidebarVisible);
|
|
527
|
-
process.stdout.write(`\x1B[H${frame}`);
|
|
528
|
-
};
|
|
529
|
-
const loadContent = (entry) => {
|
|
530
|
-
if (!entry?.filePath || entry.entry.page === false) {
|
|
531
|
-
if (loadedPath !== "") {
|
|
532
|
-
contentLines = [];
|
|
533
|
-
contentScroll = 0;
|
|
534
|
-
loadedPath = "";
|
|
535
|
-
draw();
|
|
536
|
-
}
|
|
537
|
-
return;
|
|
538
|
-
}
|
|
539
|
-
if (entry.filePath === loadedPath) return;
|
|
540
|
-
const targetPath = entry.filePath;
|
|
541
|
-
docs.getContent(entry).then(async (raw) => {
|
|
542
|
-
if (!raw || flat[cursor]?.filePath !== targetPath) return;
|
|
543
|
-
contentLines = await renderContent(raw, entry.entry, sidebarVisible ? calcNavWidth(flat) : 0);
|
|
544
|
-
contentScroll = 0;
|
|
545
|
-
contentSearchQuery = "";
|
|
546
|
-
contentMatches = [];
|
|
547
|
-
contentLinks = extractLinks(contentLines);
|
|
548
|
-
linkIdx = -1;
|
|
549
|
-
loadedPath = targetPath;
|
|
550
|
-
draw();
|
|
551
|
-
});
|
|
552
|
-
};
|
|
553
|
-
let cleaned = false;
|
|
554
|
-
const cleanup = (code = 0) => {
|
|
555
|
-
if (cleaned) return;
|
|
556
|
-
cleaned = true;
|
|
557
|
-
showCursor();
|
|
558
|
-
leaveAltScreen();
|
|
559
|
-
process.exit(code);
|
|
560
|
-
};
|
|
561
|
-
const reloadContent = () => {
|
|
562
|
-
const entry = flat[cursor];
|
|
563
|
-
if (!entry?.filePath || entry.entry.page === false) return;
|
|
564
|
-
docs.invalidate(entry.filePath);
|
|
565
|
-
loadedPath = "";
|
|
566
|
-
loadContent(entry);
|
|
567
|
-
};
|
|
568
|
-
process.on("SIGINT", () => cleanup());
|
|
569
|
-
process.on("SIGTERM", () => cleanup());
|
|
570
|
-
process.on("uncaughtException", (err) => {
|
|
571
|
-
if (cleaned) return;
|
|
572
|
-
cleaned = true;
|
|
573
|
-
showCursor();
|
|
574
|
-
leaveAltScreen();
|
|
575
|
-
console.error(err);
|
|
576
|
-
process.exit(1);
|
|
577
|
-
});
|
|
578
|
-
process.on("unhandledRejection", (err) => {
|
|
579
|
-
if (cleaned) return;
|
|
580
|
-
cleaned = true;
|
|
581
|
-
showCursor();
|
|
582
|
-
leaveAltScreen();
|
|
583
|
-
console.error(err);
|
|
584
|
-
process.exit(1);
|
|
585
|
-
});
|
|
586
|
-
process.stdout.on("resize", () => {
|
|
587
|
-
reloadContent();
|
|
588
|
-
draw();
|
|
589
|
-
});
|
|
590
|
-
clear();
|
|
591
|
-
draw();
|
|
592
|
-
loadContent(flat[cursor]);
|
|
593
|
-
process.stdin.on("data", (data) => {
|
|
594
|
-
const key = data.toString();
|
|
595
|
-
if (key === "") return cleanup();
|
|
596
|
-
if (searching) handleSearch(key);
|
|
597
|
-
else if (contentSearching) handleContentSearch(key);
|
|
598
|
-
else if (focusContent) handleContent(key);
|
|
599
|
-
else handleNav(key);
|
|
600
|
-
draw();
|
|
601
|
-
});
|
|
602
|
-
function handleNav(key) {
|
|
603
|
-
const rows = process.stdout.rows || 24;
|
|
604
|
-
const maxScroll = Math.max(0, contentLines.length - rows + 2);
|
|
605
|
-
if (key === "q") return cleanup();
|
|
606
|
-
if (key === "/") {
|
|
607
|
-
searching = true;
|
|
608
|
-
searchQuery = "";
|
|
609
|
-
searchMatches = [];
|
|
610
|
-
showCursor();
|
|
611
|
-
} else if (key === "\x1B[A" || key === "k") {
|
|
612
|
-
cursor = nextNavigable(flat, cursor, -1);
|
|
613
|
-
loadContent(flat[cursor]);
|
|
614
|
-
} else if (key === "\x1B[B" || key === "j") {
|
|
615
|
-
cursor = nextNavigable(flat, cursor, 1);
|
|
616
|
-
loadContent(flat[cursor]);
|
|
617
|
-
} else if (key === "\r" || key === "\n" || key === " " || key === "\x1B[C") {
|
|
618
|
-
if (contentLines.length > 0) focusContent = true;
|
|
619
|
-
} else if (key === " " || key === "\x1B[6~") contentScroll = Math.min(maxScroll, contentScroll + rows - 2);
|
|
620
|
-
else if (key === "b" || key === "\x1B[5~") contentScroll = Math.max(0, contentScroll - rows + 2);
|
|
621
|
-
else if (key === "g") {
|
|
622
|
-
cursor = firstNavigable(flat);
|
|
623
|
-
loadContent(flat[cursor]);
|
|
624
|
-
} else if (key === "G") {
|
|
625
|
-
for (let i = flat.length - 1; i >= 0; i--) if (isNavigable(flat, i)) {
|
|
626
|
-
cursor = i;
|
|
627
|
-
break;
|
|
628
|
-
}
|
|
629
|
-
loadContent(flat[cursor]);
|
|
630
|
-
} else if (key === "t") {
|
|
631
|
-
sidebarVisible = !sidebarVisible;
|
|
632
|
-
reloadContent();
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
const scrollToLink = () => {
|
|
636
|
-
if (linkIdx < 0 || linkIdx >= contentLinks.length) return;
|
|
637
|
-
const rows = process.stdout.rows || 24;
|
|
638
|
-
const line = contentLinks[linkIdx].line;
|
|
639
|
-
if (line < contentScroll || line >= contentScroll + rows - 2) contentScroll = Math.max(0, Math.min(line - Math.floor(rows / 3), contentLines.length - rows + 2));
|
|
640
|
-
};
|
|
641
|
-
const activateLink = (url) => {
|
|
642
|
-
if (url.startsWith("http://") || url.startsWith("https://")) openInBrowser(url);
|
|
643
|
-
else {
|
|
644
|
-
const target = url.replace(/^\.\//, "/").replace(/\/$/, "");
|
|
645
|
-
const idx = flat.indexOf(docs.findByPath(target));
|
|
646
|
-
if (idx >= 0) {
|
|
647
|
-
cursor = idx;
|
|
648
|
-
focusContent = false;
|
|
649
|
-
linkIdx = -1;
|
|
650
|
-
loadContent(flat[cursor]);
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
};
|
|
654
|
-
function handleContent(key) {
|
|
655
|
-
const rows = process.stdout.rows || 24;
|
|
656
|
-
const maxScroll = Math.max(0, contentLines.length - rows + 2);
|
|
657
|
-
if (key === "" || key === "\b" || key === "\x1B[D" || key === "\x1B") {
|
|
658
|
-
focusContent = false;
|
|
659
|
-
linkIdx = -1;
|
|
660
|
-
contentSearchQuery = "";
|
|
661
|
-
contentMatches = [];
|
|
662
|
-
} else if (key === " ") {
|
|
663
|
-
if (contentLinks.length > 0) {
|
|
664
|
-
linkIdx = linkIdx < contentLinks.length - 1 ? linkIdx + 1 : 0;
|
|
665
|
-
scrollToLink();
|
|
666
|
-
}
|
|
667
|
-
} else if (key === "\x1B[Z") {
|
|
668
|
-
if (contentLinks.length > 0) {
|
|
669
|
-
linkIdx = linkIdx > 0 ? linkIdx - 1 : contentLinks.length - 1;
|
|
670
|
-
scrollToLink();
|
|
671
|
-
}
|
|
672
|
-
} else if ((key === "\r" || key === "\n") && linkIdx >= 0 && linkIdx < contentLinks.length) activateLink(contentLinks[linkIdx].url);
|
|
673
|
-
else if (key === "\x1B[A" || key === "k") contentScroll = Math.max(0, contentScroll - 1);
|
|
674
|
-
else if (key === "\x1B[B" || key === "j") contentScroll = Math.min(maxScroll, contentScroll + 1);
|
|
675
|
-
else if (key === " " || key === "\x1B[6~") contentScroll = Math.min(maxScroll, contentScroll + rows - 2);
|
|
676
|
-
else if (key === "b" || key === "\x1B[5~") contentScroll = Math.max(0, contentScroll - rows + 2);
|
|
677
|
-
else if (key === "/") {
|
|
678
|
-
contentSearching = true;
|
|
679
|
-
contentSearchQuery = "";
|
|
680
|
-
contentMatches = [];
|
|
681
|
-
contentMatchIdx = 0;
|
|
682
|
-
showCursor();
|
|
683
|
-
} else if (key === "n" && contentMatches.length > 0) {
|
|
684
|
-
contentMatchIdx = (contentMatchIdx + 1) % contentMatches.length;
|
|
685
|
-
scrollToMatch();
|
|
686
|
-
} else if (key === "N" && contentMatches.length > 0) {
|
|
687
|
-
contentMatchIdx = (contentMatchIdx - 1 + contentMatches.length) % contentMatches.length;
|
|
688
|
-
scrollToMatch();
|
|
689
|
-
} else if (key === "g") contentScroll = 0;
|
|
690
|
-
else if (key === "G") contentScroll = maxScroll;
|
|
691
|
-
else if (key === "t") {
|
|
692
|
-
sidebarVisible = !sidebarVisible;
|
|
693
|
-
reloadContent();
|
|
694
|
-
} else if (key === "q") return cleanup();
|
|
695
|
-
}
|
|
696
|
-
function handleContentSearch(key) {
|
|
697
|
-
if (key === "\x1B") {
|
|
698
|
-
contentSearching = false;
|
|
699
|
-
contentSearchQuery = "";
|
|
700
|
-
contentMatches = [];
|
|
701
|
-
hideCursor();
|
|
702
|
-
} else if (key === "\r" || key === "\n") {
|
|
703
|
-
contentSearching = false;
|
|
704
|
-
hideCursor();
|
|
705
|
-
} else if (key === "" || key === "\b") {
|
|
706
|
-
contentSearchQuery = contentSearchQuery.slice(0, -1);
|
|
707
|
-
contentMatches = findContentMatches(contentSearchQuery);
|
|
708
|
-
contentMatchIdx = 0;
|
|
709
|
-
scrollToMatch();
|
|
710
|
-
} else if (key === "\x1B[A") {
|
|
711
|
-
if (contentMatches.length > 0) {
|
|
712
|
-
contentMatchIdx = (contentMatchIdx - 1 + contentMatches.length) % contentMatches.length;
|
|
713
|
-
scrollToMatch();
|
|
714
|
-
}
|
|
715
|
-
} else if (key === "\x1B[B") {
|
|
716
|
-
if (contentMatches.length > 0) {
|
|
717
|
-
contentMatchIdx = (contentMatchIdx + 1) % contentMatches.length;
|
|
718
|
-
scrollToMatch();
|
|
719
|
-
}
|
|
720
|
-
} else if (key.length === 1 && key >= " ") {
|
|
721
|
-
contentSearchQuery += key;
|
|
722
|
-
contentMatches = findContentMatches(contentSearchQuery);
|
|
723
|
-
contentMatchIdx = 0;
|
|
724
|
-
scrollToMatch();
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
function updateSearchMatches() {
|
|
728
|
-
searchMatches = docs.matchIndices(searchQuery);
|
|
729
|
-
if (searchMatches.length > 0) cursor = searchMatches[0];
|
|
730
|
-
else if (!searchQuery) cursor = firstNavigable(flat);
|
|
731
|
-
loadContent(flat[cursor]);
|
|
732
|
-
}
|
|
733
|
-
function nextSearchMatch(dir) {
|
|
734
|
-
if (searchMatches.length === 0) return;
|
|
735
|
-
const curIdx = searchMatches.indexOf(cursor);
|
|
736
|
-
if (curIdx < 0) cursor = searchMatches[0];
|
|
737
|
-
else {
|
|
738
|
-
const next = curIdx + dir;
|
|
739
|
-
cursor = searchMatches[(next + searchMatches.length) % searchMatches.length];
|
|
740
|
-
}
|
|
741
|
-
loadContent(flat[cursor]);
|
|
742
|
-
}
|
|
743
|
-
function handleSearch(key) {
|
|
744
|
-
if (key === "\x1B" || key === "\x1B[D") {
|
|
745
|
-
searching = false;
|
|
746
|
-
searchMatches = [];
|
|
747
|
-
cursor = firstNavigable(flat);
|
|
748
|
-
hideCursor();
|
|
749
|
-
loadContent(flat[cursor]);
|
|
750
|
-
} else if (key === "\r" || key === "\n") {
|
|
751
|
-
searching = false;
|
|
752
|
-
searchMatches = [];
|
|
753
|
-
hideCursor();
|
|
754
|
-
loadContent(flat[cursor]);
|
|
755
|
-
} else if (key === "" || key === "\b") {
|
|
756
|
-
searchQuery = searchQuery.slice(0, -1);
|
|
757
|
-
updateSearchMatches();
|
|
758
|
-
} else if (key === "\x1B[A") nextSearchMatch(-1);
|
|
759
|
-
else if (key === "\x1B[B") nextSearchMatch(1);
|
|
760
|
-
else if (key.length === 1 && key >= " ") {
|
|
761
|
-
searchQuery += key;
|
|
762
|
-
updateSearchMatches();
|
|
763
|
-
}
|
|
309
|
+
function printNotFound(docs, pagePath) {
|
|
310
|
+
process.stderr.write(`Page not found: ${pagePath}\n`);
|
|
311
|
+
const suggestions = docs.suggest(pagePath);
|
|
312
|
+
if (suggestions.length > 0) {
|
|
313
|
+
process.stderr.write("\nDid you mean:\n");
|
|
314
|
+
for (const s of suggestions) process.stderr.write(` - ${s.entry.title} (${s.entry.path})\n`);
|
|
764
315
|
}
|
|
316
|
+
process.stderr.write("\nTo search within pages, pass a search query as second argument.\n");
|
|
765
317
|
}
|
|
766
318
|
//#endregion
|
|
767
319
|
//#region src/cli/main.ts
|
|
@@ -772,62 +324,55 @@ async function main() {
|
|
|
772
324
|
});
|
|
773
325
|
const { values, positionals } = parseArgs({
|
|
774
326
|
args: process.argv.slice(2),
|
|
327
|
+
allowPositionals: true,
|
|
775
328
|
options: {
|
|
776
329
|
help: {
|
|
777
330
|
type: "boolean",
|
|
778
331
|
short: "h"
|
|
779
332
|
},
|
|
780
333
|
export: { type: "string" },
|
|
781
|
-
page: {
|
|
782
|
-
type: "string",
|
|
783
|
-
short: "p"
|
|
784
|
-
},
|
|
785
334
|
plain: {
|
|
786
335
|
type: "boolean",
|
|
787
336
|
default: isAgent || !process.stdout.isTTY
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
tui: { type: "boolean" }
|
|
791
|
-
},
|
|
792
|
-
allowPositionals: true
|
|
337
|
+
}
|
|
338
|
+
}
|
|
793
339
|
});
|
|
794
|
-
const
|
|
795
|
-
const
|
|
796
|
-
const plain = values.plain ||
|
|
797
|
-
if (values.help || !
|
|
798
|
-
const isURL =
|
|
799
|
-
if (
|
|
800
|
-
const source =
|
|
801
|
-
const docs = new
|
|
340
|
+
const input = positionals[0];
|
|
341
|
+
const query = positionals[1];
|
|
342
|
+
const plain = values.plain || input?.startsWith("npm:") || false;
|
|
343
|
+
if (values.help || !input) return printUsage(!!input);
|
|
344
|
+
const isURL = input.startsWith("http://") || input.startsWith("https://");
|
|
345
|
+
if (input.endsWith(".md")) return singleFileMode(input, plain, isURL);
|
|
346
|
+
const source = resolveSource(input);
|
|
347
|
+
const docs = new Collection(source);
|
|
802
348
|
await docs.load();
|
|
803
|
-
if (
|
|
804
|
-
await
|
|
805
|
-
console.log(`Exported ${docs.pages.length} pages to ${
|
|
349
|
+
if (values.export) {
|
|
350
|
+
await writeCollection(docs, values.export, { plainText: plain });
|
|
351
|
+
console.log(`Exported ${docs.pages.length} pages to ${values.export}`);
|
|
806
352
|
return;
|
|
807
353
|
}
|
|
808
|
-
let
|
|
809
|
-
if (!
|
|
810
|
-
const urlPath = new URL(
|
|
811
|
-
if (urlPath && urlPath !== "/")
|
|
354
|
+
let resolvedQuery = query;
|
|
355
|
+
if (!resolvedQuery && isURL) {
|
|
356
|
+
const urlPath = new URL(input).pathname.replace(/\/+$/, "");
|
|
357
|
+
if (urlPath && urlPath !== "/") resolvedQuery = urlPath;
|
|
812
358
|
}
|
|
813
|
-
if (
|
|
814
|
-
if (plain) return
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
359
|
+
if (resolvedQuery) return smartResolve(docs, resolvedQuery, plain);
|
|
360
|
+
if (plain) return tocMode(docs);
|
|
361
|
+
return serverMode(source, docs);
|
|
362
|
+
}
|
|
363
|
+
async function smartResolve(docs, query, plain) {
|
|
364
|
+
const normalized = query.startsWith("/") ? query : "/" + query;
|
|
365
|
+
const resolved = await docs.resolvePage(normalized);
|
|
366
|
+
if (resolved.raw) return renderPage(docs, resolved, normalized, plain);
|
|
367
|
+
const fuzzy = docs.filter(query);
|
|
368
|
+
if (fuzzy.length === 1 && fuzzy[0].entry.page !== false) {
|
|
369
|
+
const match = fuzzy[0];
|
|
370
|
+
const page = await docs.resolvePage(match.entry.path);
|
|
371
|
+
if (page.raw) return renderPage(docs, page, match.entry.path, plain);
|
|
826
372
|
}
|
|
373
|
+
return searchMode(docs, query, plain);
|
|
827
374
|
}
|
|
828
375
|
main().catch((err) => {
|
|
829
|
-
showCursor();
|
|
830
|
-
leaveAltScreen();
|
|
831
376
|
console.error(err);
|
|
832
377
|
process.exit(1);
|
|
833
378
|
});
|