repoview 0.1.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/src/server.js ADDED
@@ -0,0 +1,487 @@
1
+ import fs from "node:fs/promises";
2
+ import http from "node:http";
3
+ import path from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import express from "express";
6
+ import chokidar from "chokidar";
7
+ import mime from "mime-types";
8
+
9
+ import { createMarkdownRenderer } from "./markdown.js";
10
+ import { loadGitIgnoreMatcher } from "./gitignore.js";
11
+ import { createRepoLinkScanner } from "./linkcheck.js";
12
+ import {
13
+ renderBrokenLinksPage,
14
+ renderErrorPage,
15
+ renderFilePage,
16
+ renderTreePage,
17
+ } from "./views.js";
18
+
19
+ function toPosixPath(p) {
20
+ return p.split(path.sep).join("/");
21
+ }
22
+
23
+ function encodePathForUrl(posixPath) {
24
+ return posixPath
25
+ .split("/")
26
+ .map((segment) => encodeURIComponent(segment))
27
+ .join("/");
28
+ }
29
+
30
+ function isWithinRoot(rootReal, candidateReal) {
31
+ if (candidateReal === rootReal) return true;
32
+ const rootWithSep = rootReal.endsWith(path.sep) ? rootReal : rootReal + path.sep;
33
+ return candidateReal.startsWith(rootWithSep);
34
+ }
35
+
36
+ async function getGitInfo(repoRootReal) {
37
+ const gitDir = path.join(repoRootReal, ".git");
38
+ try {
39
+ const stat = await fs.stat(gitDir);
40
+ if (!stat.isDirectory()) return { branch: null, commit: null };
41
+ } catch {
42
+ return { branch: null, commit: null };
43
+ }
44
+
45
+ const execGit = async (args) => {
46
+ return await new Promise((resolve) => {
47
+ const child = spawn("git", args, { cwd: repoRootReal });
48
+ let out = "";
49
+ child.stdout.on("data", (chunk) => (out += String(chunk)));
50
+ child.on("close", (code) => resolve(code === 0 ? out.trim() : null));
51
+ child.on("error", () => resolve(null));
52
+ });
53
+ };
54
+
55
+ const [branch, commit] = await Promise.all([
56
+ execGit(["rev-parse", "--abbrev-ref", "HEAD"]),
57
+ execGit(["rev-parse", "HEAD"]),
58
+ ]);
59
+ return { branch: branch && branch !== "HEAD" ? branch : branch, commit };
60
+ }
61
+
62
+ async function safeRealpath(rootReal, requestPath) {
63
+ const stripped = String(requestPath || "").replace(/^\/+/, "");
64
+ const resolved = path.resolve(rootReal, stripped);
65
+ if (!isWithinRoot(rootReal, resolved)) {
66
+ const err = new Error("Path escapes repo root");
67
+ err.statusCode = 400;
68
+ throw err;
69
+ }
70
+
71
+ let real;
72
+ try {
73
+ real = await fs.realpath(resolved);
74
+ } catch (e) {
75
+ e.statusCode = 404;
76
+ throw e;
77
+ }
78
+ if (!isWithinRoot(rootReal, real)) {
79
+ const err = new Error("Path resolves outside repo root");
80
+ err.statusCode = 400;
81
+ throw err;
82
+ }
83
+ return { stripped, resolved: real };
84
+ }
85
+
86
+ async function statSafe(p, { followSymlinks = true } = {}) {
87
+ const stat = followSymlinks ? await fs.stat(p) : await fs.lstat(p);
88
+ return {
89
+ isFile: stat.isFile(),
90
+ isDir: stat.isDirectory(),
91
+ size: stat.size,
92
+ mtimeMs: stat.mtimeMs,
93
+ };
94
+ }
95
+
96
+ function formatBytes(bytes) {
97
+ if (!Number.isFinite(bytes)) return "";
98
+ if (bytes < 1024) return `${bytes} B`;
99
+ const units = ["KB", "MB", "GB", "TB"];
100
+ let value = bytes / 1024;
101
+ let unit = 0;
102
+ while (value >= 1024 && unit < units.length - 1) {
103
+ value /= 1024;
104
+ unit++;
105
+ }
106
+ return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`;
107
+ }
108
+
109
+ function formatDate(ms) {
110
+ const d = new Date(ms);
111
+ return d.toLocaleString(undefined, {
112
+ year: "numeric",
113
+ month: "short",
114
+ day: "2-digit",
115
+ hour: "2-digit",
116
+ minute: "2-digit",
117
+ });
118
+ }
119
+
120
+ function createReloadHub() {
121
+ const clients = new Set();
122
+ let revision = 0;
123
+ return {
124
+ add(res) {
125
+ clients.add(res);
126
+ res.on("close", () => clients.delete(res));
127
+ },
128
+ broadcastReload() {
129
+ revision++;
130
+ const payload = `event: reload\ndata: ${Date.now()}\n\n`;
131
+ for (const res of clients) res.write(payload);
132
+ },
133
+ getRevision() {
134
+ return revision;
135
+ },
136
+ broadcastPing() {
137
+ const payload = `event: ping\ndata: ${Date.now()}\n\n`;
138
+ for (const res of clients) res.write(payload);
139
+ },
140
+ };
141
+ }
142
+
143
+ export async function startServer({ repoRoot, host, port, watch }) {
144
+ const repoRootReal = await fs.realpath(repoRoot);
145
+ const repoName = path.basename(repoRootReal);
146
+ const gitInfo = await getGitInfo(repoRootReal);
147
+ const reloadHub = createReloadHub();
148
+ const md = createMarkdownRenderer();
149
+ let ignoreMatcher = await loadGitIgnoreMatcher(repoRootReal);
150
+ const isIgnored = (relPosix, opts) => ignoreMatcher.ignores(relPosix, opts);
151
+ const linkScanner = createRepoLinkScanner({ repoRootReal, markdownRenderer: md, isIgnored });
152
+
153
+ const app = express();
154
+ app.disable("x-powered-by");
155
+
156
+ const publicDir = path.join(process.cwd(), "public");
157
+ app.use("/static", express.static(publicDir, { fallthrough: true }));
158
+ app.use(
159
+ "/static/vendor/github-markdown-css",
160
+ express.static(path.join(process.cwd(), "node_modules/github-markdown-css"), {
161
+ fallthrough: false,
162
+ }),
163
+ );
164
+ app.use(
165
+ "/static/vendor/highlight.js",
166
+ express.static(path.join(process.cwd(), "node_modules/highlight.js"), {
167
+ fallthrough: false,
168
+ }),
169
+ );
170
+ app.use(
171
+ "/static/vendor/katex",
172
+ express.static(path.join(process.cwd(), "node_modules/katex/dist"), {
173
+ fallthrough: false,
174
+ }),
175
+ );
176
+ app.use(
177
+ "/static/vendor/mermaid",
178
+ express.static(path.join(process.cwd(), "node_modules/mermaid/dist"), {
179
+ fallthrough: false,
180
+ }),
181
+ );
182
+
183
+ app.use((req, res, next) => {
184
+ if (!req.path.startsWith("/static/")) res.setHeader("Cache-Control", "no-store");
185
+ next();
186
+ });
187
+
188
+ app.get("/", (req, res) => res.redirect("/tree/"));
189
+
190
+ void linkScanner.triggerScan();
191
+
192
+ app.get("/rev", (req, res) => {
193
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
194
+ res.status(200).send({ revision: reloadHub.getRevision() });
195
+ });
196
+
197
+ app.get("/broken-links.json", (req, res) => {
198
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
199
+ res.status(200).send(linkScanner.getState());
200
+ });
201
+
202
+ app.get("/broken-links", (req, res) => {
203
+ const showIgnored = req.query.ignored === "1";
204
+ const query = new URLSearchParams();
205
+ if (req.query.watch === "0") query.set("watch", "0");
206
+ if (showIgnored) query.set("ignored", "1");
207
+ const querySuffix = query.toString() ? `?${query.toString()}` : "";
208
+ const toggleIgnoredSuffix = (() => {
209
+ const q = new URLSearchParams(query);
210
+ if (showIgnored) q.delete("ignored");
211
+ else q.set("ignored", "1");
212
+ return q.toString() ? `?${q.toString()}` : "";
213
+ })();
214
+ const toggleIgnoredHref = `/broken-links${toggleIgnoredSuffix}`;
215
+ const state = linkScanner.getState();
216
+ res.status(200).send(
217
+ renderBrokenLinksPage({
218
+ title: `${repoName} · Broken links`,
219
+ repoName,
220
+ gitInfo,
221
+ relPathPosix: "",
222
+ scanState: state,
223
+ querySuffix,
224
+ toggleIgnoredHref,
225
+ showIgnored,
226
+ }),
227
+ );
228
+ });
229
+
230
+ app.get("/events", (req, res) => {
231
+ res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
232
+ res.setHeader("Cache-Control", "no-cache, no-transform");
233
+ res.setHeader("Connection", "keep-alive");
234
+ res.flushHeaders?.();
235
+ res.write("event: hello\ndata: ok\n\n");
236
+ reloadHub.add(res);
237
+
238
+ const interval = setInterval(() => {
239
+ try {
240
+ res.write(":\n\n");
241
+ reloadHub.broadcastPing();
242
+ } catch {
243
+ // ignore
244
+ }
245
+ }, 15000);
246
+ res.on("close", () => clearInterval(interval));
247
+ });
248
+
249
+ app.get(["/tree/*", "/tree"], async (req, res) => {
250
+ try {
251
+ const showIgnored = req.query.ignored === "1";
252
+ const query = new URLSearchParams();
253
+ if (req.query.watch === "0") query.set("watch", "0");
254
+ if (showIgnored) query.set("ignored", "1");
255
+ const querySuffix = query.toString() ? `?${query.toString()}` : "";
256
+ const toggleIgnoredSuffix = (() => {
257
+ const q = new URLSearchParams(query);
258
+ if (showIgnored) q.delete("ignored");
259
+ else q.set("ignored", "1");
260
+ return q.toString() ? `?${q.toString()}` : "";
261
+ })();
262
+
263
+ const p = req.params[0] ?? "";
264
+ const { stripped, resolved } = await safeRealpath(repoRootReal, p);
265
+ const toggleIgnoredHref = `/tree/${encodePathForUrl(
266
+ toPosixPath(stripped),
267
+ )}${toggleIgnoredSuffix}`;
268
+ const st = await statSafe(resolved);
269
+ if (st.isFile)
270
+ return res.redirect(
271
+ `/blob/${encodePathForUrl(toPosixPath(stripped))}${querySuffix}`,
272
+ );
273
+
274
+ const entries = await fs.readdir(resolved, { withFileTypes: true });
275
+ const readmeEntry = entries.find(
276
+ (e) =>
277
+ e.isFile() &&
278
+ /^readme(?:\.(?:md|markdown|mdown|mkd|mkdn))?$/i.test(e.name),
279
+ );
280
+ const rows = await Promise.all(
281
+ entries
282
+ .filter((e) => {
283
+ if (e.name === ".git") return false;
284
+ if (showIgnored) return true;
285
+ const relPosix = toPosixPath(path.posix.join(toPosixPath(stripped), e.name));
286
+ return !ignoreMatcher.ignores(relPosix, { isDir: e.isDirectory() });
287
+ })
288
+ .map(async (e) => {
289
+ const relPosix = toPosixPath(path.posix.join(toPosixPath(stripped), e.name));
290
+ const full = path.join(resolved, e.name);
291
+ const info = await statSafe(full, { followSymlinks: false });
292
+ const isDir = e.isDirectory();
293
+ const href = isDir
294
+ ? `/tree/${encodePathForUrl(relPosix)}${querySuffix}`
295
+ : `/blob/${encodePathForUrl(relPosix)}${querySuffix}`;
296
+ return {
297
+ name: e.name,
298
+ isDir,
299
+ href,
300
+ size: isDir ? "" : formatBytes(info.size),
301
+ mtime: formatDate(info.mtimeMs),
302
+ };
303
+ }),
304
+ );
305
+
306
+ rows.sort((a, b) => {
307
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
308
+ return a.name.localeCompare(b.name);
309
+ });
310
+
311
+ let readmeHtml = "";
312
+ if (readmeEntry) {
313
+ try {
314
+ const readmeRel = toPosixPath(path.posix.join(toPosixPath(stripped), readmeEntry.name));
315
+ if (!showIgnored && ignoreMatcher.ignores(readmeRel, { isDir: false }))
316
+ throw new Error("ignored");
317
+ const { resolved: readmePath } = await safeRealpath(repoRootReal, readmeRel);
318
+ const readmeStat = await statSafe(readmePath);
319
+ if (readmeStat.size <= 2 * 1024 * 1024) {
320
+ const buf = await fs.readFile(readmePath);
321
+ readmeHtml = md.render(buf.toString("utf8"), {
322
+ baseDirPosix: toPosixPath(stripped),
323
+ });
324
+ }
325
+ } catch {
326
+ readmeHtml = "";
327
+ }
328
+ }
329
+
330
+ res.status(200).send(
331
+ renderTreePage({
332
+ title: `${repoName}${stripped ? `/${stripped}` : ""}`,
333
+ repoName,
334
+ gitInfo,
335
+ brokenLinks: linkScanner.getState(),
336
+ relPathPosix: toPosixPath(stripped),
337
+ querySuffix,
338
+ toggleIgnoredHref,
339
+ showIgnored,
340
+ rows,
341
+ readmeHtml,
342
+ }),
343
+ );
344
+ } catch (e) {
345
+ res
346
+ .status(e.statusCode || 500)
347
+ .send(renderErrorPage({ title: "Error", message: e.message }));
348
+ }
349
+ });
350
+
351
+ app.get(["/blob/*", "/blob"], async (req, res) => {
352
+ try {
353
+ const showIgnored = req.query.ignored === "1";
354
+ const query = new URLSearchParams();
355
+ if (req.query.watch === "0") query.set("watch", "0");
356
+ if (showIgnored) query.set("ignored", "1");
357
+ const querySuffix = query.toString() ? `?${query.toString()}` : "";
358
+ const toggleIgnoredSuffix = (() => {
359
+ const q = new URLSearchParams(query);
360
+ if (showIgnored) q.delete("ignored");
361
+ else q.set("ignored", "1");
362
+ return q.toString() ? `?${q.toString()}` : "";
363
+ })();
364
+
365
+ const p = req.params[0] ?? "";
366
+ const { stripped, resolved } = await safeRealpath(repoRootReal, p);
367
+ const toggleIgnoredHref = `/blob/${encodePathForUrl(
368
+ toPosixPath(stripped),
369
+ )}${toggleIgnoredSuffix}`;
370
+ const st = await statSafe(resolved);
371
+ if (st.isDir)
372
+ return res.redirect(
373
+ `/tree/${encodePathForUrl(toPosixPath(stripped))}${querySuffix}`,
374
+ );
375
+
376
+ const fileName = path.basename(resolved);
377
+ const ext = path.extname(fileName).toLowerCase();
378
+ const isMarkdown = [".md", ".markdown", ".mdown", ".mkd", ".mkdn"].includes(ext);
379
+ const maxBytes = 2 * 1024 * 1024;
380
+
381
+ if (st.size > maxBytes) {
382
+ res.status(200).send(
383
+ renderFilePage({
384
+ title: `${repoName}/${stripped}`,
385
+ repoName,
386
+ gitInfo,
387
+ brokenLinks: linkScanner.getState(),
388
+ relPathPosix: toPosixPath(stripped),
389
+ querySuffix,
390
+ toggleIgnoredHref,
391
+ showIgnored,
392
+ fileName,
393
+ isMarkdown: false,
394
+ renderedHtml: `<div class="note">File is too large to render (${formatBytes(
395
+ st.size,
396
+ )}). Use <a href="/raw/${encodePathForUrl(
397
+ toPosixPath(stripped),
398
+ )}${querySuffix}">Raw</a>.</div>`,
399
+ }),
400
+ );
401
+ return;
402
+ }
403
+
404
+ const raw = await fs.readFile(resolved);
405
+ const text = raw.toString("utf8");
406
+
407
+ let renderedHtml;
408
+ if (isMarkdown) {
409
+ const baseDir = toPosixPath(path.posix.dirname(toPosixPath(stripped)));
410
+ renderedHtml = md.render(text, { baseDirPosix: baseDir === "." ? "" : baseDir });
411
+ } else {
412
+ renderedHtml = md.renderCodeBlock(text, {
413
+ languageHint: ext ? ext.slice(1) : "",
414
+ });
415
+ }
416
+
417
+ res.status(200).send(
418
+ renderFilePage({
419
+ title: `${repoName}/${stripped}`,
420
+ repoName,
421
+ gitInfo,
422
+ brokenLinks: linkScanner.getState(),
423
+ relPathPosix: toPosixPath(stripped),
424
+ querySuffix,
425
+ toggleIgnoredHref,
426
+ showIgnored,
427
+ fileName,
428
+ isMarkdown,
429
+ renderedHtml,
430
+ }),
431
+ );
432
+ } catch (e) {
433
+ res
434
+ .status(e.statusCode || 500)
435
+ .send(renderErrorPage({ title: "Error", message: e.message }));
436
+ }
437
+ });
438
+
439
+ app.get(["/raw/*", "/raw"], async (req, res) => {
440
+ try {
441
+ const p = req.params[0] ?? "";
442
+ const { resolved } = await safeRealpath(repoRootReal, p);
443
+ const st = await statSafe(resolved);
444
+ if (!st.isFile) {
445
+ const err = new Error("Not a file");
446
+ err.statusCode = 400;
447
+ throw err;
448
+ }
449
+
450
+ const contentType = mime.contentType(path.extname(resolved)) || "application/octet-stream";
451
+ res.setHeader("Content-Type", contentType);
452
+ res.sendFile(resolved);
453
+ } catch (e) {
454
+ res
455
+ .status(e.statusCode || 500)
456
+ .send(renderErrorPage({ title: "Error", message: e.message }));
457
+ }
458
+ });
459
+
460
+ const server = http.createServer(app);
461
+ await new Promise((resolve) => server.listen(port, host, resolve));
462
+
463
+ if (watch) {
464
+ const watcher = chokidar.watch(repoRootReal, {
465
+ ignored: [
466
+ /(^|[/\\])\.git([/\\]|$)/,
467
+ /(^|[/\\])node_modules([/\\]|$)/,
468
+ ],
469
+ ignoreInitial: true,
470
+ });
471
+ let pending = null;
472
+ watcher.on("all", () => {
473
+ if (pending) return;
474
+ pending = setTimeout(() => {
475
+ pending = null;
476
+ reloadHub.broadcastReload();
477
+ void loadGitIgnoreMatcher(repoRootReal).then((m) => (ignoreMatcher = m));
478
+ void linkScanner.triggerScan();
479
+ }, 100);
480
+ });
481
+ }
482
+
483
+ // eslint-disable-next-line no-console
484
+ console.log(`repo-viewer: ${repoRootReal}`);
485
+ // eslint-disable-next-line no-console
486
+ console.log(`listening: http://${host}:${port}`);
487
+ }