Skip to content

Commit 93ef484

Browse files
authored
fix: page lang, body-text contrast, and per-page SEO/social metadata (#89)
1 parent 5d0a9eb commit 93ef484

16 files changed

Lines changed: 224 additions & 39 deletions

index.html

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,25 @@
11
<!doctype html>
2-
<html lang="en">
2+
<html lang="zh-Hant-TW">
33
<head>
44
<meta charset="UTF-8" />
55
<meta
66
name="viewport"
77
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
88
/>
9-
<title>Weekly Prayers for Nations| SIM Taiwan</title>
10-
<meta
11-
name="description"
12-
content="Weekly Prayers for the Nations from SIM"
13-
/>
149
<meta name="author" content="SIM Taiwan" />
1510

16-
<meta property="og:title" content="sim-weekly-prayers-hub" />
17-
<meta
18-
property="og:description"
19-
content="Weekly Prayers for the Nations from SIM"
20-
/>
11+
<!--
12+
Title, description and og:title/og:description/og:image are set per-page at
13+
runtime by react-helmet-async (see src/components/SeoHead.tsx) and captured
14+
into each prerendered snapshot. They are intentionally NOT declared
15+
statically here: Helmet appends its managed tags without removing static
16+
ones, so duplicating them would leave two of each in every snapshot and
17+
social crawlers would pick the generic static value over the per-page one.
18+
-->
2119
<meta property="og:type" content="website" />
22-
<meta property="og:image" content="/twitter-card.png" />
2320

2421
<meta name="twitter:card" content="summary_large_image" />
2522
<meta name="twitter:site" content="@sim_taiwan" />
26-
<meta name="twitter:image" content="/twitter-card.png" />
2723

2824
<!-- PWA Manifest -->
2925
<link rel="manifest" href="/manifest.json" />

package-lock.json

Lines changed: 54 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"next-themes": "^0.4.6",
4141
"react": "^19.2.7",
4242
"react-dom": "^19.2.7",
43+
"react-helmet-async": "^3.0.0",
4344
"react-hook-form": "^7.63.0",
4445
"react-i18next": "^15.6.1",
4546
"react-markdown": "^10.1.0",

scripts/prerender.mjs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,16 +76,21 @@ async function snapshot(route) {
7676
// Wait until the app has rendered real content into #root — specifically,
7777
// past the "載入中..." / "Loading..." placeholder that all list/detail pages
7878
// show while their Supabase fetch is in flight. Without this the snapshot
79-
// can capture the loading state. Falls through after the timeout so a page
80-
// that genuinely never resolves still gets snapshotted as-is.
79+
// can capture the loading state. We also require react-helmet-async to have
80+
// applied the per-page <head> (it marks managed tags with `data-rh`), so the
81+
// snapshot carries page-specific title/description/og rather than the static
82+
// index.html shell — every public route renders a <SeoHead>. Falls through
83+
// after the timeout so a page that genuinely never resolves still gets
84+
// snapshotted as-is.
8185
await page
8286
.waitForFunction(
8387
() => {
8488
const root = document.getElementById('root');
8589
if (!root) return false;
8690
const text = (root.innerText || '').trim();
8791
if (text.length <= 50) return false;
88-
return !/|Loading/i.test(text);
92+
if (/|Loading/i.test(text)) return false;
93+
return Boolean(document.querySelector('title[data-rh]'));
8994
},
9095
{ timeout: 20000 }
9196
)

src/App.tsx

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Toaster } from '@/components/ui/toaster';
22
import { Toaster as Sonner } from '@/components/ui/sonner';
33
import { TooltipProvider } from '@/components/ui/tooltip';
44
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
5+
import { HelmetProvider } from 'react-helmet-async';
56
import { BrowserRouter, Routes, Route, useNavigate } from 'react-router-dom';
67
import { ThemeProvider } from 'next-themes';
78
import { FontProvider } from '@/contexts/FontContext';
@@ -54,25 +55,27 @@ function AppRoutes() {
5455
}
5556

5657
const App = () => (
57-
<QueryClientProvider client={queryClient}>
58-
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
59-
<FontProvider>
60-
<TooltipProvider>
61-
<Toaster />
62-
<Sonner />
63-
<BrowserRouter>
64-
<div className="min-h-screen bg-background flex flex-col">
65-
<Navigation />
66-
<main className="flex-1">
67-
<AppRoutes />
68-
</main>
69-
<Footer />
70-
</div>
71-
</BrowserRouter>
72-
</TooltipProvider>
73-
</FontProvider>
74-
</ThemeProvider>
75-
</QueryClientProvider>
58+
<HelmetProvider>
59+
<QueryClientProvider client={queryClient}>
60+
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
61+
<FontProvider>
62+
<TooltipProvider>
63+
<Toaster />
64+
<Sonner />
65+
<BrowserRouter>
66+
<div className="min-h-screen bg-background flex flex-col">
67+
<Navigation />
68+
<main className="flex-1">
69+
<AppRoutes />
70+
</main>
71+
<Footer />
72+
</div>
73+
</BrowserRouter>
74+
</TooltipProvider>
75+
</FontProvider>
76+
</ThemeProvider>
77+
</QueryClientProvider>
78+
</HelmetProvider>
7679
);
7780

7881
export default App;

src/components/SeoHead.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { Helmet } from 'react-helmet-async';
2+
import { useTranslation } from 'react-i18next';
3+
4+
interface SeoHeadProps {
5+
/** Page-specific title; the localized brand suffix is appended automatically. */
6+
title: string;
7+
/** Plain-text description for <meta name="description"> and og:description. */
8+
description?: string;
9+
/** Absolute image URL for og:image (e.g. a Supabase Storage URL). */
10+
image?: string;
11+
}
12+
13+
// Site-wide social-share fallback used when a page has no image of its own.
14+
const DEFAULT_OG_IMAGE = '/twitter-card.png';
15+
16+
/**
17+
* Sets per-page document head (title + description + Open Graph) via
18+
* react-helmet-async. On live visits this keeps the browser-tab title and
19+
* social-share metadata correct per route; at build time the prerender step
20+
* (scripts/prerender.mjs) waits for these tags before snapshotting, so AI
21+
* crawlers and search engines see unique metadata per page instead of the
22+
* static index.html shell.
23+
*/
24+
const SeoHead = ({ title, description, image }: SeoHeadProps) => {
25+
const { t } = useTranslation();
26+
const suffix = t('meta.titleSuffix');
27+
const fullTitle = title ? `${title} | ${suffix}` : suffix;
28+
29+
return (
30+
<Helmet>
31+
<title>{fullTitle}</title>
32+
<meta property="og:title" content={fullTitle} />
33+
{description && <meta name="description" content={description} />}
34+
{description && <meta property="og:description" content={description} />}
35+
<meta property="og:image" content={image || DEFAULT_OG_IMAGE} />
36+
<meta name="twitter:image" content={image || DEFAULT_OG_IMAGE} />
37+
</Helmet>
38+
);
39+
};
40+
41+
export default SeoHead;

src/i18n/index.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,25 @@ i18n.use(initReactI18next).init({
2929
},
3030
});
3131

