Migrate badseo.dev to TanStack Start (#382)

* Migrate badseo.dev to TanStack Start

* Fix badseo audit command
This commit is contained in:
Ben Senescu 2026-07-10 17:53:42 -04:00 committed by GitHub
parent e264ee3472
commit 8349bca43c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 3586 additions and 557 deletions

View File

@ -6,6 +6,7 @@ This is not a completed-work log or a bug tracker. Never include secrets, creden
## Open ## Open
- [ ] `2026-07-10T21:09:27Z``codex` — While building the TanStack/Cloudflare badseo app, Wrangler reported an EPERM writing its debug log under the user preferences directory even though the build succeeded. Set `WRANGLER_LOG_PATH` to a writable temporary path in sandboxed build commands or make the logging failure non-fatal and quiet.
- [ ] `2026-07-10T17:53:20Z``codex` — While validating `.greptile/`, both `pnpm exec prettier --check` and the existing `pnpm format:check` attempted to reconcile `node_modules` and aborted because no TTY was available. Calling `node_modules/.bin/prettier` performed the non-installing check successfully; the agent/CI path needs a stable way to run package scripts without an interactive modules purge. - [ ] `2026-07-10T17:53:20Z``codex` — While validating `.greptile/`, both `pnpm exec prettier --check` and the existing `pnpm format:check` attempted to reconcile `node_modules` and aborted because no TTY was available. Calling `node_modules/.bin/prettier` performed the non-installing check successfully; the agent/CI path needs a stable way to run package scripts without an interactive modules purge.
- [ ] `2026-07-10T18:12:35Z``codex` — While validating referenced files in zsh, using `path` as a loop variable overwrote zsh's special `path` array and made commands such as `git`, `jq`, and `sed` appear missing later in the same shell. Use a neutral name such as `file_path` in shell loops. - [ ] `2026-07-10T18:12:35Z``codex` — While validating referenced files in zsh, using `path` as a loop variable overwrote zsh's special `path` array and made commands such as `git`, `jq`, and `sed` appear missing later in the same shell. Use a neutral name such as `file_path` in shell loops.

View File

@ -36,37 +36,39 @@ Browse them all at `/catalog`.
## How it's built ## How it's built
A single, dependency-free Cloudflare Worker (`src/index.ts`) that serves badseo.dev is a TanStack Start app deployed to a Cloudflare Worker, following
hand-authored HTML with byte-level control over every SEO signal — status codes, the same Vite and Cloudflare setup as the repository's `web/` app.
redirects, response headers (`X-Robots-Tag`, `Link: …; rel=canonical`), response
timing, and the raw `<head>`. No framework: SSR machinery tends to _fix_ the very
things we're trying to break (it insists on injecting a `<title>`, etc.).
- `src/index.ts` — router: fixtures → URLs, plus `robots.txt` and `sitemap.xml`. TanStack React routes render the healthy homepage and catalog.
- `src/lib.ts` — page rendering. The shared chrome (nav, footer, the OpenSEO A TanStack catch-all server route keeps the deliberate fixtures as raw
badge, the "what this page tests" panel) is deliberately **SEO-neutral**: it responses with byte-level control over status codes, redirects, headers
emits no `<h1>``<h6>` and no `<img>`, so each fixture fully controls its own (`X-Robots-Tag`, `Link: …; rel=canonical`), timing, and the malformed `<head>`
headings and images and the audit measures exactly the defect we injected. states the audit needs to observe.
- `src/routes/` — TanStack pages plus raw server routes for fixtures,
`robots.txt`, and `sitemap.xml`.
- `src/server/badseo.ts` — fixture dispatch and crawler-discovery responses.
- `src/lib.ts` — raw fixture HTML rendering. Its shared chrome is deliberately
**SEO-neutral**: it emits no `<h1>``<h6>` and no `<img>`.
- `src/fixtures/*.ts` — the fixtures, one file per category. - `src/fixtures/*.ts` — the fixtures, one file per category.
- `src/pages.ts` — the homepage and catalog (both must audit clean).
## Run it locally ## Run it locally
```bash ```bash
# from the badseo/ directory (uses the repo's wrangler) # from the badseo/ directory
npx wrangler dev # serves on http://localhost:8787 npm run dev # serves on http://localhost:8787
``` ```
## Run the end-to-end audit ## Run the end-to-end audit
The harness drives the **real** OpenSEO crawl + issue-detection functions The harness drives the **real** OpenSEO crawl + issue-detection functions
(imported straight from `../src`) against a running badseo.dev, then asserts every (imported straight from `../src`) against a running badseo.dev, then asserts every
fixture triggers exactly the issues it declares — and that the homepage, catalog, fixture triggers exactly the issues it declares — and that the homepage,
and support pages come back clean. catalog, and support pages come back clean.
```bash ```bash
# with `wrangler dev` running in another terminal: # with `npm run dev` running in another terminal:
npx tsx scripts/run-audit.ts http://localhost:8787 npm run audit -- http://localhost:8787
``` ```
It prints a per-page pass/fail matrix and an issue-type coverage line, and exits It prints a per-page pass/fail matrix and an issue-type coverage line, and exits
@ -113,20 +115,13 @@ Guidelines:
## Deploy ## Deploy
There's no bundling build — wrangler/esbuild bundles `src/index.ts` on deploy. Vite builds the TanStack Start client and Worker bundles, then TypeScript checks
The `build` script is a typecheck (`tsc --noEmit`) that runs before the deploy: the project before Wrangler deploys it:
```bash ```bash
npm run build # typecheck the Worker source npm run build # Vite build + typecheck
npm run deploy # build, then wrangler deploy → badseo.dev npm run deploy # build + wrangler deploy → badseo.dev
``` ```
`npm run deploy` runs `npm run build && wrangler deploy --env production`. To The custom-domain routes for `badseo.dev` and `www.badseo.dev` live in
deploy without the typecheck gate, run `npx wrangler deploy --env production` `wrangler.jsonc`, alongside the TanStack server entry and built asset directory.
directly.
First-time setup: the `production` env in `wrangler.jsonc` binds the custom
domains `badseo.dev` and `www.badseo.dev`, so the zone must be on the Cloudflare
account before the first deploy. (The routes live under `production` so that
plain `wrangler dev` still serves on localhost.) To preview on a `*.workers.dev`
URL without the custom domain, deploy the top-level env: `npx wrangler deploy`.

View File

@ -4,18 +4,28 @@
"type": "module", "type": "module",
"description": "badseo.dev — a deliberately broken website full of SEO mistakes, used as e2e test fixtures for the OpenSEO site audit.", "description": "badseo.dev — a deliberately broken website full of SEO mistakes, used as e2e test fixtures for the OpenSEO site audit.",
"scripts": { "scripts": {
"dev": "wrangler dev", "dev": "vite dev",
"start": "wrangler dev", "start": "vite dev",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"build": "tsc --noEmit", "build": "vite build && tsc --noEmit",
"deploy": "npm run build && wrangler deploy --env production", "deploy": "npm run build && wrangler deploy",
"audit": "tsx scripts/run-audit.ts", "audit": "cd .. && tsx badseo/scripts/run-audit.ts",
"test:e2e": "tsx scripts/run-audit.ts" "test:e2e": "cd .. && tsx badseo/scripts/run-audit.ts"
},
"dependencies": {
"@tanstack/react-router": "^1.170.16",
"@tanstack/react-start": "^1.168.26",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20250109.0", "@cloudflare/vite-plugin": "^1.42.3",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.6.0",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.6",
"wrangler": "^4.67.0" "wrangler": "^4.67.0"
} }
} }

