diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 453967b6b2..33220b8206 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,40 @@ concurrency: cancel-in-progress: true jobs: + checks: + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + + - name: Install Dependencies + run: npm ci + + # pretypecheck generates src/github-stars.json, which is gitignored and so + # absent on a fresh checkout. GITHUB_TOKEN lifts the anonymous API limit; + # the script falls back safely if the fetch fails. + - name: Typecheck + run: npm run typecheck + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check docs links + run: npm run check:links + + - name: Check licence headers + run: npm run check:headers + + - name: Validate .asf.yaml + run: >- + python3 -c "import sys,yaml; yaml.safe_load(open('.asf.yaml')); + print('.asf.yaml parses')" + build: runs-on: ubuntu-latest steps: @@ -28,3 +62,5 @@ jobs: - name: Build Website run: npm run build + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/package.json b/package.json index 1bca9fdbd4..3ead87ce20 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,10 @@ "prebuild": "node scripts/fetch-github-stars.mjs && node scripts/generate-docs-markdown.mjs", "build": "next build", "start": "next start", + "pretypecheck": "node scripts/fetch-github-stars.mjs", + "typecheck": "tsc --noEmit", + "check:links": "node scripts/check-docs-links.mjs", + "check:headers": "node scripts/check-license-headers.mjs", "postinstall": "fumadocs-mdx" }, "dependencies": { diff --git a/scripts/check-docs-links.mjs b/scripts/check-docs-links.mjs new file mode 100644 index 0000000000..fb09fea65a --- /dev/null +++ b/scripts/check-docs-links.mjs @@ -0,0 +1,111 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Fails if an internal /docs link points at a page that does not exist, if an + * .mdx page is missing from its directory's meta.json, or if a local asset + * referenced from content/ is not present under public/. + * + * All three are clean as of this commit, so this locks in a good state rather + * than fixing a broken one. No dependencies: plain Node. + */ + +import { readdirSync, readFileSync, statSync, existsSync } from "fs"; +import { join, relative } from "path"; + +const ROOT = "content/docs"; + +function walk(dir) { + return readdirSync(dir).flatMap((name) => { + const p = join(dir, name); + return statSync(p).isDirectory() ? walk(p) : [p]; + }); +} + +const files = walk(ROOT).filter((f) => f.endsWith(".mdx")); + +const routes = new Set(); +for (const f of files) { + const rel = relative(ROOT, f).slice(0, -4); + routes.add("/docs/" + rel.replace(/\/index$/, "")); + if (rel.endsWith("index")) routes.add("/docs/" + rel.slice(0, -6)); +} + +const problems = []; + +const linkPattern = /\]\((\/docs\/[^)#\s]*)/g; +const links = new Map(); +for (const f of files) { + const text = readFileSync(f, "utf8"); + for (const m of text.matchAll(linkPattern)) { + const target = m[1].replace(/\/$/, ""); + if (!links.has(target)) links.set(target, new Set()); + links.get(target).add(f); + } +} +for (const [target, sources] of links) { + if (!routes.has(target)) { + problems.push(`unresolved link ${target} (in ${[...sources].sort().join(", ")})`); + } +} + +const dirs = new Set(files.map((f) => f.slice(0, f.lastIndexOf("/")))); +for (const dir of dirs) { + const metaPath = join(dir, "meta.json"); + if (!existsSync(metaPath)) continue; + let listed; + try { + listed = new Set(JSON.parse(readFileSync(metaPath, "utf8")).pages ?? []); + } catch (e) { + problems.push(`unparseable ${metaPath}: ${e.message}`); + continue; + } + for (const f of files.filter((f) => f.startsWith(dir + "/") && !f.slice(dir.length + 1).includes("/"))) { + const stem = f.slice(dir.length + 1, -4); + if (stem !== "index" && !listed.has(stem)) { + problems.push(`${f} is not listed in ${metaPath}`); + } + } +} + +// Local assets referenced from any content page must exist under public/. +// A missing image does not fail the build, it just renders broken on the site. +const CONTENT = "content"; +const assetPattern = /(?:\]\(|src=")(\/[^)"\s]+\.(?:png|jpe?g|svg|gif|webp|ico|pdf))/g; +const contentFiles = walk(CONTENT).filter((f) => f.endsWith(".mdx") || f.endsWith(".md")); +const assets = new Map(); +for (const f of contentFiles) { + const text = readFileSync(f, "utf8"); + for (const m of text.matchAll(assetPattern)) { + if (!assets.has(m[1])) assets.set(m[1], new Set()); + assets.get(m[1]).add(f); + } +} +for (const [asset, sources] of assets) { + if (!existsSync(join("public", asset))) { + problems.push(`missing asset public${asset} (referenced in ${[...sources].sort().join(", ")})`); + } +} + +console.log( + `docs: ${files.length} pages, ${links.size} distinct internal links, ` + + `${assets.size} local assets, ${problems.length} problem(s)`, +); +for (const p of problems) console.error(" " + p); +process.exit(problems.length ? 1 : 0); diff --git a/scripts/check-license-headers.mjs b/scripts/check-license-headers.mjs new file mode 100644 index 0000000000..1b3b42d1f7 --- /dev/null +++ b/scripts/check-license-headers.mjs @@ -0,0 +1,57 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Fails if a source file under src/ or scripts/ has no ASF licence header. + * + * Strictly speaking the ASF source-header policy governs files shipped in a + * release, and this website is published rather than released - so this is a + * house-style rule, not a compliance gate. Every file in scope carries a + * header as of this commit, which is why it can enforce rather than report: + * the bar is already met, and this keeps it met. + */ + +import { readdirSync, readFileSync, statSync } from "fs"; +import { join } from "path"; + +const DIRS = ["src", "scripts"]; +const EXTS = [".ts", ".tsx", ".mjs", ".css"]; + +const MARKER = "Licensed to the Apache Software Foundation"; + +function walk(dir) { + return readdirSync(dir).flatMap((name) => { + const p = join(dir, name); + return statSync(p).isDirectory() ? walk(p) : [p]; + }); +} + +const files = DIRS.flatMap(walk).filter((f) => EXTS.some((e) => f.endsWith(e))); +const missing = files.filter((f) => !readFileSync(f, "utf8").includes(MARKER)); + +console.log(`licence headers: ${files.length} files checked, ${missing.length} without a header`); +for (const f of missing) console.log(" " + f); + +if (missing.length) { + console.error( + "\nAdd the ASF header (copy one from a neighbouring file) or, if the file " + + "genuinely should not carry one, exclude it here with a comment saying why.", + ); + process.exit(1); +} diff --git a/scripts/fetch-github-stars.mjs b/scripts/fetch-github-stars.mjs index ff90c60393..de0e8797c0 100644 --- a/scripts/fetch-github-stars.mjs +++ b/scripts/fetch-github-stars.mjs @@ -17,32 +17,61 @@ * under the License. */ -import { writeFileSync } from "fs"; +import { writeFileSync, readFileSync, existsSync } from "fs"; -const FALLBACK = "3.9K"; +// Only used when there is no previously fetched value on disk. Keep it roughly +// current: a stale fallback silently ships a wrong number on the site. +const FALLBACK = "4.7K"; + +const OUT = new URL("../src/github-stars.json", import.meta.url); function formatStars(count) { return count >= 1000 ? `${(count / 1000).toFixed(1)}K` : String(count); } +function existingStars() { + if (!existsSync(OUT)) return null; + try { + const value = JSON.parse(readFileSync(OUT, "utf8")).stars; + return typeof value === "string" ? value : null; + } catch { + return null; + } +} + async function main() { - let stars = FALLBACK; + // The anonymous GitHub API limit is per IP, and shared CI runners hit it + // routinely. Actions exposes GITHUB_TOKEN, which raises the limit a long way. + const token = process.env.GITHUB_TOKEN; + const headers = token ? { Authorization: `Bearer ${token}` } : {}; + + let stars = null; try { - const res = await fetch("https://api.github.com/repos/apache/iggy"); + const res = await fetch("https://api.github.com/repos/apache/iggy", { headers }); if (res.ok) { const data = await res.json(); if (data.stargazers_count) { stars = formatStars(data.stargazers_count); } + } else { + console.warn(`GitHub stars: API returned ${res.status} ${res.statusText}`); } } catch (e) { - console.warn("Failed to fetch GitHub stars, using fallback:", e.message); + console.warn("GitHub stars: fetch failed:", e.message); + } + + if (stars === null) { + // Never overwrite a good value with a worse one. + const previous = existingStars(); + stars = previous ?? FALLBACK; + console.warn( + previous + ? `GitHub stars: keeping previously fetched ${previous}` + : `GitHub stars: no previous value, using fallback ${FALLBACK}`, + ); } - writeFileSync( - new URL("../src/github-stars.json", import.meta.url), - JSON.stringify({ stars }), - ); + writeFileSync(OUT, JSON.stringify({ stars })); console.log(`GitHub stars: ${stars}`); } diff --git a/src/app/error.tsx b/src/app/error.tsx index 5f4782e413..407bbf1293 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -1,3 +1,22 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + "use client"; export default function Error({ diff --git a/src/components/benchmark-chart.tsx b/src/components/benchmark-chart.tsx index 28db7e0856..29b36ebcdc 100644 --- a/src/components/benchmark-chart.tsx +++ b/src/components/benchmark-chart.tsx @@ -1,3 +1,22 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + "use client"; import Link from "next/link"; diff --git a/src/components/force-dark-theme.tsx b/src/components/force-dark-theme.tsx index 6939b7e317..5f6259508f 100644 --- a/src/components/force-dark-theme.tsx +++ b/src/components/force-dark-theme.tsx @@ -1,3 +1,22 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + "use client"; import { useEffect } from "react";