32-
// Save language changes to localStorage
32+
// Map an i18n language code to a valid BCP-47 tag for <html lang>. Screen
33+
// readers and search engines rely on this; the app defaults to zh-TW content,
34+
// so the document must not advertise itself as English.
35+
const toHtmlLang = (lng: string) => (lng === 'zh-TW' ? 'zh-Hant-TW' : lng);
36+
37+
const applyHtmlLang = (lng: string) => {
38+
if (typeof document !== 'undefined') {
39+
document.documentElement.lang = toHtmlLang(lng);
40+
}
41+
};
42+
43+
// Sync on initial load (covers the case where the saved preference differs from
44+
// the static lang baked into index.html) and on every subsequent switch.
45+
applyHtmlLang(i18n.language);
46+
47+
// Save language changes to localStorage and keep <html lang> in sync.
3348
i18n.on('languageChanged', (lng) => {
3449
localStorage.setItem('language-preference', lng);
50+
applyHtmlLang(lng);
3551
});
3652

3753
export default i18n;

src/i18n/locales/en.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,14 @@
7575
},
7676
"meta": {
7777
"title": "SIM Weekly Prayers Hub",
78-
"description": "Weekly prayers for nations around the world"
78+
"description": "Weekly prayers for nations around the world",
79+
"titleSuffix": "SIM Taiwan",
80+
"prayers": {
81+
"description": "Browse weekly prayers that follow current world events — pray with us for the nations."
82+
},
83+
"familyPrayers": {
84+
"description": "Family-friendly weekly prayers and World Kids News to pray for the nations together with your children."
85+
}
7986
},
8087
"auth": {
8188
"welcome": "Welcome to SIM Prayer",

src/i18n/locales/zh-TW.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,14 @@
8686
},
8787
"meta": {
8888
"title": "SIM 每週禱告中心",
89-
"description": "為世界各國的每週禱告"
89+
"description": "為世界各國的每週禱告",
90+
"titleSuffix": "SIM 台灣",
91+
"prayers": {
92+
"description": "瀏覽每週禱告,跟著時事脈動,與我們同為世界萬國禱告。"
93+
},
94+
"familyPrayers": {
95+
"description": "適合全家大小的每週禱告與世界兒童新聞,帶著孩子一起為萬國禱告。"
96+
}
9097
},
9198
"auth": {
9299
"welcome": "歡迎來到 SIM 禱告",

src/index.css

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ All colors MUST be HSL.
4545
--secondary-foreground: 215 25% 25%;
4646

4747
--muted: 210 25% 96%;
48-
--muted-foreground: 215 15% 55%;
48+
/* Darkened from 215 15% 55% (~3.2:1) to meet WCAG AA 4.5:1 on --background. */
49+
--muted-foreground: 215 18% 42%;
4950

5051
--accent: 195 85% 88%;
5152
--accent-foreground: 215 25% 25%;

0 commit comments

Comments
 (0)