2494
badseo/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,3 @@
minimumReleaseAge: 11520
minimumReleaseAgeExclude:
- "@every-app/*"

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="360" viewBox="0 0 720 360" role="img" aria-label="placeholder">
<rect width="720" height="360" fill="#ebe7e1"/>
<path d="M1 359 L719 1 M1 1 L719 359" stroke="#d8d1c8" stroke-width="1"/>
<rect x="0.5" y="0.5" width="719" height="359" fill="none" stroke="#d8d1c8"/>
<text x="360" y="188" text-anchor="middle" font-family="ui-monospace, monospace" font-size="24" fill="#7b7b78">placeholder image</text>
</svg>

After

Width:  |  Height:  |  Size: 475 B

436
badseo/public/styles.css Normal file
View File

@ -0,0 +1,436 @@
:root {
--canvas: #f5f1ec;
--surface: #ffffff;
--surface-2: #ebe7e1;
--ink: #111111;
--ink-muted: #626260;
--ink-subtle: #7b7b78;
--hairline: #d8d1c8;
--hairline-soft: #ebe7e1;
--orange: #ff5600;
--footer-bg: #eee8de;
--critical: #d23b1f;
--warning: #b26a00;
--info: #6b7280;
--mono:
"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--sans:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
background: var(--canvas);
color: var(--ink);
font-family: var(--sans);
font-size: 16px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
a {
color: var(--ink);
text-decoration: none;
}
a:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.wrap,
.main,
.panel,
.nav,
.foot-inner {
max-width: 820px;
margin: 0 auto;
padding-left: 24px;
padding-right: 24px;
}
/* ── nav ── */
.nav {
display: flex;
align-items: center;
gap: 20px;
padding-top: 20px;
padding-bottom: 20px;
}
.brand {
font-weight: 600;
letter-spacing: -0.01em;
color: var(--ink);
font-size: 17px;
}
.brand:hover {
text-decoration: none;
}
.nav-link {
color: var(--ink-muted);
font-weight: 500;
margin-left: auto;
font-size: 15px;
}
.nav-link:hover {
color: var(--ink);
}
/* ── content ── */
.main {
padding-top: 8px;
padding-bottom: 48px;
}
.main h1 {
font-size: clamp(30px, 4.6vw, 46px);
font-weight: 500;
line-height: 1.1;
letter-spacing: -0.025em;
margin: 12px 0 18px;
text-wrap: balance;
}
.main h2 {
font-size: 24px;
font-weight: 500;
letter-spacing: -0.017em;
margin: 40px 0 12px;
}
.main h3 {
font-size: 19px;
font-weight: 500;
margin: 26px 0 8px;
}
.main h4 {
font-size: 16px;
font-weight: 600;
margin: 20px 0 6px;
}
.main p {
color: #2c2c2b;
margin: 0 0 16px;
}
.lede {
font-size: 19px;
line-height: 1.45;
letter-spacing: -0.006em;
color: var(--ink);
}
.main a {
color: var(--ink);
text-decoration: underline;
text-decoration-color: var(--hairline);
text-underline-offset: 3px;
}
.main a:hover {
text-decoration-color: var(--ink);
}
.main img {
max-width: 100%;
border-radius: 10px;
border: 1px solid var(--hairline);
display: block;
margin: 20px 0;
background: var(--surface);
}
.main code {
font-family: var(--mono);
font-size: 13.5px;
background: var(--surface-2);
padding: 2px 6px;
border-radius: 5px;
}
.main ul,
.main ol {
color: #2c2c2b;
padding-left: 20px;
}
.main li {
margin: 4px 0;
}
.next {
margin-top: 8px;
}
.next a {
font-weight: 500;
}
/* ── test panel ── */
.panel {
margin-top: 8px;
margin-bottom: 32px;
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: 12px;
padding: 18px 20px;
}
.panel-head {
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
}
.panel-kicker {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 11px;
font-weight: 600;
color: var(--ink-subtle);
}
.panel-cat {
margin-left: auto;
font-size: 12px;
color: var(--ink-subtle);
font-family: var(--mono);
}
.panel-summary {
margin: 10px 0 0;
font-size: 15px;
color: var(--ink);
}
.panel-lesson {
margin: 6px 0 0;
font-size: 14px;
color: var(--ink-muted);
}
.panel-chips {
margin-top: 14px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.chips-label {
font-size: 12px;
color: var(--ink-subtle);
font-weight: 500;
margin-right: 2px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 12.5px;
font-weight: 500;
color: var(--ink);
padding: 3px 11px 3px 9px;
border-radius: 999px;
background: var(--canvas);
border: 1px solid var(--hairline);
}
.chip::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
flex: none;
background: var(--info);
}
.chip-critical::before {
background: var(--critical);
}
.chip-warning::before {
background: var(--warning);
}
.chip-info::before {
background: var(--info);
}
.chip-clean {
color: var(--ink-muted);
}
.chip-clean::before {
background: #3a9d5d;
}
/* ── hero + home bits ── */
.hero {
padding: 20px 0 4px;
}
.stat-row {
display: flex;
gap: 40px;
flex-wrap: wrap;
margin: 26px 0 8px;
}
.stat b {
font-size: 30px;
font-weight: 500;
letter-spacing: -0.02em;
display: block;
line-height: 1.1;
}
.stat span {
color: var(--ink-muted);
font-size: 14px;
}
.callout {
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: 12px;
padding: 16px 18px;
margin: 24px 0;
}
.callout p {
margin: 0;
color: #2c2c2b;
}
/* ── catalog index (no cards) ── */
.index-group {
margin: 40px 0;
}
.index-group > h2 {
margin-bottom: 4px;
}
.index-list {
margin-top: 8px;
border-top: 1px solid var(--hairline);
}
.index-list .index-row {
display: grid;
grid-template-columns: minmax(160px, 240px) 1fr auto;
gap: 20px;
align-items: baseline;
padding: 14px 12px;
margin: 0 -12px;
border-radius: 8px;
border-bottom: 1px solid var(--hairline);
color: var(--ink);
text-decoration: none;
}
.index-list .index-row:hover {
background: var(--surface-2);
border-bottom-color: transparent;
}
.index-list .index-row:hover .row-name {
text-decoration: underline;
text-decoration-color: var(--ink);
text-underline-offset: 3px;
}
.row-name {
font-weight: 500;
}
.row-sum {
color: var(--ink-muted);
font-size: 14.5px;
}
.row-sev {
display: inline-flex;
gap: 5px;
align-items: center;
padding-top: 4px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
flex: none;
}
.dot-critical {
background: var(--critical);
}
.dot-warning {
background: var(--warning);
}
.dot-info {
background: var(--info);
}
.legend {
display: inline-flex;
gap: 14px;
flex-wrap: wrap;
align-items: center;
color: var(--ink-muted);
font-size: 14px;
}
.legend span {
display: inline-flex;
gap: 6px;
align-items: center;
}
@media (max-width: 640px) {
.index-row {
grid-template-columns: 1fr auto;
}
.row-sum {
grid-column: 1 / -1;
}
}
/* ── footer + OpenSEO badge ── */
/* The band fills to the bottom of the page (no body padding beneath it). Extra
bottom padding keeps the footer text clear of the fixed badge, which floats
in the empty band space below the row. */
.foot {
background: var(--footer-bg);
border-top: 1px solid var(--hairline);
margin-top: 64px;
}
.foot-inner {
padding: 26px 24px 76px;
color: var(--ink-muted);
font-size: 14px;
display: flex;
justify-content: space-between;
gap: 8px 24px;
flex-wrap: wrap;
}
.foot-inner a {
color: var(--ink-muted);
}
.foot-inner a:hover {
color: var(--ink);
}
.foot-links {
display: flex;
gap: 18px;
}
.openseo-badge {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 50;
display: inline-flex;
align-items: center;
gap: 8px;
background: var(--ink);
color: #ffffff;
border-radius: 999px;
padding: 11px 17px;
font-size: 14px;
font-weight: 500;
}
.openseo-badge:hover {
background: #000000;
text-decoration: none;
}
.openseo-badge strong {
font-weight: 600;
}
.openseo-mark {
width: 22px;
height: 24px;
flex: none;
background: url("/openseo-logo.png") center / contain no-repeat;
/* The source logo is a silver tree on transparent; render it white so it
reads on the dark pill with no backing chip. */
filter: brightness(0) invert(1);
}
@media (max-width: 640px) {
.openseo-badge .badge-label {
display: none;
}
.openseo-badge {
padding: 10px 12px;
}
}

View File

@ -332,7 +332,7 @@ async function main() {
}); });
}; };
// Homepage + catalog must be clean. // Non-fixture content pages must be clean.
check("Homepage", "/", [], false); check("Homepage", "/", [], false);
check("Catalog", "/catalog", [], false); check("Catalog", "/catalog", [], false);

