Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
- **アンドゥ** — 角調整・比較画面で操作を一手戻せる
- **比較結果シェア** — クネクネ動画(GIF)・おわかりいただけただろうか動画(MP4)を生成してSNSでシェア
- **PWA対応** — ホーム画面にインストールしてアプリのように使える
- **日本語 / 英語対応** — ブラウザの言語設定で自動切替、手動切替も可能
- **日本語 / 英語対応** — 日本語は `/`、英語は専用URL `/en`(`hreflang` 相互リンク)。`/` はブラウザ言語で自動判定、トグルで切替

## 使い方

Expand Down
6 changes: 6 additions & 0 deletions app/(en)/en/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import HomePage from '@/components/HomePage'

// `/en` — English entry point. Same app body; the (en) root layout forces English.
export default function Page() {
return <HomePage />
}
34 changes: 34 additions & 0 deletions app/(en)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { Viewport } from 'next'
import ToastContainer from '@/components/Toast'
import ServiceWorkerRegister from '@/components/ServiceWorkerRegister'
import { I18nProvider } from '@/lib/i18n'
import { buildMetadata, buildJsonLd } from '@/lib/seo'
import '../globals.css'

// English root layout — serves `/en` with its own <html lang="en"> and English
// metadata. forcedLang pins the UI to English so it matches the URL/static html.
export const metadata = buildMetadata('en')

export const viewport: Viewport = {
themeColor: '#FEF6DC',
}

const jsonLd = buildJsonLd('en')

export default function EnRootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<body className="antialiased">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<I18nProvider forcedLang="en">
{children}
<ToastContainer />
</I18nProvider>
<ServiceWorkerRegister />
</body>
</html>
)
}
35 changes: 35 additions & 0 deletions app/(ja)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { Viewport } from 'next'
import ToastContainer from '@/components/Toast'
import ServiceWorkerRegister from '@/components/ServiceWorkerRegister'
import { I18nProvider } from '@/lib/i18n'
import { buildMetadata, buildJsonLd } from '@/lib/seo'
import '../globals.css'

// Japanese root layout — serves `/`. The (ja) route group keeps this at the
// site root (groups don't affect the URL) while letting `/en` have its own
// <html lang="en"> root layout.
export const metadata = buildMetadata('ja')

export const viewport: Viewport = {
themeColor: '#FEF6DC',
}

const jsonLd = buildJsonLd('ja')

export default function JaRootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="ja">
<body className="antialiased">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<I18nProvider>
{children}
<ToastContainer />
</I18nProvider>
<ServiceWorkerRegister />
</body>
</html>
)
}
6 changes: 6 additions & 0 deletions app/(ja)/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import HomePage from '@/components/HomePage'

// `/` — Japanese entry point.
export default function Page() {
return <HomePage />
}
98 changes: 0 additions & 98 deletions app/layout.tsx

This file was deleted.

18 changes: 15 additions & 3 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,28 @@ const siteUrl = 'https://machigai-salad.llll-ll.com'

export const dynamic = 'force-static'

// Single-page app: one canonical entry is enough. lastModified uses the build
// date (JST) already exposed via next.config so the sitemap stays in step with
// Both language URLs (`/` ja, `/en` en), each cross-linked via hreflang
// alternates so the English page is explicitly crawlable rather than only
// discoverable through the Japanese page. lastModified uses the build date
// (JST) already exposed via next.config so the sitemap stays in step with
// each deploy without manual editing.
export default function sitemap(): MetadataRoute.Sitemap {
const lastModified = process.env.BUILD_DATE
const languages = { ja: siteUrl, en: `${siteUrl}/en` }
return [
{
url: siteUrl,
lastModified: process.env.BUILD_DATE,
lastModified,
changeFrequency: 'monthly',
priority: 1,
alternates: { languages },
},
{
url: `${siteUrl}/en`,
lastModified,
changeFrequency: 'monthly',
priority: 1,
alternates: { languages },
},
]
}
3 changes: 2 additions & 1 deletion app/page.tsx → components/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import VisitorCounter from '@/components/VisitorCounter'
import ShareButtons from '@/components/ShareButtons'
import AffiliateGrid from '@/components/AffiliateGrid'

