85 lines
4.2 KiB
TypeScript
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');
|
|
});
|
|
});
|