View File

@ -0,0 +1,39 @@
import type { ReactNode } from "react";
export function SiteLayout({ children }: { children: ReactNode }) {
return (
<>
<nav className="nav">
<a className="brand" href="/">
badseo.dev
</a>
<a className="nav-link" href="/catalog">
Catalog
</a>
</nav>
{children}
<footer className="foot">
<div className="foot-inner">
<span>
badseo.dev is maintained by OpenSEO. Every page here is broken on
purpose.
</span>
<span className="foot-links">
<a href="/catalog">Catalog</a>
<a href="https://github.com/every-app/open-seo">GitHub</a>
<a href="https://openseo.so">OpenSEO</a>
</span>
</div>
</footer>
<a
className="openseo-badge"
href="https://openseo.so"
title="Audit a site with OpenSEO"
>
<span className="openseo-mark" aria-hidden="true" />
<span className="badge-label">Maintained by </span>
<strong>OpenSEO</strong>
</a>
</>
);
}

View File

@ -1,163 +0,0 @@
// badseo.dev — Cloudflare Worker entry point.
//
// A dependency-free Worker that serves a catalog of deliberately-broken SEO
// pages. Each fixture declares the audit issues it should trigger; this file
// wires the fixtures up to URLs, plus robots.txt and a sitemap.xml that lists
// the pages a crawler is meant to discover (including a few dead ones).
import { STYLESHEET } from "./styles";
import { OPENSEO_LOGO_PNG_BASE64 } from "./logo";
import { renderHome, renderCatalog } from "./pages";
import { renderShell, redirect } from "./lib";
import { TRAILING_SLASH_CANONICAL } from "./fixtures/redirects";
import {
allFixtures,
fixturePaths,
sitemapFixtures,
} from "./fixtures/registry";
import type { Fixture, FixtureContext } from "./fixtures/types";
// Build the path -> fixture table once at module load.
const routeTable = new Map<string, Fixture>();
for (const fixture of allFixtures) {
for (const path of fixturePaths(fixture)) {
routeTable.set(path, fixture);
}
}
const PLACEHOLDER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="720" height="360" viewBox="0 0 720 360" role="img" aria-label="placeholder">
<rect width="720" height="360" fill="#ebe7e1"/>
<path d="M1 359 L719 1 M1 1 L719 359" stroke="#d8d1c8" stroke-width="1"/>
<rect x="0.5" y="0.5" width="719" height="359" fill="none" stroke="#d8d1c8"/>
<text x="360" y="188" text-anchor="middle" font-family="ui-monospace, monospace" font-size="24" fill="#7b7b78">placeholder image</text>
</svg>`;
function normalizePath(pathname: string): string {
if (pathname.length > 1 && pathname.endsWith("/")) {
return pathname.slice(0, -1);
}
return pathname;
}
function robotsTxt(origin: string): Response {
const body = `# badseo.dev is broken on purpose, but it lets crawlers in.
User-agent: *
Allow: /
Sitemap: ${origin}/sitemap.xml
`;
return new Response(body, {
headers: { "content-type": "text/plain; charset=utf-8" },
});
}
function sitemapXml(origin: string): Response {
// Note: /catalog is deliberately NOT in the sitemap. It's the hub the deep
// click-chain hangs off, and a sitemap-listed page is crawled at "null"
// click-depth — which would propagate down the chain and stop the deep-page
// fixture from ever reaching depth >= 5. Keeping the catalog link-only means
// the chain gets real, incrementing depths.
const paths = new Set<string>(["/"]);
for (const fixture of sitemapFixtures) {
for (const path of fixturePaths(fixture)) paths.add(path);
}
const urls = [...paths]
.map((path) => ` <url><loc>${origin}${path}</loc></url>`)
.join("\n");
const body = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>`;
return new Response(body, {
headers: { "content-type": "application/xml; charset=utf-8" },
});
}
function html(body: string, status = 200): Response {
return new Response(body, {
status,
headers: {
"content-type": "text/html; charset=utf-8",
"cache-control": "no-store",
},
});
}
function notFoundPage(): Response {
return html(
renderShell({
title: "404, not found | badseo.dev",
metaDescription: "That URL is not one of the pages on this site.",
bodyHtml: `<h1>404, not found</h1>
<p class="lede">This URL is not one of the broken pages on the site. It is just missing.</p>
<p>Go back to the <a href="/catalog">catalog</a> to see the pages that break on purpose.</p>`,
}),
404,
);
}
export default {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
// Derive the origin from the Host header, not url.origin: under `wrangler
// dev` with custom-domain routes configured, url.origin resolves to the
// production host (badseo.dev) even on localhost, which would make every
// sitemap/canonical URL cross-origin to a local crawler.
const host = request.headers.get("host") ?? url.host;
const origin = `${url.protocol}//${host}`;
const path = normalizePath(url.pathname);
// Trailing-slash canonical: the non-slash form 301-redirects to the slash
// form, which is served as the canonical 200 (via the fixture below, since
// normalizePath maps the slash form to the same route). Checked on the RAW
// pathname, before normalization, so the two forms behave differently. This
// reproduces the CMS redirect cycle from every-app/open-seo#61.
if (url.pathname === TRAILING_SLASH_CANONICAL) {
return redirect(`${TRAILING_SLASH_CANONICAL}/`, 301);
}
switch (path) {
case "/styles.css":
return new Response(STYLESHEET, {
headers: {
"content-type": "text/css; charset=utf-8",
"cache-control": "public, max-age=3600",
},
});
case "/img/placeholder.svg":
return new Response(PLACEHOLDER_SVG, {
headers: {
"content-type": "image/svg+xml; charset=utf-8",
"cache-control": "public, max-age=3600",
},
});
case "/openseo-logo.png":
return new Response(
Uint8Array.from(atob(OPENSEO_LOGO_PNG_BASE64), (c) =>
c.charCodeAt(0),
),
{
headers: {
"content-type": "image/png",
"cache-control": "public, max-age=86400",
},
},
);
case "/robots.txt":
return robotsTxt(origin);
case "/sitemap.xml":
return sitemapXml(origin);
case "/":
return html(renderHome());
case "/catalog":
return html(renderCatalog());
}
const fixture = routeTable.get(path);
if (fixture) {
const ctx: FixtureContext = { origin, request, path };
return fixture.handler(ctx);
}
return notFoundPage();
},
};

