Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,36 @@ The builder copies **only this repository's** `assets/`, `demos/`, `styles.css`

The landing HTML gives each adapter version its own escaped `<code>` item separated by `<br>`. Its Markdown table remains one physical line per row, followed by a plain per-package list of all eight independently resolved packages; the same list appears in `llms.txt` and `llms-full.txt`. `@rig-c/core` and `@rig-c/adapters` are labels/aliases, not additional latest lookups. Docs badges and structured docs versions use the same build-time registry map. Recorded demo versions remain pinned to their sources.

## Link checks

`bun run verify` checks links in the twelve generated pages, their Markdown mirrors, `llms.txt` and each page section of `llms-full.txt`. The same check runs alone with `bun run check:links`. Build the output first: standalone checks refuse a nonexistent output directory or any missing expected page HTML, Markdown mirror, `llms.txt` or `llms-full.txt`, naming the missing input and exiting 1. Expected documents must be regular files. It reads the actual element IDs from generated HTML, including IDs on non-heading elements; it does not guess heading slugs. Markdown is rendered by Bun to extract inline, reference, autolinks and raw HTML links. Code examples and HTML comments are not links.

Same-page, relative, cross-page and absolute same-origin links are resolved in the source page's URL context. A link to a known `index.md#fragment` uses that page's generated HTML IDs. Each `llms-full.txt` section keeps its original page context, so another page's heading cannot satisfy its same-page fragment. Attributes decode semicolon-terminated named and numeric HTML entities once; URL paths and fragments decode percent escapes once, including Unicode. Empty `#` and `#top` navigate to the top. Missing files, missing IDs, invalid URL escapes and fragments on unsupported non-HTML files fail with the source and original href.

The link crawl covers generated pages and mirrors. It does not recursively inspect copied demo/bundle links, apply their `<base>` elements, execute scripts, or check dynamic links. Existing specialized demo and asset checks still run. A `<base>` in a generated source page is refused rather than resolved against the wrong context.

Use an external scratch directory for build output and test temporary files:

```bash
CHECK_ROOT="$HOME/.cache/rig-c-site-checks"
mkdir -p "$CHECK_ROOT"
export TMPDIR="$CHECK_ROOT"
bun run build --out "$CHECK_ROOT/output"
bun run check:links --out "$CHECK_ROOT/output"
bun run verify --out "$CHECK_ROOT/output"
bun test src/links.test.ts src/external-links.test.ts
```

The tests plant a missing anchor in an existing private fixture file, run the real check CLI and assert exit 1 with the source and href, then repair the fixture and assert exit 0. They also cover HTML entities, Unicode, queries, Markdown-only and LLM-only failures, and page context. No authored page is mutated by these controls. Tests and verification remain offline; only the existing build's npm lookups require network access.

### Bounded repository documentation report

`bun run check:external --out "$CHECK_ROOT/output" > "$CHECK_ROOT/external-links.json"` is an optional live report, separate from test, build, verify and CI. It collects only public `https://github.com/rig-c/<repo>` README pages and `/blob/...` documents ending in `.md`, `.mdx`, `.rst`, `.txt`, or named `LICENSE`, `LICENCE`, `COPYING`, `NOTICE`. Other organizations, raw hosts, directory trees, issues and arbitrary domains are outside its scope. It follows no discovered links.

Bounds are fixed: 40 HTTP requests total (redirects included), at most two redirect hops per page, 1 MiB per response body, five seconds per request including body reads, and 45 seconds for the request budget. Requests are sequential, deduplicated by URL without fragment, unauthenticated, and use manual redirects with the same scope checks at every hop. No cookies or authorization headers are sent or forwarded. There are no retries. Unvisited targets are reported as `budget`; oversized responses and refused redirects remain uncertain.

Only HTTP 404/410 are `missing` and produce exit 1. Auth failures, rate limits, server errors, timeouts and network errors have distinct report statuses and never become missing targets. Exit 0 means the bounded report completed with no observed 404/410; it does not establish that every target is valid. Results retain source pages, requested and final URLs, HTTP status and, for completed bodies, length and SHA-256. GitHub can supply headings dynamically: an observed ID proves `anchor: present`, but an unobserved fragment stays `anchor: unknown`, even on HTTP 200. This report does not claim external anchor absence. Offline fixtures cover all these dispositions without live requests.

## Actions Pages deployment

`.github/workflows/pages.yml` runs on pushes to `main`, pull requests, manual dispatch and hourly at minute **17 UTC** (`17 * * * *`). Pull requests only test, build and verify. Main runs are serialized through deployment to keep the live comparison meaningful. Actions first runs the offline tests, then the real registry-backed build, then artifact verification; only successful, eligible and changed artifacts reach `actions/upload-pages-artifact@v4` and `actions/deploy-pages@v4`. Checkout uses `actions/checkout@v6` with credential persistence disabled. No bot writes source commits.
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
"build:ci": "bun scripts/build-site.ts",
"compare:live": "bun scripts/compare-live.ts",
"verify": "bun src/verify.ts",
"check:links": "bun src/links.ts",
"check:external": "bun src/external-links.ts",
"serve": "bun src/serve.ts"
}
}
19 changes: 19 additions & 0 deletions src/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,25 @@ describe('isolated deployable site', () => {
verifySite(out);
});

test('verification rejects missing HTML and mirror anchors despite a refreshed valid manifest', () => {
for (const path of ['docs/core/index.html', 'docs/core/index.md']) {
const absolute = join(out, path);
const original = readFileSync(absolute);
const manifest = readFileSync(join(out, 'deployment-manifest.json'));
try {
const planted = path.endsWith('.html')
? '<a href="#private-missing-anchor">Missing</a>'
: '\n[Missing](#private-missing-anchor)';
writeFileSync(absolute, original.toString() + planted);
writeManifest(out);
expect(() => verifySite(out)).toThrow('Verification failed');
} finally {
writeFileSync(absolute, original);
writeFileSync(join(out, 'deployment-manifest.json'), manifest);
}
}
});

test('output argument cannot target authored paths or root', () => {
for (const path of [ROOT_DIR, '..', 'assets', 'content', 'src', 'docs']) expect(() => outputRoot(path)).toThrow();
});
Expand Down
120 changes: 120 additions & 0 deletions src/external-links.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { expect, spyOn, test } from 'bun:test';
import { checkExternalLinks, documentationLinks, EXTERNAL_BOUNDS, repositoryDoc, type DocumentationLink } from './external-links.ts';
import { markdownDocument } from './links.ts';

const link = (url = 'https://github.com/rig-c/core#section'): DocumentationLink => ({ url, sources: ['docs/core/index.html'] });
const fake = (handler: (url: string, init: RequestInit) => Response | Promise<Response>) =>
((url: string | URL | Request, init?: RequestInit) => handler(String(url), init!)) as typeof fetch;

test('only public rig-c repository roots and document blobs enter the bounded report', () => {
const allowed = ['https://github.com/rig-c/core', 'https://github.com/rig-c/core/blob/main/docs/AUTHORING.md#x', 'https://github.com/rig-c/core/blob/main/LICENSE'];
const excluded = ['https://example.org/doc.md', 'https://github.com/else/core', 'https://github.com/rig-c/core/issues/88', 'https://github.com/rig-c/core/tree/main/docs', 'https://github.com/rig-c/core/blob/main/src/run.ts', 'https://token@github.com/rig-c/core', 'http://github.com/rig-c/core', 'https://raw.githubusercontent.com/rig-c/core/main/README.md'];
for (const url of allowed) expect(repositoryDoc(new URL(url))).toBe(true);
for (const url of excluded) expect(repositoryDoc(new URL(url))).toBe(false);
const md = [...allowed, ...excluded].map((url) => `[doc](${url})`).join('\n');
const documents = [markdownDocument('one.md', '/', md), markdownDocument('two.md', '/', md)];
const links = documentationLinks(documents);
expect(links).toHaveLength(allowed.length);
expect(links.every((item) => item.sources.join(',') === 'one.md,two.md')).toBe(true);
});

test('one page request covers multiple fragments, actual IDs establish presence, absent GitHub IDs stay unknown', async () => {
let calls = 0;
const report = await checkExternalLinks([link(), link('https://github.com/rig-c/core#dynamic'), link('https://github.com/rig-c/core')], fake((url, init) => {
calls++;
expect(url).toBe('https://github.com/rig-c/core');
expect(init.credentials).toBe('omit');
expect(init.redirect).toBe('manual');
const headers = new Headers(init.headers);
expect(headers.has('authorization')).toBe(false);
expect(headers.has('cookie')).toBe(false);
return new Response('<h2 id="user-content-section">Section</h2>', { headers: { 'content-type': 'text/html' } });
}));
expect(calls).toBe(1);
expect(report.results.map((item) => item.anchor)).toEqual(['present', 'unknown', 'not-requested']);
expect(report.missing).toBe(0);
expect(report.results[0]!.sha256).toHaveLength(64);
});

for (const [http, status, headers] of [
[404, 'missing', {}], [410, 'missing', {}], [401, 'auth', {}], [403, 'auth', {}],
[403, 'rate-limit', { 'x-ratelimit-remaining': '0' }], [429, 'rate-limit', {}],
[500, 'transient', {}], [503, 'transient', {}], [408, 'transient', {}], [418, 'unknown', {}],
] as const) {
test(`HTTP ${http} / ${status} is classified without retries`, async () => {
const report = await checkExternalLinks([link()], fake(() => new Response('', { status: http, headers })));
expect(report.requests).toBe(1);
expect(report.results[0]!.status).toBe(status);
expect(report.results[0]!.anchor).toBe('unknown');
expect(report.missing).toBe(status === 'missing' ? 1 : 0);
});
}

test('redirects remain in scope, count toward request limit, stop at two hops, never forward credentials', async () => {
const locations: string[] = [];
const report = await checkExternalLinks([link()], fake((url, init) => {
locations.push(url);
expect(new Headers(init.headers).has('authorization')).toBe(false);
return new Response('', { status: 302, headers: { location: `${url}/` } });
}));
expect(report.requests).toBe(3);
expect(report.results[0]!.status).toBe('redirect');
expect(locations).toHaveLength(3);
for (const location of ['https://example.org/doc', 'https://secret@github.com/rig-c/core', '/login']) {
let calls = 0;
const blocked = await checkExternalLinks([link()], fake(() => { calls++; return new Response('', { status: 302, headers: { location } }); }));
expect(calls).toBe(1);
expect(blocked.results[0]!.status).toBe('redirect');
expect(blocked.missing).toBe(0);
}
});

test('declared and streamed byte limits produce uncertainty and cancel oversized responses', async () => {
const declared = await checkExternalLinks([link()], fake(() => new Response('x', { headers: { 'content-length': String(EXTERNAL_BOUNDS.responseBytes + 1) } })));
expect(declared.results[0]!.status).toBe('body-limit');
let cancelled = false;
const streamed = await checkExternalLinks([link()], fake(() => new Response(new ReadableStream({
start(controller) { controller.enqueue(new Uint8Array(EXTERNAL_BOUNDS.responseBytes + 1)); },
cancel() { cancelled = true; },
}))));
expect(streamed.results[0]!.status).toBe('body-limit');
expect(cancelled).toBe(true);
});

test('request budget is strict and out-of-scope input makes no request', async () => {
const links = Array.from({ length: EXTERNAL_BOUNDS.requests + 2 }, (_, i) => link(`https://github.com/rig-c/repo-${i}`));
links.push(link('https://example.org/not-in-scope'));
const report = await checkExternalLinks(links, fake(() => new Response('ok')));
expect(report.requests).toBe(EXTERNAL_BOUNDS.requests);
expect(report.results.filter((item) => item.status === 'budget')).toHaveLength(2);
expect(report.results.at(-1)!.status).toBe('unknown');
});

test('elapsed total-time budget stops further requests', async () => {
let now = 1_000;
const clock = spyOn(Date, 'now').mockImplementation(() => now);
try {
const report = await checkExternalLinks([link(), link('https://github.com/rig-c/parts')], fake(() => {
now += EXTERNAL_BOUNDS.totalMs + 1;
return new Response('ok');
}));
expect(report.requests).toBe(1);
expect(report.results[1]!.status).toBe('budget');
} finally { clock.mockRestore(); }
});

test('network errors are uncertain and do not retry', async () => {
const report = await checkExternalLinks([link()], fake(() => { throw new Error('fixture connection refused'); }));
expect(report.requests).toBe(1);
expect(report.results[0]!.status).toBe('network');
expect(report.missing).toBe(0);
});

test('timeout aborts the request and remains uncertain', async () => {
const report = await checkExternalLinks([link()], fake((_url, init) => new Promise((_resolve, reject) => {
init.signal!.addEventListener('abort', () => reject(new Error('fixture abort')), { once: true });
})));
expect(report.results[0]!.status).toBe('timeout');
expect(report.requests).toBe(1);
expect(report.missing).toBe(0);
}, 10_000);
Loading
Loading