This URL is not one of the broken pages on the site. It is just missing.
- {
- 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();
- },
-};
diff --git a/badseo/src/logo.ts b/badseo/src/logo.ts
deleted file mode 100644
index 6fad544..0000000
--- a/badseo/src/logo.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-// OpenSEO logo for the "Maintained by OpenSEO" badge, embedded as base64 so the
-// Worker stays dependency-free (no asset pipeline). Source: public/openseo-logo.png
-// (the 1024px transparent-logo.png downscaled to 96px). Served at /openseo-logo.png.
-export const OPENSEO_LOGO_PNG_BASE64 =
- "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";
diff --git a/badseo/src/pages.ts b/badseo/src/pages.ts
deleted file mode 100644
index 7d31c57..0000000
--- a/badseo/src/pages.ts
+++ /dev/null
@@ -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: `
- A website demonstrating common technical SEO problems
- 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.
-
-
-
-
${allFixtures.length}broken pages
-
${distinctIssueTypes}issue types
-
${totalPaths}crawlable URLs
-
-
-What's on it
-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 catalog, or open the kitchen-sink page, which breaks six ways at once.
-
-Why it exists
-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.
-
-It's open source
-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 OpenSEO, an open-source SEO tool.
-
-Run an audit
-OpenSEO 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.
`,
- });
-}
-
-function issueDots(fixture: Fixture): string {
- return fixture.expectedIssues
- .map((id) => {
- const sev = AUDIT_ISSUE_TYPES[id].severity;
- return ``;
- })
- .join("");
-}
-
-function indexRow(fixture: Fixture): string {
- return `
- ${escapeHtml(fixture.name)}
- ${escapeHtml(fixture.summary)}
- ${issueDots(fixture)}
-`;
-}
-
-export function renderCatalog(): string {
- const groups = categories
- .map((category) => {
- const rows = catalogLinkedFixtures
- .filter((f) => f.category === category)
- .map(indexRow)
- .join("\n");
- return `
- ${escapeHtml(category)}
-
-${rows}
-
-`;
- })
- .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: `Technical SEO issues, by category
-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.
-
- critical
- warning
- info
-
-${groups}
-
- Duplicate URLs
- 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.
- ${duplicateUrlLinks
- .map(
- (d) =>
- `${escapeHtml(
- d.path,
- )}`,
- )
- .join(" · ")}
-
-A few pages are left off this list on purpose. An orphan page (/structure/orphan) 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.
`,
- });
-}
diff --git a/badseo/src/routeTree.gen.ts b/badseo/src/routeTree.gen.ts
new file mode 100644
index 0000000..481b785
--- /dev/null
+++ b/badseo/src/routeTree.gen.ts
@@ -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()
+
+import type { getRouter } from './router.tsx'
+import type { createStart } from '@tanstack/react-start'
+declare module '@tanstack/react-start' {
+ interface Register {
+ ssr: true
+ router: Awaited>
+ }
+}
diff --git a/badseo/src/router.tsx b/badseo/src/router.tsx
new file mode 100644
index 0000000..adbbe73
--- /dev/null
+++ b/badseo/src/router.tsx
@@ -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,
+ });
+}
diff --git a/badseo/src/routes/$.ts b/badseo/src/routes/$.ts
new file mode 100644
index 0000000..9fcdf37
--- /dev/null
+++ b/badseo/src/routes/$.ts
@@ -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),
+ },
+ },
+});
diff --git a/badseo/src/routes/__root.tsx b/badseo/src/routes/__root.tsx
new file mode 100644
index 0000000..79a2493
--- /dev/null
+++ b/badseo/src/routes/__root.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/badseo/src/routes/catalog.tsx b/badseo/src/routes/catalog.tsx
new file mode 100644
index 0000000..2a9b392
--- /dev/null
+++ b/badseo/src/routes/catalog.tsx
@@ -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 (
+
+ );
+ });
+}
+
+function CatalogPage() {
+ return (
+
+
+ Technical SEO issues, by category
+
+ 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.
+
+
+
+ critical
+
+
+ warning
+
+
+ info
+
+
+
+ {categories.map((category) => (
+
+ ))}
+
+
+ Duplicate URLs
+
+ 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.
+
+
+ {duplicateUrlLinks.map((duplicate, index) => (
+
+ {index > 0 ? " · " : null}
+
+ {duplicate.path}
+
+
+ ))}
+
+
+
+ A few pages are left off this list on purpose. An orphan page (
+ /structure/orphan) 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.
+
+
+
+ );
+}
diff --git a/badseo/src/routes/index.tsx b/badseo/src/routes/index.tsx
new file mode 100644
index 0000000..fe00921
--- /dev/null
+++ b/badseo/src/routes/index.tsx
@@ -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 (
+
+
+
+ A website demonstrating common technical SEO problems
+
+ 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.
+
+
+
+
+
+ {allFixtures.length}
+ broken pages
+
+
+ {distinctIssueTypes}
+ issue types
+
+
+ {totalPaths}
+ crawlable URLs
+
+
+
+ What's on it
+
+ 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{" "}
+ catalog, or open the{" "}
+ kitchen-sink page, which breaks six ways
+ at once.
+
+
+ Why it exists
+
+ 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.
+
+
+ It's open source
+
+ 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 OpenSEO
+ , an open-source SEO tool.
+
+
+ Run an audit
+
+ OpenSEO 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.
+
+
+
+ );
+}
diff --git a/badseo/src/routes/robots[.]txt.ts b/badseo/src/routes/robots[.]txt.ts
new file mode 100644
index 0000000..e05dfc0
--- /dev/null
+++ b/badseo/src/routes/robots[.]txt.ts
@@ -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),
+ },
+ },
+});
diff --git a/badseo/src/routes/sitemap[.]xml.ts b/badseo/src/routes/sitemap[.]xml.ts
new file mode 100644
index 0000000..4c7cf29
--- /dev/null
+++ b/badseo/src/routes/sitemap[.]xml.ts
@@ -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),
+ },
+ },
+});
diff --git a/badseo/src/server/badseo.ts b/badseo/src/server/badseo.ts
new file mode 100644
index 0000000..72c0791
--- /dev/null
+++ b/badseo/src/server/badseo.ts
@@ -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();
+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 {
+ 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: `404, not found
+This URL is not one of the broken pages on the site. It is just missing.
+Go back to the catalog to see the pages that break on purpose.
`,
+ }),
+ {
+ 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(["/"]);
+ for (const fixture of sitemapFixtures) {
+ for (const path of fixturePaths(fixture)) paths.add(path);
+ }
+ const urls = [...paths]
+ .map((path) => ` ${origin}${path}`)
+ .join("\n");
+ const body = `
+
+${urls}
+`;
+ return new Response(body, {
+ headers: { "content-type": "application/xml; charset=utf-8" },
+ });
+}
diff --git a/badseo/src/styles.ts b/badseo/src/styles.ts
deleted file mode 100644
index 4575e46..0000000
--- a/badseo/src/styles.ts
+++ /dev/null
@@ -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; }
-}
-`;
diff --git a/badseo/tsconfig.json b/badseo/tsconfig.json
index 2faa319..a280903 100644
--- a/badseo/tsconfig.json
+++ b/badseo/tsconfig.json
@@ -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"]
}
diff --git a/badseo/vite.config.ts b/badseo/vite.config.ts
new file mode 100644
index 0000000..1a89e6b
--- /dev/null
+++ b/badseo/vite.config.ts
@@ -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(),
+ ],
+});
diff --git a/badseo/wrangler.jsonc b/badseo/wrangler.jsonc
index 427d0b1..a366eaf 100644
--- a/badseo/wrangler.jsonc
+++ b/badseo/wrangler.jsonc
@@ -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 },
+ ],
}
diff --git a/package.json b/package.json
index 2a87a43..3f35ae2 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/tsconfig.json b/tsconfig.json
index e88eef2..5e4e42c 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,6 +1,6 @@
{
"include": ["**/*.ts", "**/*.tsx"],
- "exclude": ["web/**/*"],
+ "exclude": ["web/**/*", "badseo/**/*"],
"compilerOptions": {
"strict": true,
"esModuleInterop": true,