File diff suppressed because one or more lines are too long

View File

@ -1,110 +0,0 @@
// The two non-fixture pages: the homepage and the catalog. Both must audit
// CLEAN — they're the crawl entry point and the hub that links every fixture,
// so any accidental issue here would show up in the e2e run.
import { renderShell, escapeHtml } from "./lib";
import { AUDIT_ISSUE_TYPES } from "../../src/shared/audit-issues";
import {
allFixtures,
catalogLinkedFixtures,
categories,
duplicateUrlLinks,
fixturePaths,
} from "./fixtures/registry";
import type { Fixture } from "./fixtures/types";
const totalPaths = allFixtures.reduce((n, f) => n + fixturePaths(f).length, 0);
const distinctIssueTypes = new Set(allFixtures.flatMap((f) => f.expectedIssues))
.size;
export function renderHome(): string {
return renderShell({
title: "Technical SEO issues, by example | badseo.dev",
metaDescription:
"Every page here has one common technical SEO issue, from a missing title to a redirect loop, so you can test what your SEO crawler catches.",
bodyHtml: `<section class="hero">
<h1>A website demonstrating common technical SEO problems</h1>
<p class="lede">Every page on this site has one thing wrong with it, on purpose: a missing title, a redirect loop, a page nothing links to. Point an SEO crawler at the site and see which problems it catches.</p>
</section>
<div class="stat-row">
<div class="stat"><b>${allFixtures.length}</b><span>broken pages</span></div>
<div class="stat"><b>${distinctIssueTypes}</b><span>issue types</span></div>
<div class="stat"><b>${totalPaths}</b><span>crawlable URLs</span></div>
</div>
<h2>What's on it</h2>
<p>Each page breaks one thing and is otherwise fine, so an audit sees a single problem instead of a pile of them. Every page shows what it is testing and which issue an audit should report. Start with the <a href="/catalog">catalog</a>, or open the <a href="/kitchen-sink">kitchen-sink page</a>, which breaks six ways at once.</p>
<h2>Why it exists</h2>
<p>SEO tools all say they find broken titles, duplicate pages, redirect chains, and thin content. It is hard to check that they actually do. badseo.dev gives you a site that is broken in known ways, so you can run a crawler against it and compare what it finds to what is really there. We use it to test the OpenSEO audit.</p>
<h2>It's open source</h2>
<p>The site is open source. If there is a common SEO mistake it does not cover yet, you can add it. Each page is one small file that lists the issues it should trigger, so a new page also works as a test. It is maintained by the team behind <a href="https://openseo.so">OpenSEO</a>, an open-source SEO tool.</p>
<h2>Run an audit</h2>
<p><a href="https://openseo.so">OpenSEO</a> can crawl badseo.dev and report the issues on each page, along with how to fix them. It is a straightforward way to see what a crawler catches.</p>`,
});
}
function issueDots(fixture: Fixture): string {
return fixture.expectedIssues
.map((id) => {
const sev = AUDIT_ISSUE_TYPES[id].severity;
return `<span class="dot dot-${sev}" title="${escapeHtml(
AUDIT_ISSUE_TYPES[id].title,
)}"></span>`;
})
.join("");
}
function indexRow(fixture: Fixture): string {
return `<a class="index-row" href="${escapeHtml(fixture.path)}">
<span class="row-name">${escapeHtml(fixture.name)}</span>
<span class="row-sum">${escapeHtml(fixture.summary)}</span>
<span class="row-sev">${issueDots(fixture)}</span>
</a>`;
}
export function renderCatalog(): string {
const groups = categories
.map((category) => {
const rows = catalogLinkedFixtures
.filter((f) => f.category === category)
.map(indexRow)
.join("\n");
return `<section class="index-group">
<h2>${escapeHtml(category)}</h2>
<div class="index-list">
${rows}
</div>
</section>`;
})
.join("\n");
return renderShell({
title: "Technical SEO issues checklist | badseo.dev",
metaDescription:
"A checklist of common technical SEO issues, each with a live example page: head tags, duplicate content, redirects, HTTP status, speed, and site structure.",
bodyHtml: `<h1>Technical SEO issues, by category</h1>
<p class="lede">Every page on the site, grouped by the kind of technical SEO issue it shows. The dots are the issues an audit should report for that page.</p>
<p class="legend">
<span><span class="dot dot-critical"></span> critical</span>
<span><span class="dot dot-warning"></span> warning</span>
<span><span class="dot dot-info"></span> info</span>
</p>
${groups}
<section class="index-group">
<h2>Duplicate URLs</h2>
<p>The same page served again at a second address. This is the raw material for the duplicate-content check. They are linked here so a crawler reaches them and does not mistake them for orphans.</p>
<p>${duplicateUrlLinks
.map(
(d) =>
`<a href="${escapeHtml(d.path)}"><code>${escapeHtml(
d.path,
)}</code></a>`,
)
.join(" &middot; ")}</p>
</section>
<p style="margin-top:32px">A few pages are left off this list on purpose. An orphan page (<code>/structure/orphan</code>) is only in the sitemap, and some 404, 500, and 403 URLs are only in the sitemap too, so a crawler has to find them on its own.</p>`,
});
}

