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
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.
227 lines
6.9 KiB
TypeScript
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)
|
|
}
|