export default function Home() {
// Shared app body, rendered by both root routes: `/` (ja) and `/en` (en).
export default function HomePage() {
const { t } = useI18n()

return (
Expand Down
40 changes: 23 additions & 17 deletions components/LangToggle.tsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,45 @@
'use client'

import { useI18n } from '@/lib/i18n'
import { saveLang } from '@/lib/storage'

// URL is the source of truth for language: JA = `/`, EN = `/en`. The toggle
// navigates between the two roots (a full load, since they are separate root
// layouts) and records the choice so the `/` route's auto-detect respects it.
const base = process.env.NEXT_PUBLIC_BASE_PATH || ''

export default function LangToggle() {
const { lang, setLang } = useI18n()
const { lang } = useI18n()

const linkStyle = (active: boolean) => ({
fontWeight: active ? 700 : 400,
color: active ? 'var(--olive)' : 'var(--muted)',
background: active ? 'rgba(107,127,62,0.15)' : 'transparent',
})

return (
<div className="flex items-center text-xs" style={{ color: 'var(--muted)' }}>
<button
onClick={() => setLang('ja')}
<a
href={`${base}/`}
onClick={() => saveLang('ja')}
className="rounded px-2 py-2"
style={{
fontWeight: lang === 'ja' ? 700 : 400,
color: lang === 'ja' ? 'var(--olive)' : 'var(--muted)',
background: lang === 'ja' ? 'rgba(107,127,62,0.15)' : 'transparent',
}}
style={linkStyle(lang === 'ja')}
aria-label="日本語"
>
JA
</button>
</a>
<span className="mx-1" style={{ fontSize: 10, opacity: 0.5 }}>
/
</span>
<button
onClick={() => setLang('en')}
<a
href={`${base}/en`}
onClick={() => saveLang('en')}
className="rounded px-2 py-2"
style={{
fontWeight: lang === 'en' ? 700 : 400,
color: lang === 'en' ? 'var(--olive)' : 'var(--muted)',
background: lang === 'en' ? 'rgba(107,127,62,0.15)' : 'transparent',
}}
style={linkStyle(lang === 'en')}
aria-label="English"
>
EN
</button>
</a>
</div>
)
}
29 changes: 29 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,33 @@
└─────────────────────────────────────┘
```

### ルーティングと多言語 (i18n)

言語ごとに **別URL** を持ち、各ページが自分の `<html lang>` を静的に出力する。
これは Next.js の **route group で root layout を2つ**に分ける構成で実現している。

```
app/
├── (ja)/ # URL `/` — <html lang="ja">
│ ├── layout.tsx # JA メタ / JSON-LD(inLanguage [ja,en])
│ └── page.tsx # → <HomePage />
├── (en)/ # URL `/en` — <html lang="en">
│ ├── layout.tsx # EN メタ / JSON-LD、I18nProvider forcedLang="en"
│ └── en/page.tsx # → <HomePage />
├── globals.css
├── robots.ts # 静的 robots.txt
└── sitemap.ts # 静的 sitemap.xml
```

- `components/HomePage.tsx` が本体(両ルートで共有描画)
- `lib/seo.ts` に言語別 metadata / JSON-LD を集約。`alternates.languages` で
**hreflang(ja=`/` / en=`/en` / x-default=`/`)** と canonical を両ページに出力
- `lib/i18n.tsx` の `I18nProvider` は `forcedLang` を受け取る。`/en` は en 固定
(navigator / localStorage より URL を優先=ちらつき防止)。`/` は従来どおり
ブラウザ言語で自動判定
- `LangToggle` は 2ルート間のナビゲーション(JA→`/`、EN→`/en`)。選択を
localStorage に保存し、`/` の自動判定が選択を尊重する

### 画像処理パイプライン

```
Expand Down Expand Up @@ -355,6 +382,8 @@ alert('😅 うまくいかなかったみたい。もう一度写真をとっ
- ✅ PWA化(manifest + Service Worker + OGP)
- ✅ QRコード共有
- ✅ ヘッダバナー
- ✅ SEO土台(robots.txt / sitemap.xml / JSON-LD / meta description)
- ✅ 多言語ルーティング(日本語 `/` + 英語 `/en`、hreflang 相互リンク)

### 今後の候補

Expand Down
16 changes: 13 additions & 3 deletions lib/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,10 +217,20 @@ const I18nContext = createContext<I18nContextValue>({
t: (key) => dict[key].ja,
})

export function I18nProvider({ children }: { children: React.ReactNode }) {
const [lang, setLangState] = useState<Lang>('ja')
export function I18nProvider({
children,
forcedLang,
}: {
children: React.ReactNode
// When set (the /en route), the URL owns the language: skip stored prefs and
// navigator auto-detect so the rendered language always matches the URL and
// the static <html lang>. Left undefined on `/`, which keeps auto-detection.
forcedLang?: Lang
}) {
const [lang, setLangState] = useState<Lang>(forcedLang ?? 'ja')

useEffect(() => {
if (forcedLang) return
const stored = loadLang()
if (stored) {
setLangState(stored)
Expand All @@ -237,7 +247,7 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
if (!langs.some((l) => l.toLowerCase().startsWith('ja'))) {
setLangState('en')
}
}, [])
}, [forcedLang])

useEffect(() => {
document.documentElement.lang = lang
Expand Down
Loading
Loading