d3ro-voice/site/src/i18n/index.ts
Yun Chan a0abda7a5c
Some checks failed
ci / 정본·보안·린트·타입·테스트 (push) Successful in 53s
ci / 모바일 린트·타입·Jest (push) Successful in 45s
ci / Supabase Edge Functions + Cloudflare Worker (push) Successful in 21s
ci / .NET API 서버 테스트 (push) Successful in 13s
deploy-site / deploy (push) Failing after 27s
ci / 워크스페이스 빌드 검증 (push) Successful in 36s
feat(site): prerender every locale and publish structured data, llms.txt and sitemaps
Crawlers received an empty client-rendered shell (107 characters of text);
most AI crawlers do not run JavaScript, so the product was invisible to them.

- Build renders each locale to static HTML (/ for Korean, /en/ … /vi/) with
  src/entry-server.tsx + scripts/prerender.mjs; the browser hydrates the same
  locale. The language menu links to those pages instead of switching in place.
- Per-locale head: title, description, canonical, hreflang (+ x-default /en/),
  Open Graph and X cards with a per-locale 1200x630 image.
- JSON-LD graph: Organization, WebSite, WebPage, SoftwareApplication (KRW
  offers, version, release date, download URL from the canonical sources),
  HowTo and FAQPage. No invented ratings.
- robots.txt (search, ai-input and ai-train allowed; /app, /api, invites
  excluded), sitemap.xml with language alternates, llms.txt and llms-full.txt
  generated from the same translations and canonical values.
- IndexNow key and a post-deploy ping (Bing, Naver, Yandex).
- A factual "at a glance" section and two FAQ answers (recognised languages,
  where data is stored) in all 10 languages.
- Icons, apple-touch-icon and web manifest; asset base is now absolute so
  sub-path pages load the same bundle.

Verified: 3.6k-7.7k characters of text per page, no hydration warnings,
no overflow at 320/1440 in six locales, axe 0, Lighthouse mobile SEO,
accessibility and best practices 100.
2026-09-27 10:45:05 +09:00

227 lines
6.9 KiB
TypeScript

