README, docs and site: one story (the whole job, no avoidable rejections, the desktop app, your AI, install)
README is a short front page (about 120 lines) with one real visual per section; every former section moved unchanged into docs/*.md with a docs/README.md index (journey, rejections, getting-started, commands, growth, studios, store-art, listing-languages, api, skills, mcp, safety, faq, project). The site tells the same story on one page: the teaser in the hero, real CLI output as text, the desktop app as a section with light/dark captures of eight screens and the per-system downloads (desktop/latest.json); /desktop/ redirects to /#download. New capture tools: tools/site-desktop-shots.ts (fixture-mode desktop frames) and tools/site-term-shots.ts (real CLI output as text and WebP). tests/docs-links.test.ts checks README, docs and site links resolve.
This commit is contained in:
parent
deb218943c
commit
710c3a53d0
63 changed files with 2742 additions and 1782 deletions
80
tests/docs-links.test.ts
Normal file
80
tests/docs-links.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// 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('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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue