Skip to content
Closed
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
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,32 @@ jobs:
- name: Smoke test the serverless routes
run: pnpm test:smoke

production-links:
name: Production Links
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:links
- run: pnpm build
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm check:links
- name: Upload link check report
if: always()
uses: actions/upload-artifact@v4
with:
name: production-links
path: playwright-report/link-check.json
if-no-files-found: ignore
retention-days: 14

playwright-a11y:
name: Playwright A11y Gate
needs: build
Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,47 @@ the OG image endpoint (rendering, query escaping, cache headers and the failure
response). Buttondown is stubbed, so no mail is sent and no test touches the network.
The suite runs on every pull request in CI.

## Production link checks

```bash
pnpm build
pnpm exec playwright install chromium
pnpm check:links
pnpm test:links
```

The pull request `Production Links` job runs the same checker against `dist/`.
It discovers all built HTML routes and sitemap URLs, then uses Chromium to crawl
rendered links and validate same-page and cross-page anchors (including MDX headings).
Production-origin URLs resolve against the local build. Missing files and rendered
not-found pages fail even when the preview server returns an SPA fallback with HTTP 200.
The checker parses the sitemap, checks built-route coverage, and validates the main
RSS feed, every generated tag feed, and feeds expected by sitemap tag archives.
RSS channel, item, permalink GUID, and self URLs are checked too.

External HTTP(S) destinations use GET with a ten-second timeout, at most two attempts,
and six concurrent requests. External fragments are not checked. `mailto:` and `tel:`
links are skipped. For an offline internal check, use `pnpm check:links --internal-only`;
CI always runs the full check. Failures include source and destination in the console
and `playwright-report/link-check.json`, uploaded as a CI artifact.

Intentional external failures must be recorded in `scripts/link-check-allowlist.json`
as exact normalized URLs (no fragment or wildcard), with a reason and the specific
allowed HTTP statuses or `"network"` for a known unavailable host. For example:

```json
[
{
"url": "https://example.org/protected",
"reason": "Intentionally requires authentication",
"failures": [401]
}
]
```

The allowlist starts empty. It cannot suppress internal links, anchors, or XML failures.
Allowed failures remain visible in the report; unexpected failure statuses still fail CI.

## SEO & Metadata

- **`robots.txt`**: Located in `public/robots.txt` and copied to the build root. It allows search engine crawling while excluding staging, preview, admin, and 404 routes, and points crawlers to the sitemap location.
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
"lint:i18n": "node scripts/lint-i18n.mjs",
"generate:og": "node scripts/generate-og.mjs",
"validate:jsonld": "tsx scripts/validate-jsonld.mjs",
"check:links": "node scripts/check-links.mjs",
"test:links": "vitest run tests/link-checker.test.mjs",
"validate:content": "tsx scripts/validate-content.ts",
"prepare": "husky"
},
Expand Down
9 changes: 0 additions & 9 deletions public/.well-known/security.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,9 @@ Canonical: https://usewraith.xyz/.well-known/security.txt
Contact: mailto:security@usewraith.xyz
Contact: https://usewraith.xyz/security

# PGP key for encrypted communications
Encryption: https://keys.openpgp.org/vks/v1/by-fingerprint/ABCD123456789...

# Preferred languages
Preferred-Languages: en

# Disclosure policy
Policy: https://docs.usewraith.xyz/security/disclosure-policy

# Acknowledgments page
Acknowledgments: https://docs.usewraith.xyz/security/disclosures

# Hiring security researchers
Hiring: https://usewraith.xyz/careers

Expand Down
94 changes: 94 additions & 0 deletions scripts/check-links.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
import { chromium } from '@playwright/test';
import { preview } from 'vite';
import { checkBuild } from './link-checker.mjs';

const root = fileURLToPath(new URL('../', import.meta.url));
const internalOnly = process.argv.includes('--internal-only');
if (process.argv.slice(2).some((arg) => arg !== '--internal-only')) {
throw new Error('Usage: pnpm check:links [--internal-only]');
}
let server;
let browser;
try {
const allowlist = JSON.parse(
await readFile(join(root, 'scripts/link-check-allowlist.json'), 'utf8'),
);
server = await preview({
root,
configFile: false,
preview: { host: '127.0.0.1', port: 0, open: false },
});
const origin = `http://127.0.0.1:${server.httpServer.address().port}`;
browser = await chromium.launch();
const context = await browser.newContext({ locale: 'en-US', serviceWorkers: 'block' });
// Only page content comes from the local production build. External destinations
// are checked separately, with bounded retries, rather than loading third-party JS.
await context.route('**/*', (route) =>
new URL(route.request().url()).origin === origin ? route.continue() : route.abort(),
);
const page = await context.newPage();
page.setDefaultTimeout(15_000);
let pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
page.on('requestfailed', (request) => {
if (request.url().startsWith(origin + '/') && request.resourceType() === 'script') {
pageErrors.push(`Script failed: ${request.url()}`);
}
});
page.on('response', (response) => {
if (
response.url().startsWith(origin + '/') &&
response.status() >= 400 &&
response.request().resourceType() === 'script'
) {
pageErrors.push(`Script HTTP ${response.status()}: ${response.url()}`);
}
});
const report = await checkBuild({
distDir: join(root, 'dist'),
allowlist,
internalOnly,
progress: console.log,
renderPage: async (path) => {
pageErrors = [];
const response = await page.goto(origin + path, { waitUntil: 'networkidle' });
await page.locator('h1').first().waitFor({ state: 'attached' });
if (pageErrors.length) throw new Error(pageErrors.join('; '));
const snapshot = await page.evaluate(() => ({
heading: document.querySelector('h1')?.textContent.trim(),
links: [
...document.querySelectorAll('a[href], area[href], link[rel="alternate"][href]'),
].map((node) => node.getAttribute('href')),
anchors: [...document.querySelectorAll('[id], a[name]')].flatMap((node) =>
[node.id, node.getAttribute('name')].filter(Boolean),
),
}));
return { ...snapshot, status: response.status() };
},
});
await mkdir(join(root, 'playwright-report'), { recursive: true });
await writeFile(
join(root, 'playwright-report/link-check.json'),
JSON.stringify(report, null, 2) + '\n',
);
for (const finding of report.errors)
console.error(`${finding.source} -> ${finding.target}: ${finding.message}`);
for (const finding of report.allowed)
console.log(`Allowed: ${finding.target}: ${finding.reason}`);
console.log(
`Checked ${report.pages} pages, ${report.feeds} feeds, ${report.links} links; ${report.errors.length} failures, ${report.allowed.length} allowed external failures.${internalOnly ? ' External checks skipped.' : ''}`,
);
if (report.errors.length) process.exitCode = 1;
} catch (error) {
console.error(error);
process.exitCode = 1;
} finally {
await browser?.close();
if (server)
await new Promise((resolve, reject) =>
server.httpServer.close((error) => (error ? reject(error) : resolve())),
);
}
7 changes: 7 additions & 0 deletions scripts/link-check-allowlist.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[
{
"url": "https://www.npmjs.com/package/@wraith-protocol/sdk",
"reason": "npm returns a Cloudflare challenge to automated link-check requests",
"failures": [403]
}
]
Loading
Loading