|
| 1 | +# SEO Pivot to c4lab.github.io Implementation Plan |
| 2 | + |
| 3 | +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. |
| 4 | +
|
| 5 | +**Goal:** Implement robust on-page SEO for the c4Lab SPA and set `https://c4lab.github.io` as the canonical domain across metadata, crawler files, and structured data. |
| 6 | + |
| 7 | +**Architecture:** Add a lightweight client-side SEO system for this React Router SPA: a shared SEO config module, a reusable head manager component for per-route tags, and a site-level JSON-LD injector. Keep it framework-native (no extra SEO dependency), and pair each implementation change with focused tests. Publish crawl discovery files (`robots.txt`, `sitemap.xml`) aligned to the canonical domain. |
| 8 | + |
| 9 | +**Tech Stack:** React 19, TypeScript, react-router-dom v7, Vitest + Testing Library, Vite static `public/` assets. |
| 10 | + |
| 11 | +--- |
| 12 | + |
| 13 | +### Task 1: Create Canonical SEO Config Utilities |
| 14 | + |
| 15 | +**Files:** |
| 16 | +- Create: `src/lib/seo.ts` |
| 17 | +- Test: `src/lib/seo.test.ts` |
| 18 | + |
| 19 | +**Step 1: Write the failing test** |
| 20 | + |
| 21 | +```ts |
| 22 | +import { describe, expect, test } from "vitest"; |
| 23 | +import { buildAbsoluteUrl, buildPageTitle, SEO_BASE_URL, pageSeo } from "./seo"; |
| 24 | + |
| 25 | +describe("seo config", () => { |
| 26 | + test("uses github.io as canonical base", () => { |
| 27 | + expect(SEO_BASE_URL).toBe("https://c4lab.github.io"); |
| 28 | + expect(buildAbsoluteUrl("/research")).toBe("https://c4lab.github.io/research"); |
| 29 | + }); |
| 30 | + |
| 31 | + test("builds page title with site suffix", () => { |
| 32 | + expect(buildPageTitle("Research")).toBe("Research | c4Lab"); |
| 33 | + }); |
| 34 | + |
| 35 | + test("contains route SEO entries", () => { |
| 36 | + expect(pageSeo.home.path).toBe("/"); |
| 37 | + expect(pageSeo.blog.path).toBe("/blog"); |
| 38 | + }); |
| 39 | +}); |
| 40 | +``` |
| 41 | + |
| 42 | +**Step 2: Run test to verify it fails** |
| 43 | + |
| 44 | +Run: `npm test -- src/lib/seo.test.ts` |
| 45 | +Expected: FAIL with module not found for `./seo` |
| 46 | + |
| 47 | +**Step 3: Write minimal implementation** |
| 48 | + |
| 49 | +```ts |
| 50 | +export const SEO_BASE_URL = "https://c4lab.github.io"; |
| 51 | +export const SEO_SITE_NAME = "c4Lab"; |
| 52 | + |
| 53 | +export function buildAbsoluteUrl(path: string) { |
| 54 | + return new URL(path, SEO_BASE_URL).toString(); |
| 55 | +} |
| 56 | + |
| 57 | +export function buildPageTitle(title: string) { |
| 58 | + return `${title} | ${SEO_SITE_NAME}`; |
| 59 | +} |
| 60 | + |
| 61 | +export const pageSeo = { |
| 62 | + home: { title: "Machine Learning and Bioinformatics Lab", description: "...", path: "/" }, |
| 63 | + research: { title: "Research", description: "...", path: "/research" }, |
| 64 | + publication: { title: "Publications", description: "...", path: "/publication" }, |
| 65 | + member: { title: "Members", description: "...", path: "/member" }, |
| 66 | + blog: { title: "Blog", description: "...", path: "/blog" }, |
| 67 | + galaxy: { title: "NTU Galaxy Guide", description: "...", path: "/galaxy" } |
| 68 | +}; |
| 69 | +``` |
| 70 | + |
| 71 | +**Step 4: Run test to verify it passes** |
| 72 | + |
| 73 | +Run: `npm test -- src/lib/seo.test.ts` |
| 74 | +Expected: PASS |
| 75 | + |
| 76 | +**Step 5: Commit** |
| 77 | + |
| 78 | +```bash |
| 79 | +git add src/lib/seo.ts src/lib/seo.test.ts |
| 80 | +git commit -m "feat(seo): add canonical SEO route config utilities" |
| 81 | +``` |
| 82 | + |
| 83 | +### Task 2: Add Reusable Head Metadata Component |
| 84 | + |
| 85 | +**Files:** |
| 86 | +- Create: `src/components/seo/SeoHead.tsx` |
| 87 | +- Test: `src/components/seo/SeoHead.test.tsx` |
| 88 | + |
| 89 | +**Step 1: Write the failing test** |
| 90 | + |
| 91 | +```tsx |
| 92 | +import { render } from "@testing-library/react"; |
| 93 | +import { SeoHead } from "./SeoHead"; |
| 94 | + |
| 95 | +test("sets title, canonical, description, og and twitter tags", () => { |
| 96 | + render(<SeoHead title="Research" description="Research page" path="/research" />); |
| 97 | + |
| 98 | + expect(document.title).toBe("Research | c4Lab"); |
| 99 | + expect(document.head.querySelector('link[rel="canonical"]')?.getAttribute("href")) |
| 100 | + .toBe("https://c4lab.github.io/research"); |
| 101 | + expect(document.head.querySelector('meta[name="description"]')?.getAttribute("content")) |
| 102 | + .toBe("Research page"); |
| 103 | + expect(document.head.querySelector('meta[property="og:url"]')?.getAttribute("content")) |
| 104 | + .toBe("https://c4lab.github.io/research"); |
| 105 | + expect(document.head.querySelector('meta[name="twitter:card"]')?.getAttribute("content")) |
| 106 | + .toBe("summary_large_image"); |
| 107 | +}); |
| 108 | +``` |
| 109 | + |
| 110 | +**Step 2: Run test to verify it fails** |
| 111 | + |
| 112 | +Run: `npm test -- src/components/seo/SeoHead.test.tsx` |
| 113 | +Expected: FAIL with missing `SeoHead` |
| 114 | + |
| 115 | +**Step 3: Write minimal implementation** |
| 116 | + |
| 117 | +```tsx |
| 118 | +export function SeoHead({ title, description, path, image, type = "website", noIndex = false }: SeoMeta) { |
| 119 | + useEffect(() => { |
| 120 | + const fullTitle = buildPageTitle(title); |
| 121 | + const canonicalUrl = buildAbsoluteUrl(path); |
| 122 | + const imageUrl = buildAbsoluteUrl(image ?? SEO_DEFAULT_IMAGE); |
| 123 | + |
| 124 | + document.title = fullTitle; |
| 125 | + // upsert canonical, description, robots |
| 126 | + // upsert og:type, og:site_name, og:title, og:description, og:url, og:image |
| 127 | + // upsert twitter:card, twitter:title, twitter:description, twitter:image |
| 128 | + }, [title, description, path, image, type, noIndex]); |
| 129 | + |
| 130 | + return null; |
| 131 | +} |
| 132 | +``` |
| 133 | + |
| 134 | +**Step 4: Run test to verify it passes** |
| 135 | + |
| 136 | +Run: `npm test -- src/components/seo/SeoHead.test.tsx` |
| 137 | +Expected: PASS |
| 138 | + |
| 139 | +**Step 5: Commit** |
| 140 | + |
| 141 | +```bash |
| 142 | +git add src/components/seo/SeoHead.tsx src/components/seo/SeoHead.test.tsx |
| 143 | +git commit -m "feat(seo): add reusable route metadata head manager" |
| 144 | +``` |
| 145 | + |
| 146 | +### Task 3: Add Site-Level Structured Data Component |
| 147 | + |
| 148 | +**Files:** |
| 149 | +- Create: `src/components/seo/SiteStructuredData.tsx` |
| 150 | +- Modify: `src/components/layout/SiteLayout.tsx` |
| 151 | +- Test: `src/components/seo/SiteStructuredData.test.tsx` |
| 152 | + |
| 153 | +**Step 1: Write the failing test** |
| 154 | + |
| 155 | +```tsx |
| 156 | +import { render } from "@testing-library/react"; |
| 157 | +import { SiteStructuredData } from "./SiteStructuredData"; |
| 158 | + |
| 159 | +test("injects JSON-LD graph for organization and website", () => { |
| 160 | + render(<SiteStructuredData />); |
| 161 | + const script = document.head.querySelector('script#site-structured-data'); |
| 162 | + expect(script).toBeTruthy(); |
| 163 | + const payload = JSON.parse(script?.textContent ?? "{}"); |
| 164 | + expect(payload["@graph"]?.[0]?.["@type"]).toBe("ResearchOrganization"); |
| 165 | + expect(payload["@graph"]?.[1]?.["@type"]).toBe("WebSite"); |
| 166 | +}); |
| 167 | +``` |
| 168 | + |
| 169 | +**Step 2: Run test to verify it fails** |
| 170 | + |
| 171 | +Run: `npm test -- src/components/seo/SiteStructuredData.test.tsx` |
| 172 | +Expected: FAIL with missing component |
| 173 | + |
| 174 | +**Step 3: Write minimal implementation** |
| 175 | + |
| 176 | +```tsx |
| 177 | +export function SiteStructuredData() { |
| 178 | + useEffect(() => { |
| 179 | + const payload = { |
| 180 | + "@context": "https://schema.org", |
| 181 | + "@graph": [ |
| 182 | + { "@type": "ResearchOrganization", "@id": "https://c4lab.github.io/#organization", name: "c4Lab", url: "https://c4lab.github.io" }, |
| 183 | + { "@type": "WebSite", "@id": "https://c4lab.github.io/#website", url: "https://c4lab.github.io", name: "c4Lab" } |
| 184 | + ] |
| 185 | + }; |
| 186 | + // upsert <script id="site-structured-data" type="application/ld+json"> |
| 187 | + }, []); |
| 188 | + |
| 189 | + return null; |
| 190 | +} |
| 191 | +``` |
| 192 | + |
| 193 | +Also mount it once in `SiteLayout` above the skip link. |
| 194 | + |
| 195 | +**Step 4: Run test to verify it passes** |
| 196 | + |
| 197 | +Run: `npm test -- src/components/seo/SiteStructuredData.test.tsx` |
| 198 | +Expected: PASS |
| 199 | + |
| 200 | +**Step 5: Commit** |
| 201 | + |
| 202 | +```bash |
| 203 | +git add src/components/seo/SiteStructuredData.tsx src/components/seo/SiteStructuredData.test.tsx src/components/layout/SiteLayout.tsx |
| 204 | +git commit -m "feat(seo): add site-level JSON-LD structured data" |
| 205 | +``` |
| 206 | + |
| 207 | +### Task 4: Wire Route-Level SEO into Pages |
| 208 | + |
| 209 | +**Files:** |
| 210 | +- Modify: `src/pages/HomePage.tsx` |
| 211 | +- Modify: `src/pages/ResearchPage.tsx` |
| 212 | +- Modify: `src/pages/PublicationPage.tsx` |
| 213 | +- Modify: `src/pages/MemberPage.tsx` |
| 214 | +- Modify: `src/pages/BlogPage.tsx` |
| 215 | +- Modify: `src/pages/GalaxyPage.tsx` |
| 216 | +- Modify: `src/app/App.test.tsx` |
| 217 | + |
| 218 | +**Step 1: Write the failing test** |
| 219 | + |
| 220 | +Add route metadata assertions in `App.test.tsx`: |
| 221 | + |
| 222 | +```tsx |
| 223 | +test("applies canonical title and URL for research route", () => { |
| 224 | + renderApp(["/research"]); |
| 225 | + expect(document.title).toBe("Research | c4Lab"); |
| 226 | + expect(document.head.querySelector('link[rel="canonical"]')?.getAttribute("href")) |
| 227 | + .toBe("https://c4lab.github.io/research"); |
| 228 | +}); |
| 229 | +``` |
| 230 | + |
| 231 | +**Step 2: Run test to verify it fails** |
| 232 | + |
| 233 | +Run: `npm test -- src/app/App.test.tsx` |
| 234 | +Expected: FAIL because title/canonical remain default |
| 235 | + |
| 236 | +**Step 3: Write minimal implementation** |
| 237 | + |
| 238 | +In each page component, add: |
| 239 | + |
| 240 | +```tsx |
| 241 | +import { SeoHead } from "../components/seo/SeoHead"; |
| 242 | +import { pageSeo } from "../lib/seo"; |
| 243 | + |
| 244 | +<> |
| 245 | + <SeoHead {...pageSeo.research} /> |
| 246 | + {/* existing page content */} |
| 247 | +</> |
| 248 | +``` |
| 249 | + |
| 250 | +Use matching route key (`home`, `research`, `publication`, `member`, `blog`, `galaxy`). |
| 251 | + |
| 252 | +**Step 4: Run test to verify it passes** |
| 253 | + |
| 254 | +Run: `npm test -- src/app/App.test.tsx` |
| 255 | +Expected: PASS |
| 256 | + |
| 257 | +**Step 5: Commit** |
| 258 | + |
| 259 | +```bash |
| 260 | +git add src/pages/HomePage.tsx src/pages/ResearchPage.tsx src/pages/PublicationPage.tsx src/pages/MemberPage.tsx src/pages/BlogPage.tsx src/pages/GalaxyPage.tsx src/app/App.test.tsx |
| 261 | +git commit -m "feat(seo): apply per-route metadata configuration" |
| 262 | +``` |
| 263 | + |
| 264 | +### Task 5: Add Crawler Discovery Files and Root Fallback Tags |
| 265 | + |
| 266 | +**Files:** |
| 267 | +- Modify: `index.html` |
| 268 | +- Create: `public/robots.txt` |
| 269 | +- Create: `public/sitemap.xml` |
| 270 | + |
| 271 | +**Step 1: Write the failing test** |
| 272 | + |
| 273 | +Add a simple integration verification in `src/app/App.test.tsx` or a new smoke test for sitemap/robots presence by reading built output in Task 6. For this task, use build-time verification as the failing check. |
| 274 | + |
| 275 | +**Step 2: Run check to verify it fails before files exist** |
| 276 | + |
| 277 | +Run: `npm run build && test -f dist/robots.txt && test -f dist/sitemap.xml` |
| 278 | +Expected: FAIL because one or both files are missing |
| 279 | + |
| 280 | +**Step 3: Write minimal implementation** |
| 281 | + |
| 282 | +`public/robots.txt`: |
| 283 | + |
| 284 | +```txt |
| 285 | +User-agent: * |
| 286 | +Allow: / |
| 287 | +
|
| 288 | +Sitemap: https://c4lab.github.io/sitemap.xml |
| 289 | +``` |
| 290 | + |
| 291 | +`public/sitemap.xml`: |
| 292 | + |
| 293 | +```xml |
| 294 | +<?xml version="1.0" encoding="UTF-8"?> |
| 295 | +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> |
| 296 | + <url><loc>https://c4lab.github.io/</loc></url> |
| 297 | + <url><loc>https://c4lab.github.io/research</loc></url> |
| 298 | + <url><loc>https://c4lab.github.io/publication</loc></url> |
| 299 | + <url><loc>https://c4lab.github.io/member</loc></url> |
| 300 | + <url><loc>https://c4lab.github.io/blog</loc></url> |
| 301 | + <url><loc>https://c4lab.github.io/galaxy</loc></url> |
| 302 | +</urlset> |
| 303 | +``` |
| 304 | + |
| 305 | +Update `index.html` baseline tags to match canonical domain for the default route: |
| 306 | +- `<link rel="canonical" href="https://c4lab.github.io/" />` |
| 307 | +- Open Graph/Twitter defaults for the home page. |
| 308 | + |
| 309 | +**Step 4: Run check to verify it passes** |
| 310 | + |
| 311 | +Run: `npm run build && test -f dist/robots.txt && test -f dist/sitemap.xml` |
| 312 | +Expected: PASS |
| 313 | + |
| 314 | +**Step 5: Commit** |
| 315 | + |
| 316 | +```bash |
| 317 | +git add index.html public/robots.txt public/sitemap.xml |
| 318 | +git commit -m "feat(seo): add robots, sitemap, and canonical root tags" |
| 319 | +``` |
| 320 | + |
| 321 | +### Task 6: End-to-End Verification and Documentation Sync |
| 322 | + |
| 323 | +**Files:** |
| 324 | +- Modify (if needed): `README.md` |
| 325 | + |
| 326 | +**Step 1: Run full test suite** |
| 327 | + |
| 328 | +Run: `npm test` |
| 329 | +Expected: PASS |
| 330 | + |
| 331 | +**Step 2: Run production build** |
| 332 | + |
| 333 | +Run: `npm run build` |
| 334 | +Expected: PASS and emit `dist/index.html`, `dist/robots.txt`, `dist/sitemap.xml` |
| 335 | + |
| 336 | +**Step 3: Spot-check metadata output** |
| 337 | + |
| 338 | +Run: |
| 339 | + |
| 340 | +```bash |
| 341 | +npm run preview -- --host 127.0.0.1 --port 4173 |
| 342 | +curl -s http://127.0.0.1:4173/ | sed -n '1,120p' |
| 343 | +``` |
| 344 | + |
| 345 | +Expected: |
| 346 | +- canonical tag points to `https://c4lab.github.io/` |
| 347 | +- default description and OG/Twitter tags present |
| 348 | + |
| 349 | +**Step 4: Update docs if canonical policy is documented anywhere else** |
| 350 | + |
| 351 | +If README mentions multiple equivalent hosts, update wording to mark `https://c4lab.github.io` as primary canonical host for search indexing. |
| 352 | + |
| 353 | +**Step 5: Commit** |
| 354 | + |
| 355 | +```bash |
| 356 | +git add README.md |
| 357 | +git commit -m "docs(seo): document canonical github.io domain policy" |
| 358 | +``` |
| 359 | + |
| 360 | +### Execution Notes |
| 361 | + |
| 362 | +- Follow `@superpowers/test-driven-development` for each task before implementation. |
| 363 | +- Use `@superpowers/verification-before-completion` before claiming done. |
| 364 | +- Keep commits small and task-scoped. |
| 365 | +- Do not introduce additional SEO libraries unless a test reveals a hard blocker. |
| 366 | + |
0 commit comments