From f9c78fa9f9dd913bef98098745c3b88e75f0efcf Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 3 Jul 2026 16:23:23 +0200 Subject: [PATCH] ci for broken links + fix broken links --- .github/scripts/check-docs-links.mjs | 126 ++++++++++++++++++ .github/workflows/check-docs-links.yml | 23 ++++ .../apps/editor/component/components.ts | 14 +- 3 files changed, 156 insertions(+), 7 deletions(-) create mode 100644 .github/scripts/check-docs-links.mjs create mode 100644 .github/workflows/check-docs-links.yml diff --git a/.github/scripts/check-docs-links.mjs b/.github/scripts/check-docs-links.mjs new file mode 100644 index 0000000000..122d098197 --- /dev/null +++ b/.github/scripts/check-docs-links.mjs @@ -0,0 +1,126 @@ +// Extracts every windmill.dev/docs link referenced in the frontend source and +// verifies none of them 404. Run: `node .github/scripts/check-docs-links.mjs`. +// Used by the check-docs-links GitHub workflow (release / manual trigger only). + +import { readdir, readFile } from 'node:fs/promises' +import { join, extname } from 'node:path' + +const ROOT = 'frontend/src' +const EXTS = new Set(['.ts', '.js', '.svelte', '.mjs', '.cjs']) +const DOCS_RE = /https?:\/\/(?:www\.)?windmill\.dev\/docs\/[^\s"'`)>\]}]*/g +// `const someBaseUrl = 'https://www.windmill.dev/docs/...'` used later as `${someBaseUrl}/foo` +const BASE_RE = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*['"`](https?:\/\/(?:www\.)?windmill\.dev\/docs\/[^'"`]+)['"`]/g + +const CONCURRENCY = 24 +const TIMEOUT_MS = 20000 +const RETRIES = 2 + +async function walk(dir) { + const out = [] + for (const entry of await readdir(dir, { withFileTypes: true })) { + const p = join(dir, entry.name) + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === '.svelte-kit') continue + out.push(...(await walk(p))) + } else if (EXTS.has(extname(entry.name))) { + out.push(p) + } + } + return out +} + +// url (no fragment) -> Set of source files it appears in +const urls = new Map() +const unresolved = [] + +function record(url, file) { + const clean = url + .replace(/\\.*$/, '') // cut at an escape sequence embedded in a string literal (e.g. \n) + .replace(/#.*$/, '') // drop anchor fragment — irrelevant to a 404 check + .replace(/[.,;:'")\]]+$/, '') + if (!clean) return + // A `{`/`${` means the URL is built from an unresolved template/interpolation var. + if (clean.includes('{')) { + unresolved.push(`${clean} (${file})`) + return + } + if (!urls.has(clean)) urls.set(clean, new Set()) + urls.get(clean).add(file) +} + +for (const file of await walk(ROOT)) { + let content = await readFile(file, 'utf8') + // Inline file-local base-url constants so `${base}/page` template literals resolve. + const bases = [] + for (const m of content.matchAll(BASE_RE)) bases.push({ name: m[1], value: m[2], decl: m[0] }) + for (const { name, value } of bases) { + content = content.replaceAll('${' + name + '}', value) + } + // Blank each base declaration so a prefix-only base (no index page of its own, + // e.g. .../app_configuration_settings) isn't checked as a standalone link. + // A genuinely bare `${base}` usage was already inlined above, so it's still covered. + for (const { decl } of bases) content = content.replace(decl, '') + for (const m of content.matchAll(DOCS_RE)) record(m[0], file) +} + +const allUrls = [...urls.keys()].sort() +console.log(`Found ${allUrls.length} distinct docs links across ${ROOT}`) +if (unresolved.length) { + console.log(`\n⚠️ ${unresolved.length} link(s) built from an unrecognized base URL — skipped (register the base const so they can be checked):`) + for (const u of [...new Set(unresolved)].sort()) console.log(` ${u}`) +} + +async function check(url) { + for (let attempt = 0; attempt <= RETRIES; attempt++) { + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS) + try { + let res = await fetch(url, { + method: 'HEAD', + redirect: 'follow', + signal: ctrl.signal, + headers: { 'user-agent': 'windmill-docs-link-check' } + }) + // Some hosts reject HEAD — fall back to GET. + if (res.status === 405 || res.status === 501) { + res = await fetch(url, { + method: 'GET', + redirect: 'follow', + signal: ctrl.signal, + headers: { 'user-agent': 'windmill-docs-link-check' } + }) + } + clearTimeout(timer) + return { url, status: res.status, ok: res.status < 400 } + } catch (err) { + clearTimeout(timer) + if (attempt === RETRIES) return { url, status: 0, ok: false, error: String(err?.message || err) } + await new Promise((r) => setTimeout(r, 500 * (attempt + 1))) + } + } +} + +// Simple concurrency pool. +const results = [] +let idx = 0 +async function worker() { + while (idx < allUrls.length) { + const url = allUrls[idx++] + results.push(await check(url)) + } +} +await Promise.all(Array.from({ length: CONCURRENCY }, worker)) + +const failures = results.filter((r) => !r.ok) +if (failures.length === 0) { + console.log(`\n✅ All ${allUrls.length} docs links are reachable.`) + process.exit(0) +} + +console.log(`\n❌ ${failures.length} broken docs link(s):`) +for (const f of failures.sort((a, b) => a.url.localeCompare(b.url))) { + console.log(`\n ${f.url}`) + console.log(` status: ${f.error ? `error (${f.error})` : f.status}`) + for (const file of urls.get(f.url)) console.log(` ↳ ${file}`) +} +process.exit(1) diff --git a/.github/workflows/check-docs-links.yml b/.github/workflows/check-docs-links.yml new file mode 100644 index 0000000000..ec17c06771 --- /dev/null +++ b/.github/workflows/check-docs-links.yml @@ -0,0 +1,23 @@ +name: Check frontend docs links + +on: + push: + tags: + - "v*" + workflow_dispatch: + +jobs: + check-docs-links: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: | + frontend/src + .github/scripts + - uses: actions/setup-node@v4 + with: + node-version: "22.x" + - name: Verify docs links are not 404 + run: node .github/scripts/check-docs-links.mjs diff --git a/frontend/src/lib/components/apps/editor/component/components.ts b/frontend/src/lib/components/apps/editor/component/components.ts index 707d992799..eb4028752d 100644 --- a/frontend/src/lib/components/apps/editor/component/components.ts +++ b/frontend/src/lib/components/apps/editor/component/components.ts @@ -1171,7 +1171,7 @@ export const components = { chatcomponent: { name: 'Chat', icon: MessageSquare, - documentationLink: `${documentationBaseUrl}/chat`, + documentationLink: `${documentationBaseUrl}/app_component_library`, dims: '3:8-6:12' as AppComponentDimensions, customCss: { container: { class: '', style: '' }, @@ -1299,7 +1299,7 @@ export const components = { jobprogressbarcomponent: { name: 'Progress Bar by Job Id', icon: Monitor, - documentationLink: `${documentationBaseUrl}/progress_bar`, + documentationLink: `${documentationBaseUrl}/app_component_library`, dims: '2:2-6:2' as AppComponentDimensions, customCss: { header: { class: '', style: '' }, @@ -1467,7 +1467,7 @@ export const components = { name: 'Code Input', icon: Code, dims: '2:1-4:4' as AppComponentDimensions, - documentationLink: `${documentationBaseUrl}/code`, + documentationLink: `${documentationBaseUrl}/code_input`, customCss: { text: { class: '', style: '' }, container: { class: '', style: '' } @@ -1810,7 +1810,7 @@ export const components = { piechartcomponent: { name: 'Pie Chart', icon: PieChart, - documentationLink: `${documentationBaseUrl}/pie_chart`, + documentationLink: `${documentationBaseUrl}/chartjs`, dims: '2:8-6:8' as AppComponentDimensions, customCss: { container: { class: '', style: '' } @@ -1919,7 +1919,7 @@ export const components = { barchartcomponent: { name: 'Bar/Line Chart', icon: BarChart4, - documentationLink: `${documentationBaseUrl}/bar_line_chart`, + documentationLink: `${documentationBaseUrl}/chartjs`, dims: '2:8-6:8' as AppComponentDimensions, customCss: { container: { class: '', style: '' } @@ -2132,7 +2132,7 @@ This is a paragraph. timeseriescomponent: { name: 'Timeseries', icon: GripHorizontal, - documentationLink: `${documentationBaseUrl}/timeseries`, + documentationLink: `${documentationBaseUrl}/chartjs`, dims: '2:8-6:8' as AppComponentDimensions, customCss: { container: { class: '', style: '' } @@ -2206,7 +2206,7 @@ This is a paragraph. scatterchartcomponent: { name: 'Scatter Chart', icon: GripHorizontal, - documentationLink: `${documentationBaseUrl}/scatter_chart`, + documentationLink: `${documentationBaseUrl}/chartjs`, dims: '2:8-6:8' as AppComponentDimensions, customCss: { container: { class: '', style: '' }