140
badseo/src/routeTree.gen.ts Normal file
View File

@ -0,0 +1,140 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as SitemapDotxmlRouteImport } from './routes/sitemap[.]xml'
import { Route as RobotsDottxtRouteImport } from './routes/robots[.]txt'
import { Route as CatalogRouteImport } from './routes/catalog'
import { Route as SplatRouteImport } from './routes/$'
import { Route as IndexRouteImport } from './routes/index'
const SitemapDotxmlRoute = SitemapDotxmlRouteImport.update({
id: '/sitemap.xml',
path: '/sitemap.xml',
getParentRoute: () => rootRouteImport,
} as any)
const RobotsDottxtRoute = RobotsDottxtRouteImport.update({
id: '/robots.txt',
path: '/robots.txt',
getParentRoute: () => rootRouteImport,
} as any)
const CatalogRoute = CatalogRouteImport.update({
id: '/catalog',
path: '/catalog',
getParentRoute: () => rootRouteImport,
} as any)
const SplatRoute = SplatRouteImport.update({
id: '/$',
path: '/$',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/$': typeof SplatRoute
'/catalog': typeof CatalogRoute
'/robots.txt': typeof RobotsDottxtRoute
'/sitemap.xml': typeof SitemapDotxmlRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/$': typeof SplatRoute
'/catalog': typeof CatalogRoute
'/robots.txt': typeof RobotsDottxtRoute
'/sitemap.xml': typeof SitemapDotxmlRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/$': typeof SplatRoute
'/catalog': typeof CatalogRoute
'/robots.txt': typeof RobotsDottxtRoute
'/sitemap.xml': typeof SitemapDotxmlRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/$' | '/catalog' | '/robots.txt' | '/sitemap.xml'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/$' | '/catalog' | '/robots.txt' | '/sitemap.xml'
id: '__root__' | '/' | '/$' | '/catalog' | '/robots.txt' | '/sitemap.xml'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
SplatRoute: typeof SplatRoute
CatalogRoute: typeof CatalogRoute
RobotsDottxtRoute: typeof RobotsDottxtRoute
SitemapDotxmlRoute: typeof SitemapDotxmlRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/sitemap.xml': {
id: '/sitemap.xml'
path: '/sitemap.xml'
fullPath: '/sitemap.xml'
preLoaderRoute: typeof SitemapDotxmlRouteImport
parentRoute: typeof rootRouteImport
}
'/robots.txt': {
id: '/robots.txt'
path: '/robots.txt'
fullPath: '/robots.txt'
preLoaderRoute: typeof RobotsDottxtRouteImport
parentRoute: typeof rootRouteImport
}
'/catalog': {
id: '/catalog'
path: '/catalog'
fullPath: '/catalog'
preLoaderRoute: typeof CatalogRouteImport
parentRoute: typeof rootRouteImport
}
'/$': {
id: '/$'
path: '/$'
fullPath: '/$'
preLoaderRoute: typeof SplatRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
SplatRoute: SplatRoute,
CatalogRoute: CatalogRoute,
RobotsDottxtRoute: RobotsDottxtRoute,
SitemapDotxmlRoute: SitemapDotxmlRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}

10
badseo/src/router.tsx Normal file
View File

@ -0,0 +1,10 @@
import { createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
export function getRouter() {
return createRouter({
routeTree,
defaultPreload: "intent",
scrollRestoration: true,
});
}

10
badseo/src/routes/$.ts Normal file
View File

@ -0,0 +1,10 @@
import { createFileRoute } from "@tanstack/react-router";
import { handleFixtureRequest } from "../server/badseo";
export const Route = createFileRoute("/$")({
server: {
handlers: {
GET: ({ request }) => handleFixtureRequest(request),
},
},
});

View File

@ -0,0 +1,46 @@
import {
createRootRoute,
HeadContent,
Outlet,
Scripts,
} from "@tanstack/react-router";
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: "utf-8" },
{
name: "viewport",
content: "width=device-width, initial-scale=1",
},
],
links: [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap",
},
{ rel: "stylesheet", href: "/styles.css" },
],
}),
component: RootComponent,
});
function RootComponent() {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<Outlet />
<Scripts />
</body>
</html>
);
}

View File

