Skip to content

Commit d59f0b2

Browse files
committed
feat(seo): canonicalize site to c4lab.github.io
1 parent 812b303 commit d59f0b2

20 files changed

Lines changed: 744 additions & 7 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ public/
155155

156156
- 內層 container 只負責 serve 靜態檔,不管 TLS、不管 domain
157157
- 外層 nginx 負責 `c4lab.tw` / `c4lab.bime.ntu.edu.tw` 的 TLS 和轉發
158+
- SEO canonical domain 已切換為 `https://c4lab.github.io``canonical``sitemap.xml``robots.txt` 皆以此為準)
158159
- SPA 路由靠 `try_files $uri $uri/ /index.html` 處理
159160

160161
### Server 上的操作
Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
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

Comments
 (0)