import { createContext, useContext, type ReactNode } from 'react'
import { createElement } from 'react'
import { en } from './locales/en'
import { ko } from './locales/ko'
import { ja } from './locales/ja'
import { zh } from './locales/zh'
import { es } from './locales/es'
import { fr } from './locales/fr'
import { de } from './locales/de'
import { pt } from './locales/pt'
import { ru } from './locales/ru'
import { vi } from './locales/vi'
// ── Types ──────────────────────────────────────────────
export type Locale = 'en' | 'ko' | 'ja' | 'zh' | 'es' | 'fr' | 'de' | 'pt' | 'ru' | 'vi'
export interface TitledText {
title: string
body: string
}
export interface DemoSample {
raw: string
clean: string
}
export interface FaqItem {
q: string
a: string
}
export interface PlanCopy {
name: string
desc: string
cta: string
features: string[]
}
/**
* 사이트의 모든 문구. 컴포넌트에 문자열을 직접 쓰지 않는다.
* `{name}` 자리표시자는 `fmt()`로 채운다.
*/
export interface Translations {
meta: {
title: string
description: string
}
a11y: {
skipToContent: string
primaryNav: string
openMenu: string
closeMenu: string
language: string
opensInNewTab: string
}
nav: {
features: string
how: string
privacy: string
pricing: string
faq: string
download: string
}
hero: {
title1: string
title2: string
subtitle: string
hotkeyKey: string
hotkeyAction: string
trust: string
/** 방문자가 Windows가 아닐 때 다운로드 버튼 아래에 보인다. */
windowsOnly: string
}
demo: {
/** figure 의 접근 가능한 이름 */
label: string
/** 화면 낭독용 설명. 애니메이션 자체는 aria-hidden 이다. */
description: string
note: string
windowTitle: string
docLine: string
/** 캡슐의 처리 중 문구 */
processing: string
stateIdle: string
stateListening: string
statePolishing: string
stateDone: string
pause: string
play: string
/** raw 는 띄어쓰기 단위로 한 낱말씩 나타난다. */
samples: [DemoSample, DemoSample]
}
features: {
title: string
subtitle: string
items: [TitledText, TitledText, TitledText, TitledText, TitledText, TitledText]
}
how: {
title: string
subtitle: string
steps: [TitledText, TitledText, TitledText, TitledText]
}
privacy: {
title: string
subtitle: string
points: [TitledText, TitledText, TitledText, TitledText]
policyLink: string
}
pricing: {
title: string
subtitle: string
perMonth: string
unlimited: string
note: string
free: PlanCopy
pro: PlanCopy
proPlus: PlanCopy
}
download: {
title: string
subtitle: string
windowsName: string
windowsMeta: string
cta: string
/** {date}: 게시일 (release.ts의 DESKTOP_RELEASE_DATE, 버전 동기화 대상) */
details: string
releaseNotes: string
checksum: string
soonTitle: string
soon: [TitledText, TitledText]
}
/** 사실만 모은 요약. 검색·AI 답변이 그대로 인용할 수 있게 짧은 정의형 문장으로 쓴다. */
facts: {
title: string
/** {version}·{date}·{pro}·{proPlus} 는 release.ts·plan-catalog 정본에서 채운다. */
rows: [TitledText, TitledText, TitledText, TitledText, TitledText, TitledText, TitledText, TitledText]
}
faq: {
title: string
items: [FaqItem, FaqItem, FaqItem, FaqItem, FaqItem, FaqItem, FaqItem, FaqItem]
}
footer: {
description: string
privacy: string
terms: string
deleteAccount: string
releaseNotes: string
copyright: string
}
}
/** `{key}` 자리표시자를 값으로 바꾼다. */
export function fmt(template: string, values: Record<string, string | number>): string {
return template.replace(/\{(\w+)\}/g, (match, key: string) =>
key in values ? String(values[key]) : match,
)
}
// ── Locale metadata ────────────────────────────────────
export interface LocaleMeta {
code: Locale
label: string
nativeName: string
/** Intl 포맷용 BCP 47 태그 */
bcp47: string
}
export const LOCALES: LocaleMeta[] = [
{ code: 'ko', label: 'KO', nativeName: '한국어', bcp47: 'ko-KR' },
{ code: 'en', label: 'EN', nativeName: 'English', bcp47: 'en-US' },
{ code: 'ja', label: 'JA', nativeName: '日本語', bcp47: 'ja-JP' },
{ code: 'zh', label: 'ZH', nativeName: '简体中文', bcp47: 'zh-CN' },
{ code: 'es', label: 'ES', nativeName: 'Español', bcp47: 'es-ES' },
{ code: 'fr', label: 'FR', nativeName: 'Français', bcp47: 'fr-FR' },
{ code: 'de', label: 'DE', nativeName: 'Deutsch', bcp47: 'de-DE' },
{ code: 'pt', label: 'PT', nativeName: 'Português', bcp47: 'pt-BR' },
{ code: 'ru', label: 'RU', nativeName: 'Русский', bcp47: 'ru-RU' },
{ code: 'vi', label: 'VI', nativeName: 'Tiếng Việt', bcp47: 'vi-VN' },
]
// ── Translations map ───────────────────────────────────
export const translations: Record<Locale, Translations> = {
en, ko, ja, zh, es, fr, de, pt, ru, vi,
}
/** 기본 언어. 루트(`/`)가 이 언어이고, 나머지는 `/{code}/` 에 따로 미리 그려진다. */
export const DEFAULT_LOCALE: Locale = 'ko'
export function isLocale(value: string): value is Locale {
return Object.prototype.hasOwnProperty.call(translations, value)
}
/** 언어별 페이지 경로. 검색 엔진이 언어마다 다른 주소를 색인하도록 언어를 주소로 나눈다. */
export function localePath(locale: Locale): string {
return locale === DEFAULT_LOCALE ? '/' : `/${locale}/`
}
export function localeMeta(locale: Locale): LocaleMeta {
return LOCALES.find((l) => l.code === locale) ?? LOCALES[0]
}
// ── Context ────────────────────────────────────────────
interface I18nContextValue {
locale: Locale
bcp47: string
t: Translations
}
const I18nContext = createContext<I18nContextValue | null>(null)
export function useI18n(): I18nContextValue {
const ctx = useContext(I18nContext)
if (!ctx) throw new Error('useI18n must be used within I18nProvider')
return ctx
}
// ── Provider ───────────────────────────────────────────
interface I18nProviderProps {
/** 페이지 언어. 빌드 때 언어별 HTML을 미리 그리고, 브라우저는 같은 언어로 이어받는다. */
locale: Locale
children: ReactNode
}
export function I18nProvider({ locale, children }: I18nProviderProps) {
const value = { locale, bcp47: localeMeta(locale).bcp47, t: translations[locale] }
return createElement(I18nContext.Provider, { value }, children)
}