weavesignal/tests/docs-links/check.mjs
The WeaveSignal contributors ad39c8c6dd The website as a landing site, the Cloudron catalogue file, MP3 on the public route
- weavesignal.top is now a landing site (home, compare, FAQ, run your own station) with the
  documentation under /docs/; old addresses redirect; a glossary; "desk B" is "the desk".
- deploy/cloudron/CloudronVersions.json for community installs (Cloudron 10.0 or later).
- The Compose bundle's public route publishes the MP3 compatibility stream too.
- The Cloudron guide no longer names a branch; packagerUrl names the repository.
2026-11-04 15:41:17 +00:00

65 lines
2.9 KiB
JavaScript

// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: 2026 The WeaveSignal contributors
// Checks the built website (docs/dist): every internal link, image, script, stylesheet, font,
// video and audio source resolves to a file in the build, the redirect pages included, and every
// fragment points at an id on its page. Nothing points at /updates/, which the deploy adds.
// Usage: node tests/docs-links/check.mjs [docs/dist]
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
import { join, dirname, resolve } from 'node:path';
const root = resolve(process.argv[2] ?? 'docs/dist');
const pages = [];
const walk = (d) => {
for (const n of readdirSync(d)) {
const f = join(d, n);
if (statSync(f).isDirectory()) walk(f);
else if (n.endsWith('.html')) pages.push(f);
}
};
walk(root);
const ids = new Map();
const idsOf = (file) => {
if (!ids.has(file)) {
const html = readFileSync(file, 'utf8');
ids.set(file, new Set([...html.matchAll(/\sid="([^"]+)"/g)].map((m) => m[1])));
}
return ids.get(file);
};
const fileFor = (path) => {
const p = decodeURIComponent(path.split('?')[0]);
const candidates = p.endsWith('/') ? [join(root, p, 'index.html')] : [join(root, p), join(root, p, 'index.html'), join(root, `${p}.html`)];
return candidates.find((c) => existsSync(c) && statSync(c).isFile());
};
let bad = 0;
let count = 0;
const report = (page, what, why) => {
bad++;
console.log(`${page.slice(root.length)}: ${what}: ${why}`);
};
for (const page of pages) {
const html = readFileSync(page, 'utf8');
const refs = [...html.matchAll(/\s(?:href|src|poster|content)="([^"]*)"/g)].map((m) => m[1]);
const srcsets = [...html.matchAll(/\ssrcset="([^"]*)"/g)].flatMap((m) => m[1].split(',').map((s) => s.trim().split(/\s+/)[0]));
for (const ref of [...refs, ...srcsets]) {
if (!ref || ref.startsWith('#') && ref.length === 1) continue;
if (/^(https?:|mailto:|tel:|data:|javascript:)/.test(ref)) {
if (/^https:\/\/weavesignal\.top\/updates\/[^/]+\/?$/.test(ref) || /^https:\/\/weavesignal\.top\/updates\/1\.0\.0\//.test(ref)) report(page, ref, 'a bare /updates/<version>/ directory, which has no index');
continue;
}
if (ref.startsWith('//')) continue;
if (!ref.startsWith('/') && !ref.startsWith('#')) continue; // a relative reference inside an attribute that is not a link (e.g. content="…")
count++;
const [path, frag] = ref.split('#');
if (path.startsWith('/updates/')) report(page, ref, 'points into /updates/, which the deploy adds');
const target = path === '' ? page : fileFor(path);
if (!target) {
report(page, ref, 'no such file in the build');
continue;
}
if (frag && target.endsWith('.html') && !idsOf(target).has(frag)) report(page, ref, `no id "${frag}" on that page`);
}
}
console.log(`${pages.length} pages, ${count} internal references, ${bad} problems`);
process.exit(bad ? 1 : 0);