feat(site): prerender every locale and publish structured data, llms.txt and sitemaps
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
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.
This commit is contained in:
parent
52e364e578
commit
a0abda7a5c
45 changed files with 852 additions and 79 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
|
||||
import { createContext, useContext, type ReactNode } from 'react'
|
||||
import { createElement } from 'react'
|
||||
import { en } from './locales/en'
|
||||
import { ko } from './locales/ko'
|
||||
|
|
@ -129,9 +129,15 @@ export interface Translations {
|
|||
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]
|
||||
items: [FaqItem, FaqItem, FaqItem, FaqItem, FaqItem, FaqItem, FaqItem, FaqItem]
|
||||
}
|
||||
footer: {
|
||||
description: string
|
||||
|
|
@ -173,32 +179,24 @@ export const LOCALES: LocaleMeta[] = [
|
|||
]
|
||||
|
||||
// ── Translations map ───────────────────────────────────
|
||||
const translations: Record<Locale, Translations> = {
|
||||
export const translations: Record<Locale, Translations> = {
|
||||
en, ko, ja, zh, es, fr, de, pt, ru, vi,
|
||||
}
|
||||
|
||||
// ── Storage ────────────────────────────────────────────
|
||||
const STORAGE_KEY = 'd3ro-locale'
|
||||
/** 기본 언어. 루트(`/`)가 이 언어이고, 나머지는 `/{code}/` 에 따로 미리 그려진다. */
|
||||
export const DEFAULT_LOCALE: Locale = 'ko'
|
||||
|
||||
function isLocale(value: string): value is Locale {
|
||||
return value in translations
|
||||
export function isLocale(value: string): value is Locale {
|
||||
return Object.prototype.hasOwnProperty.call(translations, value)
|
||||
}
|
||||
|
||||
function detectLocale(): Locale {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored && isLocale(stored)) return stored
|
||||
} catch { /* 저장소 차단 환경 */ }
|
||||
|
||||
const browserLang = navigator.language.toLowerCase()
|
||||
const match = LOCALES.find((l) => browserLang === l.code || browserLang.startsWith(l.code + '-'))
|
||||
return match ? match.code : 'en'
|
||||
/** 언어별 페이지 경로. 검색 엔진이 언어마다 다른 주소를 색인하도록 언어를 주소로 나눈다. */
|
||||
export function localePath(locale: Locale): string {
|
||||
return locale === DEFAULT_LOCALE ? '/' : `/${locale}/`
|
||||
}
|
||||
|
||||
function persistLocale(locale: Locale): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, locale)
|
||||
} catch { /* 저장소 차단 환경 */ }
|
||||
export function localeMeta(locale: Locale): LocaleMeta {
|
||||
return LOCALES.find((l) => l.code === locale) ?? LOCALES[0]
|
||||
}
|
||||
|
||||
// ── Context ────────────────────────────────────────────
|
||||
|
|
@ -206,7 +204,6 @@ interface I18nContextValue {
|
|||
locale: Locale
|
||||
bcp47: string
|
||||
t: Translations
|
||||
setLocale: (locale: Locale) => void
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null)
|
||||
|
|
@ -219,26 +216,12 @@ export function useI18n(): I18nContextValue {
|
|||
|
||||
// ── Provider ───────────────────────────────────────────
|
||||
interface I18nProviderProps {
|
||||
/** 페이지 언어. 빌드 때 언어별 HTML을 미리 그리고, 브라우저는 같은 언어로 이어받는다. */
|
||||
locale: Locale
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }: I18nProviderProps) {
|
||||
const [locale, setLocaleState] = useState<Locale>(detectLocale)
|
||||
|
||||
const setLocale = useCallback((next: Locale) => {
|
||||
setLocaleState(next)
|
||||
persistLocale(next)
|
||||
}, [])
|
||||
|
||||
const t = translations[locale]
|
||||
const bcp47 = LOCALES.find((l) => l.code === locale)?.bcp47 ?? 'en-US'
|
||||
|
||||
// 문서 언어와 메타데이터도 선택한 언어를 따른다.
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = locale
|
||||
document.title = t.meta.title
|
||||
document.querySelector('meta[name="description"]')?.setAttribute('content', t.meta.description)
|
||||
}, [locale, t])
|
||||
|
||||
return createElement(I18nContext.Provider, { value: { locale, bcp47, t, setLocale } }, children)
|
||||
export function I18nProvider({ locale, children }: I18nProviderProps) {
|
||||
const value = { locale, bcp47: localeMeta(locale).bcp47, t: translations[locale] }
|
||||
return createElement(I18nContext.Provider, { value }, children)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue