From 95e1c12db07e7ec4b83afadcb4d83e4123431265 Mon Sep 17 00:00:00 2001 From: zakher Date: Wed, 8 Jul 2026 20:24:28 +0300 Subject: [PATCH] feat: adds `check-links.js` to find broken links --- build-scripts/check-links.js | 175 +++++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 build-scripts/check-links.js diff --git a/build-scripts/check-links.js b/build-scripts/check-links.js new file mode 100644 index 000000000..bbb34b804 --- /dev/null +++ b/build-scripts/check-links.js @@ -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); +});