@ -0,0 +1,109 @@
import { createFileRoute } from "@tanstack/react-router";
import { AUDIT_ISSUE_TYPES } from "../../../src/shared/audit-issues";
import { SiteLayout } from "../components/site-layout";
import {
catalogLinkedFixtures,
categories,
duplicateUrlLinks,
} from "../fixtures/registry";
import type { Fixture } from "../fixtures/types";
export const Route = createFileRoute("/catalog")({
head: () => ({
meta: [
{ title: "Technical SEO issues checklist | badseo.dev" },
{
name: "description",
content:
"A checklist of common technical SEO issues, each with a live example page: head tags, duplicate content, redirects, HTTP status, speed, and site structure.",
},
],
}),
component: CatalogPage,
});
function IssueDots({ fixture }: { fixture: Fixture }) {
return fixture.expectedIssues.map((issueId) => {
const issue = AUDIT_ISSUE_TYPES[issueId];
return (
<span
className={`dot dot-${issue.severity}`}
title={issue.title}
key={issueId}
/>
);
});
}
function CatalogPage() {
return (
<SiteLayout>
<main className="main">
<h1>Technical SEO issues, by category</h1>
<p className="lede">
Every page on the site, grouped by the kind of technical SEO issue it
shows. The dots are the issues an audit should report for that page.
</p>
<p className="legend">
<span>
<span className="dot dot-critical" /> critical
</span>
<span>
<span className="dot dot-warning" /> warning
</span>
<span>
<span className="dot dot-info" /> info
</span>
</p>
{categories.map((category) => (
<section className="index-group" key={category}>
<h2>{category}</h2>
<div className="index-list">
{catalogLinkedFixtures
.filter((fixture) => fixture.category === category)
.map((fixture) => (
<a
className="index-row"
href={fixture.path}
key={fixture.path}
>
<span className="row-name">{fixture.name}</span>
<span className="row-sum">{fixture.summary}</span>
<span className="row-sev">
<IssueDots fixture={fixture} />
</span>
</a>
))}
</div>
</section>
))}
<section className="index-group">
<h2>Duplicate URLs</h2>
<p>
The same page served again at a second address. This is the raw
material for the duplicate-content check. They are linked here so a
crawler reaches them and does not mistake them for orphans.
</p>
<p>
{duplicateUrlLinks.map((duplicate, index) => (
<span key={duplicate.path}>
{index > 0 ? " · " : null}
<a href={duplicate.path}>
<code>{duplicate.path}</code>
</a>
</span>
))}
</p>
</section>
<p style={{ marginTop: 32 }}>
A few pages are left off this list on purpose. An orphan page (
<code>/structure/orphan</code>) is only in the sitemap, and some 404,
500, and 403 URLs are only in the sitemap too, so a crawler has to
find them on its own.
</p>
</main>
</SiteLayout>
);
}

View File

@ -0,0 +1,92 @@
import { createFileRoute } from "@tanstack/react-router";
import { SiteLayout } from "../components/site-layout";
import { allFixtures, fixturePaths } from "../fixtures/registry";
const totalPaths = allFixtures.reduce(
(count, fixture) => count + fixturePaths(fixture).length,
0,
);
const distinctIssueTypes = new Set(
allFixtures.flatMap((fixture) => fixture.expectedIssues),
).size;
export const Route = createFileRoute("/")({
head: () => ({
meta: [
{ title: "Technical SEO issues, by example | badseo.dev" },
{
name: "description",
content:
"Every page here has one common technical SEO issue, from a missing title to a redirect loop, so you can test what your SEO crawler catches.",
},
],
}),
component: HomePage,
});
function HomePage() {
return (
<SiteLayout>
<main className="main">
<section className="hero">
<h1>A website demonstrating common technical SEO problems</h1>
<p className="lede">
Every page on this site has one thing wrong with it, on purpose: a
missing title, a redirect loop, a page nothing links to. Point an
SEO crawler at the site and see which problems it catches.
</p>
</section>
<div className="stat-row">
<div className="stat">
<b>{allFixtures.length}</b>
<span>broken pages</span>
</div>
<div className="stat">
<b>{distinctIssueTypes}</b>
<span>issue types</span>
</div>
<div className="stat">
<b>{totalPaths}</b>
<span>crawlable URLs</span>
</div>
</div>
<h2>What&apos;s on it</h2>
<p>
Each page breaks one thing and is otherwise fine, so an audit sees a
single problem instead of a pile of them. Every page shows what it is
testing and which issue an audit should report. Start with the{" "}
<a href="/catalog">catalog</a>, or open the{" "}
<a href="/kitchen-sink">kitchen-sink page</a>, which breaks six ways
at once.
</p>
<h2>Why it exists</h2>
<p>
SEO tools all say they find broken titles, duplicate pages, redirect
chains, and thin content. It is hard to check that they actually do.
badseo.dev gives you a site that is broken in known ways, so you can
run a crawler against it and compare what it finds to what is really
there. We use it to test the OpenSEO audit.
</p>
<h2>It&apos;s open source</h2>
<p>
The site is open source. If there is a common SEO mistake it does not
cover yet, you can add it. Each page is one small file that lists the
issues it should trigger, so a new page also works as a test. It is
maintained by the team behind <a href="https://openseo.so">OpenSEO</a>
, an open-source SEO tool.
</p>
<h2>Run an audit</h2>
<p>
<a href="https://openseo.so">OpenSEO</a> can crawl badseo.dev and
report the issues on each page, along with how to fix them. It is a
straightforward way to see what a crawler catches.
</p>
</main>
</SiteLayout>
);
}

View File

@ -0,0 +1,10 @@
import { createFileRoute } from "@tanstack/react-router";
import { robotsResponse } from "../server/badseo";
export const Route = createFileRoute("/robots.txt")({
server: {
handlers: {
GET: ({ request }) => robotsResponse(request),
},
},
});

View File

@ -0,0 +1,10 @@
import { createFileRoute } from "@tanstack/react-router";
import { sitemapResponse } from "../server/badseo";
export const Route = createFileRoute("/sitemap.xml")({
server: {
handlers: {
GET: ({ request }) => sitemapResponse(request),
},
},
});

View File

