Migrate badseo.dev to TanStack Start (#382)
* Migrate badseo.dev to TanStack Start * Fix badseo audit command
This commit is contained in:
parent
e264ee3472
commit
8349bca43c
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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`.
|
|
||||||
|
|||||||
@ -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
2494
badseo/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
badseo/pnpm-workspace.yaml
Normal file
3
badseo/pnpm-workspace.yaml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
minimumReleaseAge: 11520
|
||||||
|
minimumReleaseAgeExclude:
|
||||||
|
- "@every-app/*"
|
||||||
6
badseo/public/img/placeholder.svg
Normal file
6
badseo/public/img/placeholder.svg
Normal 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
436
badseo/public/styles.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
39
badseo/src/components/site-layout.tsx
Normal file
39
badseo/src/components/site-layout.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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
@ -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(" · ")}</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
140
badseo/src/routeTree.gen.ts
Normal 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
10
badseo/src/router.tsx
Normal 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
10
badseo/src/routes/$.ts
Normal 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),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
46
badseo/src/routes/__root.tsx
Normal file
46
badseo/src/routes/__root.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
badseo/src/routes/catalog.tsx
Normal file
109
badseo/src/routes/catalog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
badseo/src/routes/index.tsx
Normal file
92
badseo/src/routes/index.tsx
Normal 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'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>
|
||||||
|
</main>
|
||||||
|
</SiteLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
badseo/src/routes/robots[.]txt.ts
Normal file
10
badseo/src/routes/robots[.]txt.ts
Normal 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),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
10
badseo/src/routes/sitemap[.]xml.ts
Normal file
10
badseo/src/routes/sitemap[.]xml.ts
Normal 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),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
96
badseo/src/server/badseo.ts
Normal file
96
badseo/src/server/badseo.ts
Normal 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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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; }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@ -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
23
badseo/vite.config.ts
Normal 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(),
|
||||||
|
],
|
||||||
|
});
|
||||||
@ -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 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user