mirror of
https://github.com/Dokploy/templates.git
synced 2026-07-22 22:35:24 +02:00
feat: adds check-links.js to find broken links (#983)
This commit is contained in:
175
build-scripts/check-links.js
Normal file
175
build-scripts/check-links.js
Normal file
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const blueprintsDir = path.join(repoRoot, "blueprints");
|
||||
|
||||
const MAX_CONCURRENCY = 10;
|
||||
const TIMEOUT_MS = 15000;
|
||||
const USER_AGENT = "Mozilla/5.0 (compatible; DokployTemplatesLinkChecker/1.0)";
|
||||
|
||||
const results = { ok: [], broken: [], skipped: [] };
|
||||
let completed = 0;
|
||||
let total = 0;
|
||||
|
||||
async function checkUrl(url, label, blueprintId) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "HEAD",
|
||||
signal: controller.signal,
|
||||
redirect: "follow",
|
||||
headers: { "User-Agent": USER_AGENT },
|
||||
});
|
||||
|
||||
if (res.ok || res.status === 403 || res.status === 429) {
|
||||
if (res.status === 429) {
|
||||
results.skipped.push({ blueprintId, label, url, status: 429, reason: "Rate limited" });
|
||||
} else if (res.status === 403) {
|
||||
results.ok.push({ blueprintId, label, url, status: 403, note: "Forbidden (may still be valid)" });
|
||||
} else {
|
||||
results.ok.push({ blueprintId, label, url, status: res.status });
|
||||
}
|
||||
} else {
|
||||
// If HEAD fails, try GET as fallback
|
||||
try {
|
||||
const getRes = await fetch(url, {
|
||||
method: "GET",
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
redirect: "follow",
|
||||
headers: { "User-Agent": USER_AGENT },
|
||||
});
|
||||
if (getRes.ok || getRes.status === 403) {
|
||||
results.ok.push({ blueprintId, label, url, status: getRes.status, note: `HEAD returned ${res.status}, GET ok` });
|
||||
} else {
|
||||
results.broken.push({ blueprintId, label, url, status: getRes.status });
|
||||
}
|
||||
} catch {
|
||||
results.broken.push({ blueprintId, label, url, status: res.status, reason: "HEAD & GET both failed" });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name === "AbortError") {
|
||||
results.broken.push({ blueprintId, label, url, status: null, reason: "Timeout" });
|
||||
} else if (err.code === "ENOTFOUND" || err.code === "EAI_AGAIN") {
|
||||
results.broken.push({ blueprintId, label, url, status: null, reason: "DNS resolution failed" });
|
||||
} else if (err.code === "ECONNREFUSED") {
|
||||
results.broken.push({ blueprintId, label, url, status: null, reason: "Connection refused" });
|
||||
} else if (err.code === "ECONNRESET") {
|
||||
results.broken.push({ blueprintId, label, url, status: null, reason: "Connection reset" });
|
||||
} else if (err.code === "ENETUNREACH") {
|
||||
results.broken.push({ blueprintId, label, url, status: null, reason: "Network unreachable" });
|
||||
} else if (err.code === "ERR_INVALID_URL") {
|
||||
results.broken.push({ blueprintId, label, url, status: null, reason: "Invalid URL" });
|
||||
} else {
|
||||
results.broken.push({ blueprintId, label, url, status: null, reason: err.message || String(err) });
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkLinks(entries) {
|
||||
const queue = [];
|
||||
for (const [blueprintId, links] of entries) {
|
||||
for (const [label, url] of Object.entries(links)) {
|
||||
if (!url || typeof url !== "string" || url.trim() === "") {
|
||||
results.skipped.push({ blueprintId, label, url, reason: "Empty URL" });
|
||||
continue;
|
||||
}
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) {
|
||||
results.skipped.push({ blueprintId, label, url: trimmed, reason: "Non-HTTP URL" });
|
||||
continue;
|
||||
}
|
||||
total++;
|
||||
queue.push({ url: trimmed, label, blueprintId });
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nChecking ${total} links across ${entries.length} templates...\n`);
|
||||
|
||||
const runNext = async () => {
|
||||
while (queue.length > 0) {
|
||||
const item = queue.shift();
|
||||
await checkUrl(item.url, item.label, item.blueprintId);
|
||||
completed++;
|
||||
if (completed % 50 === 0 || completed === total) {
|
||||
const pct = ((completed / total) * 100).toFixed(1);
|
||||
console.log(` Progress: ${completed}/${total} (${pct}%) — ${results.broken.length} broken so far`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const workers = Array.from({ length: Math.min(MAX_CONCURRENCY, total || 1) }, () => runNext());
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
function printReport() {
|
||||
console.log("\n" + "=".repeat(70));
|
||||
console.log("LINK CHECK REPORT");
|
||||
console.log("=".repeat(70));
|
||||
console.log(` Total checked : ${total}`);
|
||||
console.log(` OK : ${results.ok.length}`);
|
||||
console.log(` Broken : ${results.broken.length}`);
|
||||
console.log(` Skipped : ${results.skipped.length}`);
|
||||
|
||||
if (results.broken.length > 0) {
|
||||
console.log("\n" + "-".repeat(70));
|
||||
console.log("BROKEN LINKS:");
|
||||
console.log("-".repeat(70));
|
||||
for (const b of results.broken) {
|
||||
const status = b.status ? `HTTP ${b.status}` : "N/A";
|
||||
const reason = b.reason ? ` — ${b.reason}` : "";
|
||||
console.log(` [${b.blueprintId}] ${b.label}: ${b.url}`);
|
||||
console.log(` Status: ${status}${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (results.skipped.length > 0) {
|
||||
console.log("\n" + "-".repeat(70));
|
||||
console.log("SKIPPED:");
|
||||
console.log("-".repeat(70));
|
||||
for (const s of results.skipped) {
|
||||
console.log(` [${s.blueprintId}] ${s.label}: ${s.url || "(empty)"} — ${s.reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dirs = fs
|
||||
.readdirSync(blueprintsDir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const entries = [];
|
||||
|
||||
for (const dir of dirs) {
|
||||
const metaFile = path.join(blueprintsDir, dir, "meta.json");
|
||||
if (!fs.existsSync(metaFile)) continue;
|
||||
|
||||
try {
|
||||
const entry = JSON.parse(fs.readFileSync(metaFile, "utf8"));
|
||||
if (entry.links && typeof entry.links === "object") {
|
||||
entries.push([entry.id || dir, entry.links]);
|
||||
}
|
||||
} catch {
|
||||
// skip invalid JSON
|
||||
}
|
||||
}
|
||||
|
||||
await checkLinks(entries);
|
||||
printReport();
|
||||
|
||||
process.exit(results.broken.length > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Fatal error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user