@ -0,0 +1,96 @@
import {
fixturePaths,
sitemapFixtures,
allFixtures,
} from "../fixtures/registry";
import { TRAILING_SLASH_CANONICAL } from "../fixtures/redirects";
import type { Fixture, FixtureContext } from "../fixtures/types";
import { redirect, renderShell } from "../lib";
const routeTable = new Map<string, Fixture>();
for (const fixture of allFixtures) {
for (const path of fixturePaths(fixture)) routeTable.set(path, fixture);
}
function normalizePath(pathname: string): string {
if (pathname.length > 1 && pathname.endsWith("/")) {
return pathname.slice(0, -1);
}
return pathname;
}
function requestOrigin(request: Request, url: URL): string {
const host = request.headers.get("host") ?? url.host;
return `${url.protocol}//${host}`;
}
export async function handleFixtureRequest(
request: Request,
): Promise<Response> {
const url = new URL(request.url);
const path = normalizePath(url.pathname);
if (url.pathname === TRAILING_SLASH_CANONICAL) {
return redirect(`${TRAILING_SLASH_CANONICAL}/`, 301);
}
const fixture = routeTable.get(path);
if (fixture) {
const context: FixtureContext = {
origin: requestOrigin(request, url),
request,
path,
};
return fixture.handler(context);
}
return new Response(
renderShell({
title: "404, not found | badseo.dev",
metaDescription: "That URL is not one of the pages on this site.",
bodyHtml: `<h1>404, not found</h1>
<p class="lede">This URL is not one of the broken pages on the site. It is just missing.</p>
<p>Go back to the <a href="/catalog">catalog</a> to see the pages that break on purpose.</p>`,
}),
{
status: 404,
headers: {
"content-type": "text/html; charset=utf-8",
"cache-control": "no-store",
},
},
);
}
export function robotsResponse(request: Request): Response {
const url = new URL(request.url);
const origin = requestOrigin(request, url);
return new Response(
`# badseo.dev is broken on purpose, but it lets crawlers in.
User-agent: *
Allow: /
Sitemap: ${origin}/sitemap.xml
`,
{ headers: { "content-type": "text/plain; charset=utf-8" } },
);
}
export function sitemapResponse(request: Request): Response {
const url = new URL(request.url);
const origin = requestOrigin(request, url);
const paths = new Set<string>(["/"]);
for (const fixture of sitemapFixtures) {
for (const path of fixturePaths(fixture)) paths.add(path);
}
const urls = [...paths]
.map((path) => ` <url><loc>${origin}${path}</loc></url>`)
.join("\n");
const body = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>`;
return new Response(body, {
headers: { "content-type": "application/xml; charset=utf-8" },
});
}

View File

@ -1,216 +0,0 @@
// Served verbatim at /styles.css. Kept as a module string so the Worker stays
// dependency-free. Palette + type mirror the OpenSEO marketing site (web/):
// a warm "cream" canvas, ink text, one orange accent, Inter, hairline borders,
// small radii, no shadows.
export const STYLESHEET = `
:root {
--canvas: #f5f1ec;
--surface: #ffffff;
--surface-2: #ebe7e1;
--ink: #111111;
--ink-muted: #626260;
--ink-subtle: #7b7b78;
--hairline: #d8d1c8;
--hairline-soft: #ebe7e1;
--orange: #ff5600;
--footer-bg: #eee8de;
--critical: #d23b1f;
--warning: #b26a00;
--info: #6b7280;
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background: var(--canvas);
color: var(--ink);
font-family: var(--sans);
font-size: 16px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
a { color: var(--ink); text-decoration: none; }
a:hover { text-decoration: underline; text-underline-offset: 3px; }
.wrap, .main, .panel, .nav, .foot-inner { max-width: 820px; margin: 0 auto; padding-left: 24px; padding-right: 24px; }
/* ── nav ── */
.nav {
display: flex;
align-items: center;
gap: 20px;
padding-top: 20px;
padding-bottom: 20px;
}
.brand {
font-weight: 600;
letter-spacing: -0.01em;
color: var(--ink);
font-size: 17px;
}
.brand:hover { text-decoration: none; }
.nav-link { color: var(--ink-muted); font-weight: 500; margin-left: auto; font-size: 15px; }
.nav-link:hover { color: var(--ink); }
/* ── content ── */
.main { padding-top: 8px; padding-bottom: 48px; }
.main h1 {
font-size: clamp(30px, 4.6vw, 46px);
font-weight: 500;
line-height: 1.1;
letter-spacing: -0.025em;
margin: 12px 0 18px;
text-wrap: balance;
}
.main h2 {
font-size: 24px; font-weight: 500; letter-spacing: -0.017em;
margin: 40px 0 12px;
}
.main h3 { font-size: 19px; font-weight: 500; margin: 26px 0 8px; }
.main h4 { font-size: 16px; font-weight: 600; margin: 20px 0 6px; }
.main p { color: #2c2c2b; margin: 0 0 16px; }
.lede { font-size: 19px; line-height: 1.45; letter-spacing: -0.006em; color: var(--ink); }
.main a { color: var(--ink); text-decoration: underline; text-decoration-color: var(--hairline); text-underline-offset: 3px; }
.main a:hover { text-decoration-color: var(--ink); }
.main img {
max-width: 100%; border-radius: 10px; border: 1px solid var(--hairline);
display: block; margin: 20px 0; background: var(--surface);
}
.main code {
font-family: var(--mono); font-size: 13.5px;
background: var(--surface-2); padding: 2px 6px; border-radius: 5px;
}
.main ul, .main ol { color: #2c2c2b; padding-left: 20px; }
.main li { margin: 4px 0; }
.next { margin-top: 8px; }
.next a { font-weight: 500; }
/* ── test panel ── */
.panel {
margin-top: 8px;
margin-bottom: 32px;
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: 12px;
padding: 18px 20px;
}
.panel-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.panel-kicker {
text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px;
font-weight: 600; color: var(--ink-subtle);
}
.panel-cat {
margin-left: auto; font-size: 12px; color: var(--ink-subtle);
font-family: var(--mono);
}
.panel-summary { margin: 10px 0 0; font-size: 15px; color: var(--ink); }
.panel-lesson { margin: 6px 0 0; font-size: 14px; color: var(--ink-muted); }
.panel-chips { margin-top: 14px; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.chips-label { font-size: 12px; color: var(--ink-subtle); font-weight: 500; margin-right: 2px; }
.chip {
display: inline-flex; align-items: center; gap: 7px;
font-size: 12.5px; font-weight: 500; color: var(--ink);
padding: 3px 11px 3px 9px; border-radius: 999px;
background: var(--canvas); border: 1px solid var(--hairline);
}
.chip::before {
content: ""; width: 7px; height: 7px; border-radius: 50%; flex: none;
background: var(--info);
}
.chip-critical::before { background: var(--critical); }
.chip-warning::before { background: var(--warning); }
.chip-info::before { background: var(--info); }
.chip-clean { color: var(--ink-muted); }
.chip-clean::before { background: #3a9d5d; }
/* ── hero + home bits ── */
.hero { padding: 20px 0 4px; }
.stat-row { display: flex; gap: 40px; flex-wrap: wrap; margin: 26px 0 8px; }
.stat b { font-size: 30px; font-weight: 500; letter-spacing: -0.02em; display: block; line-height: 1.1; }
.stat span { color: var(--ink-muted); font-size: 14px; }
.callout {
background: var(--surface); border: 1px solid var(--hairline);
border-radius: 12px; padding: 16px 18px; margin: 24px 0;
}
.callout p { margin: 0; color: #2c2c2b; }
/* ── catalog index (no cards) ── */
.index-group { margin: 40px 0; }
.index-group > h2 { margin-bottom: 4px; }
.index-list { margin-top: 8px; border-top: 1px solid var(--hairline); }
.index-list .index-row {
display: grid;
grid-template-columns: minmax(160px, 240px) 1fr auto;
gap: 20px;
align-items: baseline;
padding: 14px 12px;
margin: 0 -12px;
border-radius: 8px;
border-bottom: 1px solid var(--hairline);
color: var(--ink);
text-decoration: none;
}
.index-list .index-row:hover {
background: var(--surface-2);
border-bottom-color: transparent;
}
.index-list .index-row:hover .row-name {
text-decoration: underline;
text-decoration-color: var(--ink);
text-underline-offset: 3px;
}
.row-name { font-weight: 500; }
.row-sum { color: var(--ink-muted); font-size: 14.5px; }
.row-sev { display: inline-flex; gap: 5px; align-items: center; padding-top: 4px; }
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex: none; }
.dot-critical { background: var(--critical); }
.dot-warning { background: var(--warning); }
.dot-info { background: var(--info); }
.legend { display: inline-flex; gap: 14px; flex-wrap: wrap; align-items: center; color: var(--ink-muted); font-size: 14px; }
.legend span { display: inline-flex; gap: 6px; align-items: center; }
@media (max-width: 640px) {
.index-row { grid-template-columns: 1fr auto; }
.row-sum { grid-column: 1 / -1; }
}
/* ── footer + OpenSEO badge ── */
/* The band fills to the bottom of the page (no body padding beneath it). Extra
bottom padding keeps the footer text clear of the fixed badge, which floats
in the empty band space below the row. */
.foot { background: var(--footer-bg); border-top: 1px solid var(--hairline); margin-top: 64px; }
.foot-inner {
padding: 26px 24px 76px; color: var(--ink-muted); font-size: 14px;
display: flex; justify-content: space-between; gap: 8px 24px; flex-wrap: wrap;
}
.foot-inner a { color: var(--ink-muted); }
.foot-inner a:hover { color: var(--ink); }
.foot-links { display: flex; gap: 18px; }
.openseo-badge {
position: fixed; right: 20px; bottom: 20px; z-index: 50;
display: inline-flex; align-items: center; gap: 8px;
background: var(--ink); color: #ffffff;
border-radius: 999px; padding: 11px 17px;
font-size: 14px; font-weight: 500;
}
.openseo-badge:hover { background: #000000; text-decoration: none; }
.openseo-badge strong { font-weight: 600; }
.openseo-mark {
width: 22px; height: 24px; flex: none;
background: url("/openseo-logo.png") center / contain no-repeat;
/* The source logo is a silver tree on transparent; render it white so it
reads on the dark pill with no backing chip. */
filter: brightness(0) invert(1);
}
@media (max-width: 640px) {
.openseo-badge .badge-label { display: none; }
.openseo-badge { padding: 10px 12px; }
}
`;

View File

@ -3,8 +3,9 @@
"target": "ES2022", "target": "ES2022",
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"lib": ["ES2022"], "lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["@cloudflare/workers-types"], "types": ["vite/client"],
"jsx": "react-jsx",
"strict": true, "strict": true,
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
@ -12,8 +13,5 @@
"noEmit": true, "noEmit": true,
"forceConsistentCasingInFileNames": true "forceConsistentCasingInFileNames": true
}, },
// Only the Worker source is typechecked here. scripts/run-audit.ts imports "include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"]
// the main app via its own path aliases and is run with tsx from the repo
// root, so it isn't part of this project's typecheck.
"include": ["src/**/*.ts"]
} }

23
badseo/vite.config.ts Normal file
View File

@ -0,0 +1,23 @@
import { cloudflare } from "@cloudflare/vite-plugin";
import react from "@vitejs/plugin-react";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import { defineConfig } from "vite";
export default defineConfig({
server: {
host: "127.0.0.1",
port: 8787,
},
ssr: {
resolve: {
conditions: ["worker", "import", "module", "default"],
},
},
plugins: [
cloudflare({
viteEnvironment: { name: "ssr" },
}),
tanstackStart(),
react(),
],
});

View File

@ -1,26 +1,21 @@
{ {
"$schema": "node_modules/wrangler/config-schema.json", "$schema": "node_modules/wrangler/config-schema.json",
"name": "badseo", "name": "badseo",
"main": "src/index.ts", "compatibility_date": "2026-02-19",
"compatibility_date": "2025-06-01", "compatibility_flags": ["nodejs_compat"],
"main": "@tanstack/react-start/server-entry",
"assets": {
"directory": "./dist/client",
"html_handling": "none",
"not_found_handling": "none",
},
"workers_dev": true, "workers_dev": true,
"preview_urls": true, "preview_urls": true,
"observability": { "observability": {
"enabled": true, "enabled": true,
}, },
// Custom-domain routes live under the `production` env so that plain "routes": [
// `wrangler dev` serves on localhost (a top-level custom_domain route makes { "pattern": "badseo.dev", "custom_domain": true },
// dev simulate the production host, which breaks local crawling). { "pattern": "www.badseo.dev", "custom_domain": true },
// Deploy with: wrangler deploy --env production ],
"env": {
"production": {
"name": "badseo",
"workers_dev": true,
"observability": { "enabled": true },
"routes": [
{ "pattern": "badseo.dev", "custom_domain": true },
{ "pattern": "www.badseo.dev", "custom_domain": true },
],
},
},
} }

View File

@ -44,7 +44,7 @@
"billing:brand-lookup": "tsx scripts/brand-lookup-cost-profile.ts", "billing:brand-lookup": "tsx scripts/brand-lookup-cost-profile.ts",
"cleanup:default-projects:d1": "tsx scripts/d1-default-project-cleanup.ts", "cleanup:default-projects:d1": "tsx scripts/d1-default-project-cleanup.ts",
"seed:rank-tracking": "tsx scripts/seed-rank-tracking.ts", "seed:rank-tracking": "tsx scripts/seed-rank-tracking.ts",
"ci:check": "prettier --check . && knip && tsc --noEmit && oxlint . --type-aware" "ci:check": "prettier --check . && knip && tsc --noEmit && tsc --noEmit -p badseo/tsconfig.json && oxlint . --type-aware"
}, },
"cloudflare": { "cloudflare": {
"bindings": { "bindings": {

View File

@ -1,6 +1,6 @@
{ {
"include": ["**/*.ts", "**/*.tsx"], "include": ["**/*.ts", "**/*.tsx"],
"exclude": ["web/**/*"], "exclude": ["web/**/*", "badseo/**/*"],
"compilerOptions": { "compilerOptions": {
"strict": true, "strict": true,
"esModuleInterop": true, "esModuleInterop": true,