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
|
||||
|
||||
- [ ] `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-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
|
||||
|
||||
A single, dependency-free Cloudflare Worker (`src/index.ts`) that serves
|
||||
hand-authored HTML with byte-level control over every SEO signal — status codes,
|
||||
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.).
|
||||
badseo.dev is a TanStack Start app deployed to a Cloudflare Worker, following
|
||||
the same Vite and Cloudflare setup as the repository's `web/` app.
|
||||
|
||||
- `src/index.ts` — router: fixtures → URLs, plus `robots.txt` and `sitemap.xml`.
|
||||
- `src/lib.ts` — page rendering. The shared chrome (nav, footer, the OpenSEO
|
||||
badge, the "what this page tests" panel) is deliberately **SEO-neutral**: it
|
||||
emits no `<h1>`–`<h6>` and no `<img>`, so each fixture fully controls its own
|
||||
headings and images and the audit measures exactly the defect we injected.
|
||||
TanStack React routes render the healthy homepage and catalog.
|
||||
A TanStack catch-all server route keeps the deliberate fixtures as raw
|
||||
responses with byte-level control over status codes, redirects, headers
|
||||
(`X-Robots-Tag`, `Link: …; rel=canonical`), timing, and the malformed `<head>`
|
||||
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/pages.ts` — the homepage and catalog (both must audit clean).
|
||||
|
||||
## Run it locally
|
||||
|
||||
```bash
|
||||
# from the badseo/ directory (uses the repo's wrangler)
|
||||
npx wrangler dev # serves on http://localhost:8787
|
||||
# from the badseo/ directory
|
||||
npm run dev # serves on http://localhost:8787
|
||||
```
|
||||
|
||||
## Run the end-to-end audit
|
||||
|
||||
The harness drives the **real** OpenSEO crawl + issue-detection functions
|
||||
(imported straight from `../src`) against a running badseo.dev, then asserts every
|
||||
fixture triggers exactly the issues it declares — and that the homepage, catalog,
|
||||
and support pages come back clean.
|
||||
fixture triggers exactly the issues it declares — and that the homepage,
|
||||
catalog, and support pages come back clean.
|
||||
|
||||
```bash
|
||||
# with `wrangler dev` running in another terminal:
|
||||
npx tsx scripts/run-audit.ts http://localhost:8787
|
||||
# with `npm run dev` running in another terminal:
|
||||
npm run audit -- http://localhost:8787
|
||||
```
|
||||
|
||||
It prints a per-page pass/fail matrix and an issue-type coverage line, and exits
|
||||
@ -113,20 +115,13 @@ Guidelines:
|
||||
|
||||
## Deploy
|
||||
|
||||
There's no bundling build — wrangler/esbuild bundles `src/index.ts` on deploy.
|
||||
The `build` script is a typecheck (`tsc --noEmit`) that runs before the deploy:
|
||||
Vite builds the TanStack Start client and Worker bundles, then TypeScript checks
|
||||
the project before Wrangler deploys it:
|
||||
|
||||
```bash
|
||||
npm run build # typecheck the Worker source
|
||||
npm run deploy # build, then wrangler deploy → badseo.dev
|
||||
npm run build # Vite build + typecheck
|
||||
npm run deploy # build + wrangler deploy → badseo.dev
|
||||
```
|
||||
|
||||
`npm run deploy` runs `npm run build && wrangler deploy --env production`. To
|
||||
deploy without the typecheck gate, run `npx wrangler deploy --env production`
|
||||
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`.
|
||||
The custom-domain routes for `badseo.dev` and `www.badseo.dev` live in
|
||||
`wrangler.jsonc`, alongside the TanStack server entry and built asset directory.
|
||||
|
||||
@ -4,18 +4,28 @@
|
||||
"type": "module",
|
||||
"description": "badseo.dev — a deliberately broken website full of SEO mistakes, used as e2e test fixtures for the OpenSEO site audit.",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"start": "wrangler dev",
|
||||
"dev": "vite dev",
|
||||
"start": "vite dev",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc --noEmit",
|
||||
"deploy": "npm run build && wrangler deploy --env production",
|
||||
"audit": "tsx scripts/run-audit.ts",
|
||||
"test:e2e": "tsx scripts/run-audit.ts"
|
||||
"build": "vite build && tsc --noEmit",
|
||||
"deploy": "npm run build && wrangler deploy",
|
||||
"audit": "cd .. && tsx badseo/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": {
|
||||
"@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",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.6",
|
||||
"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("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",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"types": ["vite/client"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
@ -12,8 +13,5 @@
|
||||
"noEmit": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
// Only the Worker source is typechecked here. scripts/run-audit.ts imports
|
||||
// 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"]
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.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",
|
||||
"name": "badseo",
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2025-06-01",
|
||||
"compatibility_date": "2026-02-19",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"main": "@tanstack/react-start/server-entry",
|
||||
"assets": {
|
||||
"directory": "./dist/client",
|
||||
"html_handling": "none",
|
||||
"not_found_handling": "none",
|
||||
},
|
||||
"workers_dev": true,
|
||||
"preview_urls": true,
|
||||
"observability": {
|
||||
"enabled": true,
|
||||
},
|
||||
// Custom-domain routes live under the `production` env so that plain
|
||||
// `wrangler dev` serves on localhost (a top-level custom_domain route makes
|
||||
// dev simulate the production host, which breaks local crawling).
|
||||
// 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 },
|
||||
],
|
||||
},
|
||||
},
|
||||
"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",
|
||||
"cleanup:default-projects:d1": "tsx scripts/d1-default-project-cleanup.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": {
|
||||
"bindings": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["web/**/*"],
|
||||
"exclude": ["web/**/*", "badseo/**/*"],
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user