Skip to content

Commit 2e1634e

Browse files
committed
feat: implement blog i18n
1 parent 3475667 commit 2e1634e

12 files changed

Lines changed: 151 additions & 34 deletions

File tree

apps/blog/astro.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ import { thumbnailIntegration } from '@explainer/thumbnail/integration'
1010

1111
export default defineConfig({
1212
site: 'https://blog.example.com',
13+
i18n: {
14+
locales: ['en', 'fr'],
15+
defaultLocale: 'en',
16+
routing: {
17+
prefixDefaultLocale: true,
18+
},
19+
},
1320
integrations: [
1421
react(),
1522
mdx({

apps/blog/src/components/HeroSection.astro

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
---
22
import type { Post } from '../lib/posts'
33
import { formatDate, getPostSlug, getReadingTime } from '../lib/posts'
4+
import { useTranslations } from '../i18n/utils'
45
56
interface Props {
67
posts: Post[]
8+
locale?: string
79
}
810
9-
const { posts } = Astro.props
11+
const { posts, locale = 'en' } = Astro.props
12+
const t = useTranslations(locale)
1013
const [first, second, third] = posts
1114
---
1215

@@ -35,9 +38,9 @@ const [first, second, third] = posts
3538
<h2 class="text-2xl font-bold group-hover:text-primary transition-colors">{first.data.title}</h2>
3639
<p class="text-muted-foreground mt-2">{first.data.description}</p>
3740
<div class="flex items-center gap-1.5 text-xs text-muted-foreground mt-3">
38-
<time datetime={first.data.date.toISOString()}>{formatDate(first.data.date)}</time>
41+
<time datetime={first.data.date.toISOString()}>{formatDate(first.data.date, locale)}</time>
3942
<span>·</span>
40-
<span>{getReadingTime(first.body ?? '')} min read</span>
43+
<span>{getReadingTime(first.body ?? '')} {t('hero.minRead')}</span>
4144
</div>
4245
</div>
4346
</a>
@@ -67,9 +70,9 @@ const [first, second, third] = posts
6770
<h2 class="text-2xl font-bold group-hover:text-primary transition-colors">{post.data.title}</h2>
6871
<p class="text-muted-foreground mt-2">{post.data.description}</p>
6972
<div class="flex items-center gap-1.5 text-xs text-muted-foreground mt-3">
70-
<time datetime={post.data.date.toISOString()}>{formatDate(post.data.date)}</time>
73+
<time datetime={post.data.date.toISOString()}>{formatDate(post.data.date, locale)}</time>
7174
<span>·</span>
72-
<span>{getReadingTime(post.body ?? '')} min read</span>
75+
<span>{getReadingTime(post.body ?? '')} {t('hero.minRead')}</span>
7376
</div>
7477
</div>
7578
</a>
@@ -100,9 +103,9 @@ const [first, second, third] = posts
100103
<h2 class="text-2xl font-bold group-hover:text-primary transition-colors">{first.data.title}</h2>
101104
<p class="text-muted-foreground mt-2">{first.data.description}</p>
102105
<div class="flex items-center gap-1.5 text-xs text-muted-foreground mt-3">
103-
<time datetime={first.data.date.toISOString()}>{formatDate(first.data.date)}</time>
106+
<time datetime={first.data.date.toISOString()}>{formatDate(first.data.date, locale)}</time>
104107
<span>·</span>
105-
<span>{getReadingTime(first.body ?? '')} min read</span>
108+
<span>{getReadingTime(first.body ?? '')} {t('hero.minRead')}</span>
106109
</div>
107110
</div>
108111
</a>
@@ -120,9 +123,9 @@ const [first, second, third] = posts
120123
<h2 class="text-xl font-bold group-hover:text-primary transition-colors">{post.data.title}</h2>
121124
<p class="text-muted-foreground text-sm mt-2">{post.data.description}</p>
122125
<div class="flex items-center gap-1.5 text-xs text-muted-foreground mt-3">
123-
<time datetime={post.data.date.toISOString()}>{formatDate(post.data.date)}</time>
126+
<time datetime={post.data.date.toISOString()}>{formatDate(post.data.date, locale)}</time>
124127
<span>·</span>
125-
<span>{getReadingTime(post.body ?? '')} min read</span>
128+
<span>{getReadingTime(post.body ?? '')} {t('hero.minRead')}</span>
126129
</div>
127130
</a>
128131
</article>

apps/blog/src/components/ShareButtons.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import * as React from 'react'
22
import { cn } from '@explainer/ui'
3+
import { useTranslations } from '../i18n/utils'
34

45
interface ShareButtonsProps {
56
url: string
67
title: string
78
horizontal?: boolean
9+
locale?: string
810
}
911

1012
function LinkedInIcon() {
@@ -48,7 +50,8 @@ function CheckIcon() {
4850
)
4951
}
5052

51-
export function ShareButtons({ url, title, horizontal }: ShareButtonsProps) {
53+
export function ShareButtons({ url, title, horizontal, locale = 'en' }: ShareButtonsProps) {
54+
const t = useTranslations(locale)
5255
const [copied, setCopied] = React.useState(false)
5356

5457
const handleCopy = async () => {
@@ -68,16 +71,16 @@ export function ShareButtons({ url, title, horizontal }: ShareButtonsProps) {
6871

6972
const buttons = (
7073
<>
71-
<a href={linkedinUrl} target="_blank" rel="noopener noreferrer" className={buttonClass} aria-label="Share on LinkedIn">
74+
<a href={linkedinUrl} target="_blank" rel="noopener noreferrer" className={buttonClass} aria-label={t('share.linkedin')}>
7275
<LinkedInIcon />
7376
</a>
74-
<a href={twitterUrl} target="_blank" rel="noopener noreferrer" className={buttonClass} aria-label="Share on Twitter">
77+
<a href={twitterUrl} target="_blank" rel="noopener noreferrer" className={buttonClass} aria-label={t('share.twitter')}>
7578
<TwitterIcon />
7679
</a>
77-
<a href={facebookUrl} target="_blank" rel="noopener noreferrer" className={buttonClass} aria-label="Share on Facebook">
80+
<a href={facebookUrl} target="_blank" rel="noopener noreferrer" className={buttonClass} aria-label={t('share.facebook')}>
7881
<FacebookIcon />
7982
</a>
80-
<button onClick={handleCopy} className={buttonClass} aria-label="Copy link">
83+
<button onClick={handleCopy} className={buttonClass} aria-label={t('share.copyLink')}>
8184
{copied ? <CheckIcon /> : <LinkIcon />}
8285
</button>
8386
</>

apps/blog/src/components/TableOfContents.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as React from 'react'
22
import { cn } from '@explainer/ui'
3+
import { useTranslations } from '../i18n/utils'
34

45
export interface TocHeading {
56
depth: number
@@ -9,9 +10,11 @@ export interface TocHeading {
910

1011
interface TableOfContentsProps {
1112
headings: TocHeading[]
13+
locale?: string
1214
}
1315

14-
export function TableOfContents({ headings }: TableOfContentsProps) {
16+
export function TableOfContents({ headings, locale = 'en' }: TableOfContentsProps) {
17+
const t = useTranslations(locale)
1518
const filtered = headings.filter((h) => h.depth >= 2 && h.depth <= 3)
1619
const [activeId, setActiveId] = React.useState<string>('')
1720

@@ -42,7 +45,7 @@ export function TableOfContents({ headings }: TableOfContentsProps) {
4245

4346
return (
4447
<nav className="sticky top-24 h-fit">
45-
<p className="text-sm font-medium mb-3">On this page</p>
48+
<p className="text-sm font-medium mb-3">{t('toc.title')}</p>
4649
<ul className="border-l border-border space-y-0.5">
4750
{filtered.map((heading) => (
4851
<li key={heading.slug}>

apps/blog/src/components/TagFilter.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { useEffect, useState } from 'react';
2+
import { useTranslations } from '../i18n/utils';
23

34
interface TagFilterProps {
45
tags: { name: string; count: number }[]
56
initialTags?: string[]
7+
locale?: string
68
}
79

810
function TagIcon({ className }: { className?: string }) {
@@ -39,7 +41,8 @@ function writeURL(selectedTags: string[], query: string) {
3941
window.history.replaceState(null, '', url)
4042
}
4143

42-
export function TagFilter({ tags, initialTags = [] }: TagFilterProps) {
44+
export function TagFilter({ tags, initialTags = [], locale = 'en' }: TagFilterProps) {
45+
const t = useTranslations(locale)
4346
const [selectedTags, setSelectedTags] = useState<string[]>(initialTags)
4447
const [query, setQuery] = useState<string>('')
4548

@@ -84,7 +87,7 @@ export function TagFilter({ tags, initialTags = [] }: TagFilterProps) {
8487
type="text"
8588
value={query}
8689
onChange={(e) => setQuery(e.target.value)}
87-
placeholder="Rechercher un article..."
90+
placeholder={t('tagFilter.placeholder')}
8891
className="w-full rounded-md border border-border bg-transparent pl-10 pr-4 py-2 max-w-sm text-sm text-foreground placeholder:text-muted-foreground outline-none transition-colors focus:border-primary/50"
8992
/>
9093
</div>

apps/blog/src/components/blog-navbar.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { Navbar, MobileMenu, MobileNavLinks, LocaleSwitcher, getAppLinks } from '@explainer/ui'
21
import type { NavbarLink } from '@explainer/ui'
2+
import { LocaleSwitcher, MobileMenu, MobileNavLinks, Navbar, getAppLinks } from '@explainer/ui'
3+
import { useTranslations } from '../i18n/utils'
34

45
interface BlogNavbarProps {
56
activePath: string
@@ -11,11 +12,11 @@ interface BlogNavbarProps {
1112

1213
export function BlogNavbar({ activePath, appUrlOverrides, locale, locales, localeSwitchUrls }: BlogNavbarProps) {
1314
const appLinks = getAppLinks('blog', appUrlOverrides)
15+
const t = useTranslations(locale)
1416

1517
const blogLinks: NavbarLink[] = [
16-
{ label: 'Tous les articles', href: `/${locale}`, icon: 'lucide:newspaper' },
17-
{ label: 'Catégories', href: '/tags', icon: 'lucide:folder' },
18-
{ label: 'RSS', href: `/${locale}/rss.xml`, icon: 'lucide:rss' },
18+
{ label: t('nav.allArticles'), href: `/${locale}`, icon: 'lucide:newspaper' },
19+
{ label: t('nav.rss'), href: `/${locale}/rss.xml`, icon: 'lucide:rss' },
1920
]
2021

2122
return (

apps/blog/src/i18n/ui.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
export const defaultLang = 'en' as const
2+
3+
export const ui = {
4+
en: {
5+
// Index page
6+
'index.title': 'Articles',
7+
'index.heading.prefix': 'Latest',
8+
'index.heading.highlight': 'articles',
9+
'index.empty': 'No articles yet.',
10+
11+
// RSS
12+
'rss.title': 'Explainer Blog',
13+
'rss.description': 'Latest articles from the Explainer blog',
14+
15+
// Footer
16+
'footer.text': 'Built with Explainer v2',
17+
18+
// Post layout
19+
'post.back': 'Back to writing',
20+
'post.published': 'Published',
21+
22+
// Hero section
23+
'hero.minRead': 'min read',
24+
25+
// Navbar
26+
'nav.allArticles': 'All articles',
27+
'nav.categories': 'Categories',
28+
'nav.rss': 'RSS',
29+
30+
// Tag filter
31+
'tagFilter.placeholder': 'Search articles...',
32+
33+
// Table of contents
34+
'toc.title': 'On this page',
35+
36+
// Share buttons
37+
'share.linkedin': 'Share on LinkedIn',
38+
'share.twitter': 'Share on Twitter',
39+
'share.facebook': 'Share on Facebook',
40+
'share.copyLink': 'Copy link',
41+
},
42+
fr: {
43+
// Index page
44+
'index.title': 'Articles',
45+
'index.heading.prefix': 'Derniers',
46+
'index.heading.highlight': 'articles',
47+
'index.empty': 'Aucun article pour le moment.',
48+
49+
// RSS
50+
'rss.title': 'Blog Explainer',
51+
'rss.description': 'Les derniers articles du blog Explainer',
52+
53+
// Footer
54+
'footer.text': 'Construit avec Explainer v2',
55+
56+
// Post layout
57+
'post.back': "Retour aux articles",
58+
'post.published': 'Publié le',
59+
60+
// Hero section
61+
'hero.minRead': 'min de lecture',
62+
63+
// Navbar
64+
'nav.allArticles': 'Tous les articles',
65+
'nav.categories': 'Catégories',
66+
'nav.rss': 'RSS',
67+
68+
// Tag filter
69+
'tagFilter.placeholder': 'Rechercher un article...',
70+
71+
// Table of contents
72+
'toc.title': 'Sur cette page',
73+
74+
// Share buttons
75+
'share.linkedin': 'Partager sur LinkedIn',
76+
'share.twitter': 'Partager sur Twitter',
77+
'share.facebook': 'Partager sur Facebook',
78+
'share.copyLink': 'Copier le lien',
79+
},
80+
} as const
81+
82+
export type UiKey = keyof (typeof ui)['en']

apps/blog/src/i18n/utils.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { ui, defaultLang, type UiKey } from './ui'
2+
3+
export function useTranslations(locale: string) {
4+
return function t(key: UiKey): string {
5+
return ui[locale as keyof typeof ui]?.[key] ?? ui[defaultLang][key]
6+
}
7+
}

apps/blog/src/layouts/base.astro

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import '../styles/globals.css'
33
import { ClientRouter } from 'astro:transitions'
44
import { BlogNavbar } from '../components/blog-navbar'
5+
import { useTranslations } from '../i18n/utils'
56
67
interface Props {
78
title: string
@@ -13,6 +14,7 @@ interface Props {
1314
}
1415
1516
const { title, description = 'Explainer Blog', thumbnail, locale = 'en', locales = ['en'], localeSwitchUrls = {} } = Astro.props
17+
const t = useTranslations(locale)
1618
const thumbnailUrl = thumbnail ?? `${Astro.url.pathname.replace(/\/$/, '')}/thumbnail.png`
1719
1820
const appUrlOverrides = {
@@ -60,7 +62,7 @@ const appUrlOverrides = {
6062
</main>
6163
<footer class="border-t mt-16">
6264
<div class="mx-auto max-w-6xl px-6 py-8 text-sm text-muted-foreground">
63-
Built with Explainer v2
65+
{t('footer.text')}
6466
</div>
6567
</footer>
6668
</body>

apps/blog/src/layouts/post.astro

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Base from './base.astro'
33
import { formatDate } from '../lib/posts'
44
import { TableOfContents } from '../components/TableOfContents'
55
import { ShareButtons } from '../components/ShareButtons'
6+
import { useTranslations } from '../i18n/utils'
67
78
interface Heading {
89
depth: number
@@ -26,19 +27,20 @@ interface Props {
2627
}
2728
2829
const { title, description, date, tags, cover, thumbnail, readingTime, headings, url, locale = 'en', locales, localeSwitchUrls } = Astro.props
30+
const t = useTranslations(locale)
2931
---
3032

3133
<Base title={title} description={description} thumbnail={thumbnail} locale={locale} locales={locales} localeSwitchUrls={localeSwitchUrls}>
3234
<div class="mb-8">
3335
<a href={`/${locale}`} class="text-sm font-medium text-primary hover:underline transition-colors">
34-
&larr; Back to writing
36+
&larr; {t('post.back')}
3537
</a>
3638
</div>
3739

3840
<div class="flex gap-8 relative">
3941
<!-- Left column: Share buttons (sticky) -->
4042
<aside class="hidden lg:block w-12 shrink-0">
41-
<ShareButtons url={url} title={title} client:load />
43+
<ShareButtons url={url} title={title} locale={locale} client:load />
4244
</aside>
4345

4446
<!-- Center column: Article -->
@@ -47,7 +49,7 @@ const { title, description, date, tags, cover, thumbnail, readingTime, headings,
4749
<h1 class="text-4xl md:text-5xl font-bold tracking-tight leading-tight mb-4">{title}</h1>
4850
<p class="text-lg text-muted-foreground mb-4">{description}</p>
4951
<p class="text-sm text-muted-foreground">
50-
Published {formatDate(date, locale)}
52+
{t('post.published')} {formatDate(date, locale)}
5153
</p>
5254
</header>
5355
{tags.length > 0 && (
@@ -74,13 +76,13 @@ const { title, description, date, tags, cover, thumbnail, readingTime, headings,
7476
</div>
7577
<!-- Share buttons mobile (bottom of article) -->
7678
<div class="lg:hidden mt-10">
77-
<ShareButtons url={url} title={title} horizontal client:load />
79+
<ShareButtons url={url} title={title} locale={locale} horizontal client:load />
7880
</div>
7981
</article>
8082

8183
<!-- Right column: Table of Contents (sticky) -->
8284
<aside class="hidden xl:block w-56 shrink-0">
83-
<TableOfContents headings={headings} client:load />
85+
<TableOfContents headings={headings} locale={locale} client:load />
8486
</aside>
8587
</div>
8688
</Base>

0 commit comments

Comments
 (0)