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/CONTRIBUTING.md +32 -0
- package/DEVELOPMENT.md +69 -0
- package/LICENSE +22 -0
- package/README.md +75 -0
- package/package.json +53 -0
- package/public/app.css +443 -0
- package/public/app.js +120 -0
- package/src/cli.js +72 -0
- package/src/gitignore.js +34 -0
- package/src/linkcheck.js +312 -0
- package/src/markdown.js +344 -0
- package/src/server.js +487 -0
- package/src/views.js +373 -0
package/public/app.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
const shouldWatch = new URLSearchParams(location.search).get("watch") !== "0";
|
|
3
|
+
if (!shouldWatch) return;
|
|
4
|
+
|
|
5
|
+
let pollingTimer = null;
|
|
6
|
+
let lastRevision = null;
|
|
7
|
+
|
|
8
|
+
async function fetchRevision() {
|
|
9
|
+
const res = await fetch(`/rev?ts=${Date.now()}`, { cache: "no-store" });
|
|
10
|
+
if (!res.ok) throw new Error("rev fetch failed");
|
|
11
|
+
const json = await res.json();
|
|
12
|
+
return Number(json.revision);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function ensurePolling() {
|
|
16
|
+
if (pollingTimer) return;
|
|
17
|
+
try {
|
|
18
|
+
lastRevision = await fetchRevision();
|
|
19
|
+
} catch {
|
|
20
|
+
lastRevision = null;
|
|
21
|
+
}
|
|
22
|
+
pollingTimer = setInterval(async () => {
|
|
23
|
+
try {
|
|
24
|
+
const rev = await fetchRevision();
|
|
25
|
+
if (lastRevision != null && rev !== lastRevision) location.reload();
|
|
26
|
+
lastRevision = rev;
|
|
27
|
+
} catch {
|
|
28
|
+
// ignore
|
|
29
|
+
}
|
|
30
|
+
}, 2000);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const es = new EventSource("/events");
|
|
35
|
+
es.addEventListener("reload", () => {
|
|
36
|
+
location.reload();
|
|
37
|
+
});
|
|
38
|
+
es.addEventListener("error", () => {
|
|
39
|
+
// Some environments/proxies break SSE; fall back to polling.
|
|
40
|
+
void ensurePolling();
|
|
41
|
+
});
|
|
42
|
+
} catch {
|
|
43
|
+
void ensurePolling();
|
|
44
|
+
}
|
|
45
|
+
})();
|
|
46
|
+
|
|
47
|
+
function preserveQueryParamsOnInternalLinks(keys) {
|
|
48
|
+
const current = new URLSearchParams(location.search);
|
|
49
|
+
const keep = new URLSearchParams();
|
|
50
|
+
for (const k of keys) {
|
|
51
|
+
const v = current.get(k);
|
|
52
|
+
if (v != null) keep.set(k, v);
|
|
53
|
+
}
|
|
54
|
+
if ([...keep.keys()].length === 0) return;
|
|
55
|
+
|
|
56
|
+
for (const a of document.querySelectorAll("a[href]")) {
|
|
57
|
+
const href = a.getAttribute("href");
|
|
58
|
+
if (!href) continue;
|
|
59
|
+
if (!href.startsWith("/")) continue;
|
|
60
|
+
if (href.startsWith("/static/")) continue;
|
|
61
|
+
if (href.startsWith("/events")) continue;
|
|
62
|
+
|
|
63
|
+
const noPreserve = String(a.getAttribute("data-no-preserve") || "")
|
|
64
|
+
.split(",")
|
|
65
|
+
.map((s) => s.trim())
|
|
66
|
+
.filter(Boolean);
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const u = new URL(href, location.origin);
|
|
70
|
+
for (const [k, v] of keep.entries()) {
|
|
71
|
+
if (noPreserve.includes(k)) continue;
|
|
72
|
+
if (!u.searchParams.has(k)) u.searchParams.set(k, v);
|
|
73
|
+
}
|
|
74
|
+
a.setAttribute("href", u.pathname + u.search + u.hash);
|
|
75
|
+
} catch {
|
|
76
|
+
// ignore
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function renderMermaid() {
|
|
82
|
+
const nodes = document.querySelectorAll(".mermaid");
|
|
83
|
+
if (!nodes.length) return;
|
|
84
|
+
try {
|
|
85
|
+
const mod = await import("/static/vendor/mermaid/mermaid.esm.min.mjs");
|
|
86
|
+
const mermaid = mod.default ?? mod.mermaid ?? mod;
|
|
87
|
+
mermaid.initialize?.({ startOnLoad: false, securityLevel: "strict" });
|
|
88
|
+
if (typeof mermaid.run === "function") {
|
|
89
|
+
await mermaid.run({ nodes });
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
// Ignore; Mermaid is best-effort.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function renderMath() {
|
|
97
|
+
const root = document.querySelector(".markdown-body");
|
|
98
|
+
if (!root) return;
|
|
99
|
+
const renderMathInElement = window.renderMathInElement;
|
|
100
|
+
if (typeof renderMathInElement !== "function") return;
|
|
101
|
+
try {
|
|
102
|
+
renderMathInElement(root, {
|
|
103
|
+
delimiters: [
|
|
104
|
+
{ left: "$$", right: "$$", display: true },
|
|
105
|
+
{ left: "$", right: "$", display: false },
|
|
106
|
+
{ left: "\\[", right: "\\]", display: true },
|
|
107
|
+
{ left: "\\(", right: "\\)", display: false },
|
|
108
|
+
],
|
|
109
|
+
throwOnError: false,
|
|
110
|
+
});
|
|
111
|
+
} catch {
|
|
112
|
+
// Ignore; KaTeX is best-effort.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
window.addEventListener("load", () => {
|
|
117
|
+
preserveQueryParamsOnInternalLinks(["ignored", "watch"]);
|
|
118
|
+
renderMath();
|
|
119
|
+
renderMermaid();
|
|
120
|
+
});
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { startServer } from "./server.js";
|
|
6
|
+
|
|
7
|
+
function printHelp() {
|
|
8
|
+
// Keep this in sync with README.md
|
|
9
|
+
process.stdout.write(`repo-viewer
|
|
10
|
+
|
|
11
|
+
Serve a local Git repository as a GitHub-like website.
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
npm start -- --repo /path/to/repo [--host 127.0.0.1] [--port 3000] [--no-watch]
|
|
15
|
+
node src/cli.js --repo /path/to/repo [--host 127.0.0.1] [--port 3000] [--no-watch]
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--repo <path> Repository root (default: REPO_ROOT or project dir)
|
|
19
|
+
--host <host> Bind address (default: 127.0.0.1)
|
|
20
|
+
--port <port> Bind port (default: 3000)
|
|
21
|
+
--watch Enable live reload (default)
|
|
22
|
+
--no-watch Disable live reload
|
|
23
|
+
-h, --help Show this help
|
|
24
|
+
|
|
25
|
+
Environment:
|
|
26
|
+
REPO_ROOT, HOST, PORT
|
|
27
|
+
`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseArgs(argv) {
|
|
31
|
+
const args = { watch: true };
|
|
32
|
+
const rest = [];
|
|
33
|
+
for (let i = 0; i < argv.length; i++) {
|
|
34
|
+
const value = argv[i];
|
|
35
|
+
if (value === "-h" || value === "--help") args.help = true;
|
|
36
|
+
if (value === "--watch") args.watch = true;
|
|
37
|
+
else if (value === "--no-watch") args.watch = false;
|
|
38
|
+
else if (value === "--repo") args.repo = argv[++i];
|
|
39
|
+
else if (value === "--port") args.port = Number(argv[++i]);
|
|
40
|
+
else if (value === "--host") args.host = argv[++i];
|
|
41
|
+
else rest.push(value);
|
|
42
|
+
}
|
|
43
|
+
return { ...args, rest };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
47
|
+
const __dirname = path.dirname(__filename);
|
|
48
|
+
|
|
49
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
50
|
+
const { repo, port, host, watch, help } = parsed;
|
|
51
|
+
|
|
52
|
+
if (help) {
|
|
53
|
+
printHelp();
|
|
54
|
+
process.exit(0);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (port != null && !Number.isFinite(port)) {
|
|
58
|
+
process.stderr.write("Invalid --port value\n");
|
|
59
|
+
process.exit(2);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const repoRoot =
|
|
63
|
+
repo ??
|
|
64
|
+
process.env.REPO_ROOT ??
|
|
65
|
+
path.resolve(__dirname, "..");
|
|
66
|
+
|
|
67
|
+
await startServer({
|
|
68
|
+
repoRoot,
|
|
69
|
+
port: port || Number(process.env.PORT) || 3000,
|
|
70
|
+
host: host || process.env.HOST || "127.0.0.1",
|
|
71
|
+
watch,
|
|
72
|
+
});
|
package/src/gitignore.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import ignore from "ignore";
|
|
4
|
+
|
|
5
|
+
function toPosixPath(p) {
|
|
6
|
+
return p.split(path.sep).join("/");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function loadGitIgnoreMatcher(repoRootReal) {
|
|
10
|
+
const ig = ignore();
|
|
11
|
+
|
|
12
|
+
// Baseline ignores (never show these via toggle either).
|
|
13
|
+
ig.add([".git/"]);
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const content = await fs.readFile(path.join(repoRootReal, ".gitignore"), "utf8");
|
|
17
|
+
ig.add(content);
|
|
18
|
+
} catch {
|
|
19
|
+
// No .gitignore or unreadable; ignore.
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
ignores(relPathPosix, { isDir = false } = {}) {
|
|
24
|
+
const p = toPosixPath(String(relPathPosix || "").replace(/^\/+/, ""));
|
|
25
|
+
if (!p) return false;
|
|
26
|
+
if (ig.ignores(p)) return true;
|
|
27
|
+
if (isDir) {
|
|
28
|
+
const withSlash = p.endsWith("/") ? p : `${p}/`;
|
|
29
|
+
if (ig.ignores(withSlash)) return true;
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
package/src/linkcheck.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
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
|
+
}
|