repoview 0.5.0 → 0.6.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/CHANGELOG.md +53 -0
- package/CONTRIBUTING.md +4 -3
- package/DEVELOPMENT.md +70 -16
- package/README.md +36 -5
- package/dist/api.js +58 -0
- package/dist/api.js.map +1 -0
- package/dist/cli.js +224 -0
- package/dist/cli.js.map +1 -0
- package/dist/csv.js +64 -0
- package/dist/csv.js.map +1 -0
- package/dist/format.js +25 -0
- package/dist/format.js.map +1 -0
- package/dist/git.js +67 -0
- package/dist/git.js.map +1 -0
- package/dist/gitignore.js +34 -0
- package/dist/gitignore.js.map +1 -0
- package/dist/linkcheck.js +310 -0
- package/dist/linkcheck.js.map +1 -0
- package/dist/markdown.js +493 -0
- package/dist/markdown.js.map +1 -0
- package/dist/net.js +10 -0
- package/dist/net.js.map +1 -0
- package/dist/paths.js +59 -0
- package/dist/paths.js.map +1 -0
- package/dist/reload.js +36 -0
- package/dist/reload.js.map +1 -0
- package/dist/repo-context.js +73 -0
- package/dist/repo-context.js.map +1 -0
- package/dist/repo-router.js +801 -0
- package/dist/repo-router.js.map +1 -0
- package/dist/review-cli.js +228 -0
- package/dist/review-cli.js.map +1 -0
- package/dist/server.js +116 -0
- package/dist/server.js.map +1 -0
- package/dist/session.js +86 -0
- package/dist/session.js.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/views.js +633 -0
- package/dist/views.js.map +1 -0
- package/package.json +22 -6
- package/public/app.css +842 -0
- package/public/app.js +35 -2
- package/public/review.js +587 -0
- package/public/session.js +61 -0
- package/src/cli.js +0 -73
- package/src/gitignore.js +0 -34
- package/src/linkcheck.js +0 -312
- package/src/markdown.js +0 -364
- package/src/server.js +0 -760
- package/src/views.js +0 -479
package/src/server.js
DELETED
|
@@ -1,760 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
|
-
import http from "node:http";
|
|
3
|
-
import { createRequire } from "node:module";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import { spawn } from "node:child_process";
|
|
6
|
-
import { fileURLToPath } from "node:url";
|
|
7
|
-
import express from "express";
|
|
8
|
-
import chokidar from "chokidar";
|
|
9
|
-
import mime from "mime-types";
|
|
10
|
-
|
|
11
|
-
import { createMarkdownRenderer } from "./markdown.js";
|
|
12
|
-
import { loadGitIgnoreMatcher } from "./gitignore.js";
|
|
13
|
-
import { createRepoLinkScanner } from "./linkcheck.js";
|
|
14
|
-
import diff2html from "diff2html";
|
|
15
|
-
import {
|
|
16
|
-
escapeHtml,
|
|
17
|
-
renderBrokenLinksPage,
|
|
18
|
-
renderDiffPage,
|
|
19
|
-
renderErrorPage,
|
|
20
|
-
renderFilePage,
|
|
21
|
-
renderTreePage,
|
|
22
|
-
} from "./views.js";
|
|
23
|
-
|
|
24
|
-
function toPosixPath(p) {
|
|
25
|
-
return p.split(path.sep).join("/");
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function encodePathForUrl(posixPath) {
|
|
29
|
-
return posixPath
|
|
30
|
-
.split("/")
|
|
31
|
-
.map((segment) => encodeURIComponent(segment))
|
|
32
|
-
.join("/");
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function isWithinRoot(rootReal, candidateReal) {
|
|
36
|
-
if (candidateReal === rootReal) return true;
|
|
37
|
-
const rootWithSep = rootReal.endsWith(path.sep) ? rootReal : rootReal + path.sep;
|
|
38
|
-
return candidateReal.startsWith(rootWithSep);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function parseCsv(text, delimiter = ",") {
|
|
42
|
-
const rows = [];
|
|
43
|
-
let current = [];
|
|
44
|
-
let cell = "";
|
|
45
|
-
let inQuotes = false;
|
|
46
|
-
|
|
47
|
-
for (let i = 0; i < text.length; i++) {
|
|
48
|
-
const ch = text[i];
|
|
49
|
-
if (inQuotes) {
|
|
50
|
-
if (ch === '"' && text[i + 1] === '"') {
|
|
51
|
-
cell += '"';
|
|
52
|
-
i++;
|
|
53
|
-
} else if (ch === '"') {
|
|
54
|
-
inQuotes = false;
|
|
55
|
-
} else {
|
|
56
|
-
cell += ch;
|
|
57
|
-
}
|
|
58
|
-
} else {
|
|
59
|
-
if (ch === '"') {
|
|
60
|
-
inQuotes = true;
|
|
61
|
-
} else if (ch === delimiter) {
|
|
62
|
-
current.push(cell);
|
|
63
|
-
cell = "";
|
|
64
|
-
} else if (ch === "\n" || (ch === "\r" && text[i + 1] === "\n")) {
|
|
65
|
-
if (ch === "\r") i++;
|
|
66
|
-
current.push(cell);
|
|
67
|
-
rows.push(current);
|
|
68
|
-
current = [];
|
|
69
|
-
cell = "";
|
|
70
|
-
} else if (ch === "\r") {
|
|
71
|
-
current.push(cell);
|
|
72
|
-
rows.push(current);
|
|
73
|
-
current = [];
|
|
74
|
-
cell = "";
|
|
75
|
-
} else {
|
|
76
|
-
cell += ch;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
if (cell || current.length) {
|
|
81
|
-
current.push(cell);
|
|
82
|
-
rows.push(current);
|
|
83
|
-
}
|
|
84
|
-
return rows;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function renderCsvTable(rows, escFn) {
|
|
88
|
-
if (!rows.length) return "<p>Empty file</p>";
|
|
89
|
-
const header = rows[0];
|
|
90
|
-
const body = rows.slice(1);
|
|
91
|
-
const ths = header.map((h) => `<th>${escFn(h)}</th>`).join("");
|
|
92
|
-
const trs = body
|
|
93
|
-
.map((row) => `<tr>${row.map((c) => `<td>${escFn(c)}</td>`).join("")}</tr>`)
|
|
94
|
-
.join("\n");
|
|
95
|
-
return `<div class="csv-table-wrap"><table class="csv-table"><thead><tr>${ths}</tr></thead><tbody>${trs}</tbody></table></div>`;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function execGit(repoRootReal, args, maxBytes = 1024 * 1024) {
|
|
99
|
-
return new Promise((resolve) => {
|
|
100
|
-
const child = spawn("git", args, { cwd: repoRootReal });
|
|
101
|
-
let out = "";
|
|
102
|
-
let size = 0;
|
|
103
|
-
let killed = false;
|
|
104
|
-
child.stdout.on("data", (chunk) => {
|
|
105
|
-
size += chunk.length;
|
|
106
|
-
if (size > maxBytes) {
|
|
107
|
-
if (!killed) { killed = true; child.kill(); }
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
out += String(chunk);
|
|
111
|
-
});
|
|
112
|
-
child.on("close", (code) => {
|
|
113
|
-
if (killed) return resolve({ output: out, tooLarge: true, code });
|
|
114
|
-
resolve({ output: code === 0 ? out.trim() : null, tooLarge: false, code });
|
|
115
|
-
});
|
|
116
|
-
child.on("error", () => resolve({ output: null, tooLarge: false, code: -1 }));
|
|
117
|
-
});
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function validateGitRef(ref) {
|
|
121
|
-
if (!ref || typeof ref !== "string") return false;
|
|
122
|
-
return /^[a-zA-Z0-9_.\/\-~^]+$/.test(ref);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
async function getGitBranches(repoRootReal) {
|
|
126
|
-
const { output } = await execGit(repoRootReal, ["branch", "--format=%(refname:short)"]);
|
|
127
|
-
if (!output) return [];
|
|
128
|
-
return output.split("\n").filter(Boolean);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
async function getGitTags(repoRootReal) {
|
|
132
|
-
const { output } = await execGit(repoRootReal, ["tag", "-l"]);
|
|
133
|
-
if (!output) return [];
|
|
134
|
-
return output.split("\n").filter(Boolean);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
async function getGitDiffRaw(repoRootReal, base) {
|
|
138
|
-
const maxBytes = 512 * 1024;
|
|
139
|
-
const { output, tooLarge } = await execGit(repoRootReal, ["diff", base], maxBytes);
|
|
140
|
-
return { raw: output || "", tooLarge };
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
async function getGitInfo(repoRootReal) {
|
|
144
|
-
const gitDir = path.join(repoRootReal, ".git");
|
|
145
|
-
try {
|
|
146
|
-
await fs.stat(gitDir);
|
|
147
|
-
} catch {
|
|
148
|
-
return { branch: null, commit: null };
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const [branchResult, commitResult] = await Promise.all([
|
|
152
|
-
execGit(repoRootReal, ["rev-parse", "--abbrev-ref", "HEAD"]),
|
|
153
|
-
execGit(repoRootReal, ["rev-parse", "HEAD"]),
|
|
154
|
-
]);
|
|
155
|
-
const branch = branchResult.output;
|
|
156
|
-
const commit = commitResult.output;
|
|
157
|
-
return { branch: branch && branch !== "HEAD" ? branch : branch, commit };
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
async function safeRealpath(rootReal, requestPath) {
|
|
161
|
-
const stripped = String(requestPath || "").replace(/^\/+/, "");
|
|
162
|
-
const resolved = path.resolve(rootReal, stripped);
|
|
163
|
-
if (!isWithinRoot(rootReal, resolved)) {
|
|
164
|
-
const err = new Error("Path escapes repo root");
|
|
165
|
-
err.statusCode = 400;
|
|
166
|
-
throw err;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
let real;
|
|
170
|
-
try {
|
|
171
|
-
real = await fs.realpath(resolved);
|
|
172
|
-
} catch (e) {
|
|
173
|
-
e.statusCode = 404;
|
|
174
|
-
throw e;
|
|
175
|
-
}
|
|
176
|
-
if (!isWithinRoot(rootReal, real)) {
|
|
177
|
-
const err = new Error("Path resolves outside repo root");
|
|
178
|
-
err.statusCode = 400;
|
|
179
|
-
throw err;
|
|
180
|
-
}
|
|
181
|
-
return { stripped, resolved: real };
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
async function statSafe(p, { followSymlinks = true } = {}) {
|
|
185
|
-
try {
|
|
186
|
-
const stat = followSymlinks ? await fs.stat(p) : await fs.lstat(p);
|
|
187
|
-
return {
|
|
188
|
-
isFile: stat.isFile(),
|
|
189
|
-
isDir: stat.isDirectory(),
|
|
190
|
-
size: stat.size,
|
|
191
|
-
mtimeMs: stat.mtimeMs,
|
|
192
|
-
};
|
|
193
|
-
} catch (e) {
|
|
194
|
-
if (e.code === "EACCES" || e.code === "EPERM") {
|
|
195
|
-
return null;
|
|
196
|
-
}
|
|
197
|
-
throw e;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function formatBytes(bytes) {
|
|
202
|
-
if (!Number.isFinite(bytes)) return "";
|
|
203
|
-
if (bytes < 1024) return `${bytes} B`;
|
|
204
|
-
const units = ["KB", "MB", "GB", "TB"];
|
|
205
|
-
let value = bytes / 1024;
|
|
206
|
-
let unit = 0;
|
|
207
|
-
while (value >= 1024 && unit < units.length - 1) {
|
|
208
|
-
value /= 1024;
|
|
209
|
-
unit++;
|
|
210
|
-
}
|
|
211
|
-
return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function formatDate(ms) {
|
|
215
|
-
const d = new Date(ms);
|
|
216
|
-
return d.toLocaleString(undefined, {
|
|
217
|
-
year: "numeric",
|
|
218
|
-
month: "short",
|
|
219
|
-
day: "2-digit",
|
|
220
|
-
hour: "2-digit",
|
|
221
|
-
minute: "2-digit",
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function createReloadHub() {
|
|
226
|
-
const clients = new Set();
|
|
227
|
-
let revision = 0;
|
|
228
|
-
return {
|
|
229
|
-
add(res) {
|
|
230
|
-
clients.add(res);
|
|
231
|
-
res.on("close", () => clients.delete(res));
|
|
232
|
-
},
|
|
233
|
-
broadcastReload() {
|
|
234
|
-
revision++;
|
|
235
|
-
const payload = `event: reload\ndata: ${Date.now()}\n\n`;
|
|
236
|
-
for (const res of clients) res.write(payload);
|
|
237
|
-
},
|
|
238
|
-
getRevision() {
|
|
239
|
-
return revision;
|
|
240
|
-
},
|
|
241
|
-
broadcastPing() {
|
|
242
|
-
const payload = `event: ping\ndata: ${Date.now()}\n\n`;
|
|
243
|
-
for (const res of clients) res.write(payload);
|
|
244
|
-
},
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
export async function startServer({ repoRoot, host, port, watch }) {
|
|
249
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
250
|
-
const __dirname = path.dirname(__filename);
|
|
251
|
-
const packageRoot = path.resolve(__dirname, "..");
|
|
252
|
-
const require = createRequire(import.meta.url);
|
|
253
|
-
|
|
254
|
-
const resolvePackageDir = (name) => {
|
|
255
|
-
const pkgJson = require.resolve(`${name}/package.json`);
|
|
256
|
-
return path.dirname(pkgJson);
|
|
257
|
-
};
|
|
258
|
-
|
|
259
|
-
const repoRootReal = await fs.realpath(repoRoot);
|
|
260
|
-
const repoName = path.basename(repoRootReal);
|
|
261
|
-
const gitInfo = await getGitInfo(repoRootReal);
|
|
262
|
-
const reloadHub = createReloadHub();
|
|
263
|
-
const md = createMarkdownRenderer();
|
|
264
|
-
let ignoreMatcher = await loadGitIgnoreMatcher(repoRootReal);
|
|
265
|
-
const isIgnored = (relPosix, opts) => ignoreMatcher.ignores(relPosix, opts);
|
|
266
|
-
const linkScanner = createRepoLinkScanner({ repoRootReal, markdownRenderer: md, isIgnored });
|
|
267
|
-
|
|
268
|
-
const app = express();
|
|
269
|
-
app.disable("x-powered-by");
|
|
270
|
-
|
|
271
|
-
const publicDir = path.join(packageRoot, "public");
|
|
272
|
-
app.use("/static", express.static(publicDir, { fallthrough: true }));
|
|
273
|
-
app.use(
|
|
274
|
-
"/static/vendor/github-markdown-css",
|
|
275
|
-
express.static(resolvePackageDir("github-markdown-css"), {
|
|
276
|
-
fallthrough: false,
|
|
277
|
-
}),
|
|
278
|
-
);
|
|
279
|
-
app.use(
|
|
280
|
-
"/static/vendor/highlight.js",
|
|
281
|
-
express.static(resolvePackageDir("highlight.js"), {
|
|
282
|
-
fallthrough: false,
|
|
283
|
-
}),
|
|
284
|
-
);
|
|
285
|
-
app.use(
|
|
286
|
-
"/static/vendor/katex",
|
|
287
|
-
express.static(path.join(resolvePackageDir("katex"), "dist"), {
|
|
288
|
-
fallthrough: false,
|
|
289
|
-
}),
|
|
290
|
-
);
|
|
291
|
-
app.use(
|
|
292
|
-
"/static/vendor/mermaid",
|
|
293
|
-
express.static(path.join(resolvePackageDir("mermaid"), "dist"), {
|
|
294
|
-
fallthrough: false,
|
|
295
|
-
}),
|
|
296
|
-
);
|
|
297
|
-
app.use(
|
|
298
|
-
"/static/vendor/diff2html",
|
|
299
|
-
express.static(path.join(resolvePackageDir("diff2html"), "bundles", "css"), {
|
|
300
|
-
fallthrough: false,
|
|
301
|
-
}),
|
|
302
|
-
);
|
|
303
|
-
|
|
304
|
-
app.use((req, res, next) => {
|
|
305
|
-
if (!req.path.startsWith("/static/")) res.setHeader("Cache-Control", "no-store");
|
|
306
|
-
next();
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
app.get("/", (req, res) => res.redirect("/tree/"));
|
|
310
|
-
|
|
311
|
-
void linkScanner.triggerScan();
|
|
312
|
-
|
|
313
|
-
app.get("/rev", (req, res) => {
|
|
314
|
-
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
315
|
-
res.status(200).send({ revision: reloadHub.getRevision() });
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
app.get("/broken-links.json", (req, res) => {
|
|
319
|
-
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
320
|
-
res.status(200).send(linkScanner.getState());
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
app.get("/broken-links", (req, res) => {
|
|
324
|
-
const showIgnored = req.query.ignored === "1";
|
|
325
|
-
const query = new URLSearchParams();
|
|
326
|
-
if (req.query.watch === "0") query.set("watch", "0");
|
|
327
|
-
if (showIgnored) query.set("ignored", "1");
|
|
328
|
-
const querySuffix = query.toString() ? `?${query.toString()}` : "";
|
|
329
|
-
const toggleIgnoredSuffix = (() => {
|
|
330
|
-
const q = new URLSearchParams(query);
|
|
331
|
-
if (showIgnored) q.delete("ignored");
|
|
332
|
-
else q.set("ignored", "1");
|
|
333
|
-
return q.toString() ? `?${q.toString()}` : "";
|
|
334
|
-
})();
|
|
335
|
-
const toggleIgnoredHref = `/broken-links${toggleIgnoredSuffix}`;
|
|
336
|
-
const state = linkScanner.getState();
|
|
337
|
-
res.status(200).send(
|
|
338
|
-
renderBrokenLinksPage({
|
|
339
|
-
title: `${repoName} · Broken links`,
|
|
340
|
-
repoName,
|
|
341
|
-
gitInfo,
|
|
342
|
-
relPathPosix: "",
|
|
343
|
-
scanState: state,
|
|
344
|
-
querySuffix,
|
|
345
|
-
toggleIgnoredHref,
|
|
346
|
-
showIgnored,
|
|
347
|
-
}),
|
|
348
|
-
);
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
app.get("/events", (req, res) => {
|
|
352
|
-
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
353
|
-
res.setHeader("Cache-Control", "no-cache, no-transform");
|
|
354
|
-
res.setHeader("Connection", "keep-alive");
|
|
355
|
-
res.flushHeaders?.();
|
|
356
|
-
res.write("event: hello\ndata: ok\n\n");
|
|
357
|
-
reloadHub.add(res);
|
|
358
|
-
|
|
359
|
-
const interval = setInterval(() => {
|
|
360
|
-
try {
|
|
361
|
-
res.write(":\n\n");
|
|
362
|
-
reloadHub.broadcastPing();
|
|
363
|
-
} catch {
|
|
364
|
-
// ignore
|
|
365
|
-
}
|
|
366
|
-
}, 15000);
|
|
367
|
-
res.on("close", () => clearInterval(interval));
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
app.get("/diff", async (req, res) => {
|
|
371
|
-
try {
|
|
372
|
-
const base = req.query.base || "HEAD";
|
|
373
|
-
if (!validateGitRef(base)) {
|
|
374
|
-
const err = new Error("Invalid base ref");
|
|
375
|
-
err.statusCode = 400;
|
|
376
|
-
throw err;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
if (!gitInfo.commit) {
|
|
380
|
-
const err = new Error("Not a git repository");
|
|
381
|
-
err.statusCode = 400;
|
|
382
|
-
throw err;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
const query = new URLSearchParams();
|
|
386
|
-
if (req.query.watch === "0") query.set("watch", "0");
|
|
387
|
-
if (req.query.ignored === "1") query.set("ignored", "1");
|
|
388
|
-
if (base !== "HEAD") query.set("base", base);
|
|
389
|
-
if (req.query.show_all === "1") query.set("show_all", "1");
|
|
390
|
-
const querySuffix = query.toString() ? `?${query.toString()}` : "";
|
|
391
|
-
|
|
392
|
-
const [branches, tags, diffResult] = await Promise.all([
|
|
393
|
-
getGitBranches(repoRootReal),
|
|
394
|
-
getGitTags(repoRootReal),
|
|
395
|
-
getGitDiffRaw(repoRootReal, base),
|
|
396
|
-
]);
|
|
397
|
-
|
|
398
|
-
const MAX_DIFF_FILES = 30;
|
|
399
|
-
let diffHtml = "";
|
|
400
|
-
let fileCount = 0;
|
|
401
|
-
const showAll = req.query.show_all === "1";
|
|
402
|
-
if (!diffResult.tooLarge && diffResult.raw) {
|
|
403
|
-
const parsed = diff2html.parse(diffResult.raw);
|
|
404
|
-
fileCount = parsed.length;
|
|
405
|
-
const toRender = (!showAll && parsed.length > MAX_DIFF_FILES)
|
|
406
|
-
? parsed.slice(0, MAX_DIFF_FILES)
|
|
407
|
-
: parsed;
|
|
408
|
-
diffHtml = diff2html.html(toRender, {
|
|
409
|
-
outputFormat: "line-by-line",
|
|
410
|
-
drawFileList: true,
|
|
411
|
-
});
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
res.status(200).send(
|
|
415
|
-
renderDiffPage({
|
|
416
|
-
title: `${repoName} · Diff`,
|
|
417
|
-
repoName,
|
|
418
|
-
gitInfo,
|
|
419
|
-
relPathPosix: "",
|
|
420
|
-
querySuffix,
|
|
421
|
-
base,
|
|
422
|
-
branches,
|
|
423
|
-
tags,
|
|
424
|
-
diffHtml,
|
|
425
|
-
tooLarge: diffResult.tooLarge,
|
|
426
|
-
empty: !diffResult.raw,
|
|
427
|
-
fileCount,
|
|
428
|
-
showAll,
|
|
429
|
-
}),
|
|
430
|
-
);
|
|
431
|
-
} catch (e) {
|
|
432
|
-
res
|
|
433
|
-
.status(e.statusCode || 500)
|
|
434
|
-
.send(renderErrorPage({ title: "Error", message: e.message }));
|
|
435
|
-
}
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
app.get(["/tree/*", "/tree"], async (req, res) => {
|
|
439
|
-
try {
|
|
440
|
-
const showIgnored = req.query.ignored === "1";
|
|
441
|
-
const query = new URLSearchParams();
|
|
442
|
-
if (req.query.watch === "0") query.set("watch", "0");
|
|
443
|
-
if (showIgnored) query.set("ignored", "1");
|
|
444
|
-
const querySuffix = query.toString() ? `?${query.toString()}` : "";
|
|
445
|
-
const toggleIgnoredSuffix = (() => {
|
|
446
|
-
const q = new URLSearchParams(query);
|
|
447
|
-
if (showIgnored) q.delete("ignored");
|
|
448
|
-
else q.set("ignored", "1");
|
|
449
|
-
return q.toString() ? `?${q.toString()}` : "";
|
|
450
|
-
})();
|
|
451
|
-
|
|
452
|
-
const p = req.params[0] ?? "";
|
|
453
|
-
const { stripped, resolved } = await safeRealpath(repoRootReal, p);
|
|
454
|
-
const toggleIgnoredHref = `/tree/${encodePathForUrl(
|
|
455
|
-
toPosixPath(stripped),
|
|
456
|
-
)}${toggleIgnoredSuffix}`;
|
|
457
|
-
const st = await statSafe(resolved);
|
|
458
|
-
if (st === null) {
|
|
459
|
-
const err = new Error("Permission denied");
|
|
460
|
-
err.statusCode = 403;
|
|
461
|
-
throw err;
|
|
462
|
-
}
|
|
463
|
-
if (st.isFile)
|
|
464
|
-
return res.redirect(
|
|
465
|
-
`/blob/${encodePathForUrl(toPosixPath(stripped))}${querySuffix}`,
|
|
466
|
-
);
|
|
467
|
-
|
|
468
|
-
let entries;
|
|
469
|
-
try {
|
|
470
|
-
entries = await fs.readdir(resolved, { withFileTypes: true });
|
|
471
|
-
} catch (e) {
|
|
472
|
-
if (e.code === "EACCES" || e.code === "EPERM") {
|
|
473
|
-
const err = new Error("Permission denied");
|
|
474
|
-
err.statusCode = 403;
|
|
475
|
-
throw err;
|
|
476
|
-
}
|
|
477
|
-
throw e;
|
|
478
|
-
}
|
|
479
|
-
const readmeEntry = entries.find(
|
|
480
|
-
(e) =>
|
|
481
|
-
e.isFile() &&
|
|
482
|
-
/^readme(?:\.(?:md|markdown|mdown|mkd|mkdn))?$/i.test(e.name),
|
|
483
|
-
);
|
|
484
|
-
const rows = (
|
|
485
|
-
await Promise.all(
|
|
486
|
-
entries
|
|
487
|
-
.filter((e) => {
|
|
488
|
-
if (e.name === ".git") return false;
|
|
489
|
-
if (showIgnored) return true;
|
|
490
|
-
const relPosix = toPosixPath(path.posix.join(toPosixPath(stripped), e.name));
|
|
491
|
-
return !ignoreMatcher.ignores(relPosix, { isDir: e.isDirectory() });
|
|
492
|
-
})
|
|
493
|
-
.map(async (e) => {
|
|
494
|
-
const relPosix = toPosixPath(path.posix.join(toPosixPath(stripped), e.name));
|
|
495
|
-
const full = path.join(resolved, e.name);
|
|
496
|
-
const info = await statSafe(full, { followSymlinks: false });
|
|
497
|
-
if (info === null) return null;
|
|
498
|
-
const isDir = e.isDirectory();
|
|
499
|
-
const href = isDir
|
|
500
|
-
? `/tree/${encodePathForUrl(relPosix)}${querySuffix}`
|
|
501
|
-
: `/blob/${encodePathForUrl(relPosix)}${querySuffix}`;
|
|
502
|
-
return {
|
|
503
|
-
name: e.name,
|
|
504
|
-
isDir,
|
|
505
|
-
href,
|
|
506
|
-
size: isDir ? "" : formatBytes(info.size),
|
|
507
|
-
mtime: formatDate(info.mtimeMs),
|
|
508
|
-
mtimeMs: info.mtimeMs,
|
|
509
|
-
};
|
|
510
|
-
}),
|
|
511
|
-
)
|
|
512
|
-
).filter((row) => row !== null);
|
|
513
|
-
|
|
514
|
-
rows.sort((a, b) => {
|
|
515
|
-
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
516
|
-
return a.name.localeCompare(b.name);
|
|
517
|
-
});
|
|
518
|
-
|
|
519
|
-
let readmeHtml = "";
|
|
520
|
-
if (readmeEntry) {
|
|
521
|
-
try {
|
|
522
|
-
const readmeRel = toPosixPath(path.posix.join(toPosixPath(stripped), readmeEntry.name));
|
|
523
|
-
if (!showIgnored && ignoreMatcher.ignores(readmeRel, { isDir: false }))
|
|
524
|
-
throw new Error("ignored");
|
|
525
|
-
const { resolved: readmePath } = await safeRealpath(repoRootReal, readmeRel);
|
|
526
|
-
const readmeStat = await statSafe(readmePath);
|
|
527
|
-
if (readmeStat.size <= 2 * 1024 * 1024) {
|
|
528
|
-
const buf = await fs.readFile(readmePath);
|
|
529
|
-
readmeHtml = md.render(buf.toString("utf8"), {
|
|
530
|
-
baseDirPosix: toPosixPath(stripped),
|
|
531
|
-
});
|
|
532
|
-
}
|
|
533
|
-
} catch {
|
|
534
|
-
readmeHtml = "";
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
res.status(200).send(
|
|
539
|
-
renderTreePage({
|
|
540
|
-
title: `${repoName}${stripped ? `/${stripped}` : ""}`,
|
|
541
|
-
repoName,
|
|
542
|
-
gitInfo,
|
|
543
|
-
brokenLinks: linkScanner.getState(),
|
|
544
|
-
relPathPosix: toPosixPath(stripped),
|
|
545
|
-
querySuffix,
|
|
546
|
-
toggleIgnoredHref,
|
|
547
|
-
showIgnored,
|
|
548
|
-
rows,
|
|
549
|
-
readmeHtml,
|
|
550
|
-
}),
|
|
551
|
-
);
|
|
552
|
-
} catch (e) {
|
|
553
|
-
res
|
|
554
|
-
.status(e.statusCode || 500)
|
|
555
|
-
.send(renderErrorPage({ title: "Error", message: e.message }));
|
|
556
|
-
}
|
|
557
|
-
});
|
|
558
|
-
|
|
559
|
-
app.get(["/blob/*", "/blob"], async (req, res) => {
|
|
560
|
-
try {
|
|
561
|
-
const showIgnored = req.query.ignored === "1";
|
|
562
|
-
const query = new URLSearchParams();
|
|
563
|
-
if (req.query.watch === "0") query.set("watch", "0");
|
|
564
|
-
if (showIgnored) query.set("ignored", "1");
|
|
565
|
-
const querySuffix = query.toString() ? `?${query.toString()}` : "";
|
|
566
|
-
const toggleIgnoredSuffix = (() => {
|
|
567
|
-
const q = new URLSearchParams(query);
|
|
568
|
-
if (showIgnored) q.delete("ignored");
|
|
569
|
-
else q.set("ignored", "1");
|
|
570
|
-
return q.toString() ? `?${q.toString()}` : "";
|
|
571
|
-
})();
|
|
572
|
-
|
|
573
|
-
const p = req.params[0] ?? "";
|
|
574
|
-
const { stripped, resolved } = await safeRealpath(repoRootReal, p);
|
|
575
|
-
const toggleIgnoredHref = `/blob/${encodePathForUrl(
|
|
576
|
-
toPosixPath(stripped),
|
|
577
|
-
)}${toggleIgnoredSuffix}`;
|
|
578
|
-
const st = await statSafe(resolved);
|
|
579
|
-
if (st === null) {
|
|
580
|
-
const err = new Error("Permission denied");
|
|
581
|
-
err.statusCode = 403;
|
|
582
|
-
throw err;
|
|
583
|
-
}
|
|
584
|
-
if (st.isDir)
|
|
585
|
-
return res.redirect(
|
|
586
|
-
`/tree/${encodePathForUrl(toPosixPath(stripped))}${querySuffix}`,
|
|
587
|
-
);
|
|
588
|
-
|
|
589
|
-
const fileName = path.basename(resolved);
|
|
590
|
-
const ext = path.extname(fileName).toLowerCase();
|
|
591
|
-
const isMarkdown = [".md", ".markdown", ".mdown", ".mkd", ".mkdn"].includes(ext);
|
|
592
|
-
const isPdf = ext === ".pdf";
|
|
593
|
-
const isImage = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico", ".bmp"].includes(ext);
|
|
594
|
-
const isCsv = [".csv", ".tsv"].includes(ext);
|
|
595
|
-
const maxBytes = 2 * 1024 * 1024;
|
|
596
|
-
const rawSrc = `/raw/${encodePathForUrl(toPosixPath(stripped))}`;
|
|
597
|
-
|
|
598
|
-
if (isPdf) {
|
|
599
|
-
res.status(200).send(
|
|
600
|
-
renderFilePage({
|
|
601
|
-
title: `${repoName}/${stripped}`,
|
|
602
|
-
repoName,
|
|
603
|
-
gitInfo,
|
|
604
|
-
brokenLinks: linkScanner.getState(),
|
|
605
|
-
relPathPosix: toPosixPath(stripped),
|
|
606
|
-
querySuffix,
|
|
607
|
-
toggleIgnoredHref,
|
|
608
|
-
showIgnored,
|
|
609
|
-
fileName,
|
|
610
|
-
isMarkdown: false,
|
|
611
|
-
mediaType: "pdf",
|
|
612
|
-
renderedHtml: `<iframe class="pdf-frame" src="${rawSrc}"></iframe>`,
|
|
613
|
-
}),
|
|
614
|
-
);
|
|
615
|
-
return;
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
if (isImage) {
|
|
619
|
-
res.status(200).send(
|
|
620
|
-
renderFilePage({
|
|
621
|
-
title: `${repoName}/${stripped}`,
|
|
622
|
-
repoName,
|
|
623
|
-
gitInfo,
|
|
624
|
-
brokenLinks: linkScanner.getState(),
|
|
625
|
-
relPathPosix: toPosixPath(stripped),
|
|
626
|
-
querySuffix,
|
|
627
|
-
toggleIgnoredHref,
|
|
628
|
-
showIgnored,
|
|
629
|
-
fileName,
|
|
630
|
-
isMarkdown: false,
|
|
631
|
-
mediaType: "image",
|
|
632
|
-
renderedHtml: `<img class="image-preview" src="${rawSrc}" alt="${escapeHtml(fileName)}" />`,
|
|
633
|
-
}),
|
|
634
|
-
);
|
|
635
|
-
return;
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
if (st.size > maxBytes) {
|
|
639
|
-
res.status(200).send(
|
|
640
|
-
renderFilePage({
|
|
641
|
-
title: `${repoName}/${stripped}`,
|
|
642
|
-
repoName,
|
|
643
|
-
gitInfo,
|
|
644
|
-
brokenLinks: linkScanner.getState(),
|
|
645
|
-
relPathPosix: toPosixPath(stripped),
|
|
646
|
-
querySuffix,
|
|
647
|
-
toggleIgnoredHref,
|
|
648
|
-
showIgnored,
|
|
649
|
-
fileName,
|
|
650
|
-
isMarkdown: false,
|
|
651
|
-
renderedHtml: `<div class="note">File is too large to render (${formatBytes(
|
|
652
|
-
st.size,
|
|
653
|
-
)}). Use <a href="/raw/${encodePathForUrl(
|
|
654
|
-
toPosixPath(stripped),
|
|
655
|
-
)}${querySuffix}">Raw</a>.</div>`,
|
|
656
|
-
}),
|
|
657
|
-
);
|
|
658
|
-
return;
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
const raw = await fs.readFile(resolved);
|
|
662
|
-
const text = raw.toString("utf8");
|
|
663
|
-
|
|
664
|
-
let renderedHtml;
|
|
665
|
-
let mediaType;
|
|
666
|
-
if (isCsv) {
|
|
667
|
-
const delimiter = ext === ".tsv" ? "\t" : ",";
|
|
668
|
-
const rows = parseCsv(text, delimiter);
|
|
669
|
-
renderedHtml = renderCsvTable(rows, escapeHtml);
|
|
670
|
-
mediaType = "csv";
|
|
671
|
-
} else if (isMarkdown) {
|
|
672
|
-
const baseDir = toPosixPath(path.posix.dirname(toPosixPath(stripped)));
|
|
673
|
-
renderedHtml = md.render(text, { baseDirPosix: baseDir === "." ? "" : baseDir });
|
|
674
|
-
} else {
|
|
675
|
-
renderedHtml = md.renderCodeBlock(text, {
|
|
676
|
-
languageHint: ext ? ext.slice(1) : "",
|
|
677
|
-
});
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
res.status(200).send(
|
|
681
|
-
renderFilePage({
|
|
682
|
-
title: `${repoName}/${stripped}`,
|
|
683
|
-
repoName,
|
|
684
|
-
gitInfo,
|
|
685
|
-
brokenLinks: linkScanner.getState(),
|
|
686
|
-
relPathPosix: toPosixPath(stripped),
|
|
687
|
-
querySuffix,
|
|
688
|
-
toggleIgnoredHref,
|
|
689
|
-
showIgnored,
|
|
690
|
-
fileName,
|
|
691
|
-
isMarkdown,
|
|
692
|
-
mediaType,
|
|
693
|
-
renderedHtml,
|
|
694
|
-
}),
|
|
695
|
-
);
|
|
696
|
-
} catch (e) {
|
|
697
|
-
res
|
|
698
|
-
.status(e.statusCode || 500)
|
|
699
|
-
.send(renderErrorPage({ title: "Error", message: e.message }));
|
|
700
|
-
}
|
|
701
|
-
});
|
|
702
|
-
|
|
703
|
-
app.get(["/raw/*", "/raw"], async (req, res) => {
|
|
704
|
-
try {
|
|
705
|
-
const p = req.params[0] ?? "";
|
|
706
|
-
const { resolved } = await safeRealpath(repoRootReal, p);
|
|
707
|
-
const st = await statSafe(resolved);
|
|
708
|
-
if (st === null) {
|
|
709
|
-
const err = new Error("Permission denied");
|
|
710
|
-
err.statusCode = 403;
|
|
711
|
-
throw err;
|
|
712
|
-
}
|
|
713
|
-
if (!st.isFile) {
|
|
714
|
-
const err = new Error("Not a file");
|
|
715
|
-
err.statusCode = 400;
|
|
716
|
-
throw err;
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
const contentType = mime.contentType(path.extname(resolved)) || "application/octet-stream";
|
|
720
|
-
res.setHeader("Content-Type", contentType);
|
|
721
|
-
res.sendFile(resolved);
|
|
722
|
-
} catch (e) {
|
|
723
|
-
res
|
|
724
|
-
.status(e.statusCode || 500)
|
|
725
|
-
.send(renderErrorPage({ title: "Error", message: e.message }));
|
|
726
|
-
}
|
|
727
|
-
});
|
|
728
|
-
|
|
729
|
-
const server = http.createServer(app);
|
|
730
|
-
await new Promise((resolve) => server.listen(port, host, resolve));
|
|
731
|
-
|
|
732
|
-
if (watch) {
|
|
733
|
-
const watcher = chokidar.watch(repoRootReal, {
|
|
734
|
-
ignored: [
|
|
735
|
-
/(^|[/\\])\.git([/\\]|$)/,
|
|
736
|
-
/(^|[/\\])node_modules([/\\]|$)/,
|
|
737
|
-
],
|
|
738
|
-
ignoreInitial: true,
|
|
739
|
-
ignorePermissionErrors: true,
|
|
740
|
-
});
|
|
741
|
-
watcher.on("error", () => {
|
|
742
|
-
// Silently ignore watch errors (e.g., permission denied)
|
|
743
|
-
});
|
|
744
|
-
let pending = null;
|
|
745
|
-
watcher.on("all", () => {
|
|
746
|
-
if (pending) return;
|
|
747
|
-
pending = setTimeout(() => {
|
|
748
|
-
pending = null;
|
|
749
|
-
reloadHub.broadcastReload();
|
|
750
|
-
void loadGitIgnoreMatcher(repoRootReal).then((m) => (ignoreMatcher = m));
|
|
751
|
-
void linkScanner.triggerScan();
|
|
752
|
-
}, 100);
|
|
753
|
-
});
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
// eslint-disable-next-line no-console
|
|
757
|
-
console.log(`repoview: ${repoRootReal}`);
|
|
758
|
-
// eslint-disable-next-line no-console
|
|
759
|
-
console.log(`listening: http://${host}:${port}`);
|
|
760
|
-
}
|