repoview 0.5.1 → 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 +20 -9
- package/public/app.css +82 -0
- package/public/app.js +4 -2
- package/public/review.js +9 -6
- package/public/session.js +61 -0
- package/src/cli.js +0 -91
- package/src/gitignore.js +0 -34
- package/src/linkcheck.js +0 -312
- package/src/markdown.js +0 -518
- package/src/review-cli.js +0 -245
- package/src/server.js +0 -1126
- package/src/views.js +0 -657
package/src/linkcheck.js
DELETED
|
@@ -1,312 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
|
|
4
|
-
function toPosixPath(p) {
|
|
5
|
-
return p.split(path.sep).join("/");
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
function isWithinRoot(rootReal, candidateReal) {
|
|
9
|
-
if (candidateReal === rootReal) return true;
|
|
10
|
-
const rootWithSep = rootReal.endsWith(path.sep) ? rootReal : rootReal + path.sep;
|
|
11
|
-
return candidateReal.startsWith(rootWithSep);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
async function safeResolveExisting(repoRootReal, relPosixPath) {
|
|
15
|
-
const stripped = String(relPosixPath || "").replace(/^\/+/, "");
|
|
16
|
-
const resolved = path.resolve(repoRootReal, stripped);
|
|
17
|
-
if (!isWithinRoot(repoRootReal, resolved)) {
|
|
18
|
-
return { ok: false, reason: "escape", resolved: null, type: null };
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
try {
|
|
22
|
-
await fs.lstat(resolved);
|
|
23
|
-
} catch {
|
|
24
|
-
return { ok: false, reason: "missing", resolved: null, type: null };
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
let real;
|
|
28
|
-
try {
|
|
29
|
-
real = await fs.realpath(resolved);
|
|
30
|
-
} catch {
|
|
31
|
-
return { ok: false, reason: "missing", resolved: null, type: null };
|
|
32
|
-
}
|
|
33
|
-
if (!isWithinRoot(repoRootReal, real)) {
|
|
34
|
-
return { ok: false, reason: "escape", resolved: null, type: null };
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const stat = await fs.stat(real);
|
|
38
|
-
return {
|
|
39
|
-
ok: true,
|
|
40
|
-
reason: null,
|
|
41
|
-
resolved: real,
|
|
42
|
-
type: stat.isDirectory() ? "dir" : stat.isFile() ? "file" : "other",
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function extractInternalUrlsFromHtml(html) {
|
|
47
|
-
const urls = [];
|
|
48
|
-
const re = /\b(?:href|src)=(["'])([^"']+)\1/gi;
|
|
49
|
-
let match;
|
|
50
|
-
while ((match = re.exec(html))) {
|
|
51
|
-
const raw = match[2].trim();
|
|
52
|
-
if (!raw || raw.startsWith("#")) continue;
|
|
53
|
-
urls.push(raw);
|
|
54
|
-
}
|
|
55
|
-
return urls;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function decodePosixPathFromUrlPath(urlPathname, prefix) {
|
|
59
|
-
const rest = urlPathname.slice(prefix.length);
|
|
60
|
-
const stripped = rest.replace(/^\/+/, "");
|
|
61
|
-
const segments = stripped.split("/").filter(Boolean);
|
|
62
|
-
try {
|
|
63
|
-
return segments.map((s) => decodeURIComponent(s)).join("/");
|
|
64
|
-
} catch {
|
|
65
|
-
return null;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function isMarkdownFile(relPosix) {
|
|
70
|
-
const lower = relPosix.toLowerCase();
|
|
71
|
-
const base = path.posix.basename(lower);
|
|
72
|
-
if (base === "readme" || base.startsWith("readme.")) return true;
|
|
73
|
-
const ext = path.posix.extname(lower).replace(/^\./, "");
|
|
74
|
-
return new Set(["md", "markdown", "mdown", "mkd", "mkdn"]).has(ext);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
async function listMarkdownFiles(repoRootReal, { maxFiles, isIgnored } = {}) {
|
|
78
|
-
const results = [];
|
|
79
|
-
const stack = [{ abs: repoRootReal, relPosix: "" }];
|
|
80
|
-
const ignoredNames = new Set([".git", "node_modules"]);
|
|
81
|
-
|
|
82
|
-
while (stack.length) {
|
|
83
|
-
const { abs, relPosix } = stack.pop();
|
|
84
|
-
let entries;
|
|
85
|
-
try {
|
|
86
|
-
entries = await fs.readdir(abs, { withFileTypes: true });
|
|
87
|
-
} catch {
|
|
88
|
-
continue;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
for (const e of entries) {
|
|
92
|
-
if (ignoredNames.has(e.name)) continue;
|
|
93
|
-
const childAbs = path.join(abs, e.name);
|
|
94
|
-
const childRel = relPosix ? `${relPosix}/${e.name}` : e.name;
|
|
95
|
-
const childRelPosix = toPosixPath(childRel);
|
|
96
|
-
|
|
97
|
-
if (typeof isIgnored === "function" && isIgnored(childRelPosix, { isDir: e.isDirectory() }))
|
|
98
|
-
continue;
|
|
99
|
-
|
|
100
|
-
if (e.isDirectory()) {
|
|
101
|
-
stack.push({ abs: childAbs, relPosix: childRelPosix });
|
|
102
|
-
} else if (e.isFile()) {
|
|
103
|
-
if (isMarkdownFile(childRelPosix)) results.push(childRelPosix);
|
|
104
|
-
if (maxFiles && results.length >= maxFiles) return results;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
results.sort();
|
|
109
|
-
return results;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export function createRepoLinkScanner({ repoRootReal, markdownRenderer, isIgnored }) {
|
|
113
|
-
let current = {
|
|
114
|
-
status: "idle",
|
|
115
|
-
lastResult: null,
|
|
116
|
-
lastError: null,
|
|
117
|
-
lastStartedAt: null,
|
|
118
|
-
lastFinishedAt: null,
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
let scanRunning = false;
|
|
122
|
-
let scanQueued = false;
|
|
123
|
-
|
|
124
|
-
async function scanOnce({
|
|
125
|
-
maxMarkdownFiles = 5000,
|
|
126
|
-
maxBytesPerFile = 2 * 1024 * 1024,
|
|
127
|
-
concurrency = 16,
|
|
128
|
-
} = {}) {
|
|
129
|
-
const startedAt = Date.now();
|
|
130
|
-
current = { ...current, status: "running", lastError: null, lastStartedAt: startedAt };
|
|
131
|
-
|
|
132
|
-
const markdownFiles = await listMarkdownFiles(repoRootReal, {
|
|
133
|
-
maxFiles: maxMarkdownFiles,
|
|
134
|
-
isIgnored,
|
|
135
|
-
});
|
|
136
|
-
const broken = [];
|
|
137
|
-
let filesScanned = 0;
|
|
138
|
-
let urlsChecked = 0;
|
|
139
|
-
|
|
140
|
-
const queue = markdownFiles.slice();
|
|
141
|
-
const workers = Array.from({ length: concurrency }, async () => {
|
|
142
|
-
while (queue.length) {
|
|
143
|
-
const relPosix = queue.pop();
|
|
144
|
-
if (!relPosix) return;
|
|
145
|
-
|
|
146
|
-
const abs = path.join(repoRootReal, relPosix);
|
|
147
|
-
let stat;
|
|
148
|
-
try {
|
|
149
|
-
stat = await fs.stat(abs);
|
|
150
|
-
} catch {
|
|
151
|
-
continue;
|
|
152
|
-
}
|
|
153
|
-
if (stat.size > maxBytesPerFile) continue;
|
|
154
|
-
|
|
155
|
-
let text;
|
|
156
|
-
try {
|
|
157
|
-
text = await fs.readFile(abs, "utf8");
|
|
158
|
-
} catch {
|
|
159
|
-
continue;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
filesScanned++;
|
|
163
|
-
const baseDirPosix = path.posix.dirname(relPosix);
|
|
164
|
-
const env = { baseDirPosix: baseDirPosix === "." ? "" : baseDirPosix };
|
|
165
|
-
let html = "";
|
|
166
|
-
try {
|
|
167
|
-
html = markdownRenderer.render(text, env);
|
|
168
|
-
} catch {
|
|
169
|
-
continue;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const urls = extractInternalUrlsFromHtml(html);
|
|
173
|
-
for (const raw of urls) {
|
|
174
|
-
if (raw.startsWith("http://") || raw.startsWith("https://")) continue;
|
|
175
|
-
if (raw.startsWith("//")) continue;
|
|
176
|
-
if (raw.startsWith("mailto:") || raw.startsWith("tel:")) continue;
|
|
177
|
-
if (raw.startsWith("data:")) continue;
|
|
178
|
-
urlsChecked++;
|
|
179
|
-
|
|
180
|
-
let urlPath;
|
|
181
|
-
try {
|
|
182
|
-
urlPath = new URL(raw, "http://local").pathname;
|
|
183
|
-
} catch {
|
|
184
|
-
broken.push({
|
|
185
|
-
source: relPosix,
|
|
186
|
-
url: raw,
|
|
187
|
-
kind: "url",
|
|
188
|
-
reason: "invalid_url",
|
|
189
|
-
});
|
|
190
|
-
continue;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
if (urlPath === "/events" || urlPath.startsWith("/static/")) continue;
|
|
194
|
-
if (urlPath === "/broken-links" || urlPath === "/broken-links.json") continue;
|
|
195
|
-
|
|
196
|
-
let expected = null;
|
|
197
|
-
let expectType = null;
|
|
198
|
-
if (urlPath.startsWith("/blob/")) {
|
|
199
|
-
expected = decodePosixPathFromUrlPath(urlPath, "/blob/");
|
|
200
|
-
expectType = "blob";
|
|
201
|
-
} else if (urlPath.startsWith("/tree/")) {
|
|
202
|
-
expected = decodePosixPathFromUrlPath(urlPath, "/tree/");
|
|
203
|
-
expectType = "tree";
|
|
204
|
-
} else if (urlPath.startsWith("/raw/")) {
|
|
205
|
-
expected = decodePosixPathFromUrlPath(urlPath, "/raw/");
|
|
206
|
-
expectType = "raw";
|
|
207
|
-
} else {
|
|
208
|
-
broken.push({
|
|
209
|
-
source: relPosix,
|
|
210
|
-
url: raw,
|
|
211
|
-
kind: "url",
|
|
212
|
-
reason: "unknown_route",
|
|
213
|
-
});
|
|
214
|
-
continue;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
if (expected == null) {
|
|
218
|
-
broken.push({
|
|
219
|
-
source: relPosix,
|
|
220
|
-
url: raw,
|
|
221
|
-
kind: expectType,
|
|
222
|
-
reason: "bad_encoding",
|
|
223
|
-
});
|
|
224
|
-
continue;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
if (typeof isIgnored === "function") {
|
|
228
|
-
if (expectType === "tree" && isIgnored(expected, { isDir: true })) continue;
|
|
229
|
-
if (expectType !== "tree" && isIgnored(expected, { isDir: false })) continue;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const resolved = await safeResolveExisting(repoRootReal, expected);
|
|
233
|
-
if (!resolved.ok) {
|
|
234
|
-
broken.push({
|
|
235
|
-
source: relPosix,
|
|
236
|
-
url: raw,
|
|
237
|
-
kind: expectType,
|
|
238
|
-
reason: resolved.reason,
|
|
239
|
-
target: expected,
|
|
240
|
-
});
|
|
241
|
-
continue;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
if (expectType === "raw" && resolved.type !== "file") {
|
|
245
|
-
broken.push({
|
|
246
|
-
source: relPosix,
|
|
247
|
-
url: raw,
|
|
248
|
-
kind: expectType,
|
|
249
|
-
reason: "not_a_file",
|
|
250
|
-
target: expected,
|
|
251
|
-
});
|
|
252
|
-
} else if (expectType === "tree" && resolved.type !== "dir") {
|
|
253
|
-
broken.push({
|
|
254
|
-
source: relPosix,
|
|
255
|
-
url: raw,
|
|
256
|
-
kind: expectType,
|
|
257
|
-
reason: "not_a_directory",
|
|
258
|
-
target: expected,
|
|
259
|
-
});
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
await Promise.all(workers);
|
|
266
|
-
|
|
267
|
-
const finishedAt = Date.now();
|
|
268
|
-
const result = {
|
|
269
|
-
startedAt,
|
|
270
|
-
finishedAt,
|
|
271
|
-
durationMs: finishedAt - startedAt,
|
|
272
|
-
filesScanned,
|
|
273
|
-
urlsChecked,
|
|
274
|
-
broken: broken.sort((a, b) => a.source.localeCompare(b.source) || a.url.localeCompare(b.url)),
|
|
275
|
-
};
|
|
276
|
-
|
|
277
|
-
current = {
|
|
278
|
-
status: "idle",
|
|
279
|
-
lastResult: result,
|
|
280
|
-
lastError: null,
|
|
281
|
-
lastStartedAt: startedAt,
|
|
282
|
-
lastFinishedAt: finishedAt,
|
|
283
|
-
};
|
|
284
|
-
return result;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
async function triggerScan(options) {
|
|
288
|
-
if (scanRunning) {
|
|
289
|
-
scanQueued = true;
|
|
290
|
-
return current;
|
|
291
|
-
}
|
|
292
|
-
scanRunning = true;
|
|
293
|
-
try {
|
|
294
|
-
await scanOnce(options);
|
|
295
|
-
} catch (e) {
|
|
296
|
-
current = { ...current, status: "idle", lastError: String(e?.message || e) };
|
|
297
|
-
} finally {
|
|
298
|
-
scanRunning = false;
|
|
299
|
-
if (scanQueued) {
|
|
300
|
-
scanQueued = false;
|
|
301
|
-
void triggerScan(options);
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
return current;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
function getState() {
|
|
308
|
-
return current;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
return { scanOnce, triggerScan, getState };
|
|
312
|
-
}
|