storebound/tests/docs-links.test.ts

85 lines
4.2 KiB
TypeScript

// Links in README.md, docs/*.md and the site resolve: relative links and images point at files that exist, #anchors
// at headings (GitHub / Forgejo slugs) or element ids, and links into this repo on git.crt.fyi
// (/raw/branch/main/<path>, /src/branch/main/<path>) at files in the checkout. Other external URLs are not fetched.
import { describe, expect, test } from 'bun:test';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
const ROOT = join(import.meta.dir, '..');
const REPO = /^https:\/\/git\.crt\.fyi\/CRT\/storebound\/(?:raw|src)\/branch\/main\/([^?#]+)(#.*)?$/;
/** GitHub-style heading slug: lower case, punctuation dropped, spaces to hyphens. */
const slug = (h: string): string => h.trim().toLowerCase().replace(/<[^>]+>/g, '').replace(/[`*_]/g, '')
.replace(/[^\p{L}\p{N}\s-]/gu, '').replace(/\s/g, '-');
function anchorsOf(file: string): Set<string> {
const src = readFileSync(file, 'utf8');
if (file.endsWith('.html')) return new Set([...src.matchAll(/\sid="([^"]+)"/g)].map(m => m[1]!));
const out = new Set<string>();
const seen = new Map<string, number>();
let fence = false;
for (const line of src.split('\n')) {
if (/^```/.test(line)) fence = !fence;
const m = !fence && /^#{1,6} (.+)$/.exec(line);
if (!m) continue;
const s = slug(m[1]!);
const n = seen.get(s) ?? 0;
seen.set(s, n + 1);
out.add(n ? `${s}-${n}` : s);
}
return out;
}
function linksOf(file: string): string[] {
let src = readFileSync(file, 'utf8');
if (file.endsWith('.md')) src = src.replace(/^```[\s\S]*?^```/gm, '').replace(/`[^`\n]*`/g, '');
const md = [...src.matchAll(/\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)].map(m => m[1]!);
const html = [...src.matchAll(/\s(?:href|src|srcset|poster)="([^"]+)"/g)].flatMap(m => m[1]!.split(',').map(s => s.trim().split(/\s+/)[0]!));
return [...md, ...html].filter(Boolean);
}
function problems(file: string): string[] {
const out: string[] = [];
const base = dirname(file);
for (const link of linksOf(file)) {
const repo = REPO.exec(link);
let target: string, anchor: string | undefined;
if (repo) { target = join(ROOT, decodeURIComponent(repo[1]!)); anchor = repo[2]?.slice(1); }
else if (/^[a-z]+:/i.test(link) || link.startsWith('//')) continue;
// served by the desktop release server, not the site (tools/vds/apache-storebound-desktop.conf)
else if (/(^|\/)latest\.json$/.test(link)) continue;
else {
const [p, a] = link.split('#') as [string, string | undefined];
target = p ? join(base, decodeURIComponent(p.split('?')[0]!)) : file;
anchor = a;
}
const rel = relative(ROOT, file);
if (!existsSync(target)) { out.push(`${rel}: ${link} (no such file)`); continue; }
if (anchor && statSync(target).isFile() && /\.(md|html)$/.test(target) && !anchorsOf(target).has(anchor))
out.push(`${rel}: ${link} (no #${anchor})`);
}
return out;
}
const docs = readdirSync(join(ROOT, 'docs')).filter(n => n.endsWith('.md')).map(n => join(ROOT, 'docs', n));
describe('links resolve', () => {
test('README.md and docs/*.md', () => {
expect([join(ROOT, 'README.md'), ...docs].flatMap(problems)).toEqual([]);
});
test('README.md has only absolute links and images (npm renders it on npmjs.com, where relative paths 404)', () => {
const md = readFileSync(join(ROOT, 'README.md'), 'utf8');
const rel = [...md.matchAll(/!?\]\((?!https?:|#|mailto:)([^)\s]+)\)|(?:src|href)="(?!https?:|#|mailto:)([^"]+)"/g)].map(m => m[1] ?? m[2]);
expect(rel).toEqual([]);
});
test('site pages', () => {
const pages = ['site/index.html', 'site/desktop/index.html'].map(p => join(ROOT, p)).filter(existsSync);
expect(pages.flatMap(problems)).toEqual([]);
});
test('the checker catches a missing file, a missing anchor and a stale repo link', () => {
expect(slug('Install for your AI tool')).toBe('install-for-your-ai-tool');
expect(slug('Credentials (never in the repo)')).toBe('credentials-never-in-the-repo');
expect(anchorsOf(join(ROOT, 'docs/getting-started.md')).has('install-for-your-ai-tool')).toBe(true);
expect(REPO.exec('https://git.crt.fyi/CRT/storebound/raw/branch/main/site/img/showcase.webp')?.[1]).toBe('site/img/showcase.webp');
});
});