// site/src/seo/documents.ts
// 빌드 때 언어별
, 구조화 데이터(JSON-LD), sitemap.xml, robots.txt, llms.txt 를 만든다.
// 문구는 i18n 번역, 사실 값(가격·버전·날짜·주소)은 정본(plan-catalog, web-urls, release.ts)에서만 가져온다.
// 화면에 보이는 내용과 다른 말을 구조화 데이터·llms.txt 에 쓰지 않는다.
import { PLAN_PRICE_KRW } from '../../../packages/core/src/plan-catalog'
import { PUBLIC_SITE_ORIGIN, SITE_URLS, WEB_APP_BASE_PATH } from '../../../packages/core/src/web-urls'
import { DEFAULT_LOCALE, LOCALES, fmt, localeMeta, localePath, translations, type Locale } from '../i18n'
import {
DESKTOP_FEED_URL,
DESKTOP_RELEASE_DATE,
DESKTOP_RELEASE_HUB_URL,
DESKTOP_VERSION,
DESKTOP_WINDOWS_INSTALLER_URL,
} from '../release'
import { planCloudQuota } from '../pricing'
import { factValues } from './fact-values'
const BRAND = 'D3RO Voice'
/** 검색 엔진이 맞는 언어를 못 찾을 때 보여 줄 페이지. 국제 방문자에게는 영어가 낫다. */
const X_DEFAULT_LOCALE: Locale = 'en'
const OG_LOCALE: Record = {
ko: 'ko_KR', en: 'en_US', ja: 'ja_JP', zh: 'zh_CN', es: 'es_ES',
fr: 'fr_FR', de: 'de_DE', pt: 'pt_BR', ru: 'ru_RU', vi: 'vi_VN',
}
export function pageUrl(locale: Locale): string {
return `${PUBLIC_SITE_ORIGIN}${localePath(locale)}`
}
function ogImageUrl(locale: Locale): string {
return `${PUBLIC_SITE_ORIGIN}/og/${locale}.png`
}
function escapeAttr(value: string): string {
return value.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>')
}
function escapeText(value: string): string {
return value.replace(/&/g, '&').replace(//g, '>')
}
/** `,
].join('\n ')
}
// ── sitemap.xml ───────────────────────────────────────────
export function buildSitemap(lastmod: string): string {
const alternates = [
...LOCALES.map((l) => ` `),
` `,
].join('\n')
const localeEntries = LOCALES.map(
(l) => ` \n ${pageUrl(l.code)}\n ${lastmod}\n${alternates}\n `,
)
const legalEntries = [SITE_URLS.privacy, SITE_URLS.terms, SITE_URLS.deleteAccount].map(
(loc) => ` \n ${loc}\n ${lastmod}\n `,
)
return [
'',
'',
...localeEntries,
...legalEntries,
'',
'',
].join('\n')
}
/** sitemap 에 실린 모든 주소. IndexNow 알림에 쓴다. */
export function sitemapUrls(): string[] {
return [...LOCALES.map((l) => pageUrl(l.code)), SITE_URLS.privacy, SITE_URLS.terms, SITE_URLS.deleteAccount]
}
// ── robots.txt ────────────────────────────────────────────
export function buildRobots(): string {
// 검색 색인·AI 답변 근거·AI 학습 모두 허용한다(홍보가 목적). Content-Signal 은 Cloudflare 가 정의한 신호다.
return [
'User-agent: *',
'Content-Signal: search=yes, ai-input=yes, ai-train=yes',
'Allow: /',
`Disallow: ${WEB_APP_BASE_PATH}/`,
'Disallow: /api/',
'Disallow: /accept-invite/',
'',
`Sitemap: ${PUBLIC_SITE_ORIGIN}/sitemap.xml`,
'',
].join('\n')
}
// ── llms.txt ──────────────────────────────────────────────
function factLines(locale: Locale): string[] {
const t = translations[locale]
const values = factValues(locale)
return t.facts.rows.map((row) => `- ${row.title}: ${fmt(row.body, values)}`)
}
/** llmstxt.org 형식의 짧은 안내. AI 검색이 제품을 한 번에 이해하도록 사실과 주요 주소만 둔다. */
export function buildLlms(): string {
const en = translations.en
const ko = translations.ko
return [
`# ${BRAND}`,
'',
`> ${en.meta.description}`,
'',
`${ko.meta.description}`,
'',
'## Key facts',
'',
...factLines('en'),
'',
'## Pages',
'',
...LOCALES.map((l) => `- [${BRAND} (${l.nativeName})](${pageUrl(l.code)}): ${translations[l.code].meta.title}`),
`- [Download for Windows](${pageUrl(DEFAULT_LOCALE)}#download): ${en.download.subtitle}`,
`- [Release notes ${DESKTOP_VERSION}](${DESKTOP_RELEASE_HUB_URL})`,
`- [Update feed](${DESKTOP_FEED_URL}/latest.yml)`,
`- [Privacy policy](${SITE_URLS.privacy})`,
`- [Terms of service](${SITE_URLS.terms})`,
'',
'## Optional',
'',
`- [Full site text in English and Korean](${PUBLIC_SITE_ORIGIN}/llms-full.txt)`,
'',
].join('\n')
}
function fullText(locale: Locale): string[] {
const t = translations[locale]
const values = factValues(locale)
const money = new Intl.NumberFormat(localeMeta(locale).bcp47, { style: 'currency', currency: 'KRW', maximumFractionDigits: 0 })
const count = new Intl.NumberFormat(localeMeta(locale).bcp47)
// 요금제 카드와 같은 값으로 사용량 자리표시자를 채운다(Pricing.tsx 와 같은 규칙).
const plan = (copy: typeof t.pricing.free, price: number, tier: 'free' | 'pro' | 'pro_plus') => {
const quota = Object.fromEntries(
Object.entries(planCloudQuota(tier)).map(([k, v]) => [k, v < 0 ? t.pricing.unlimited : count.format(v)]),
)
return [
`### ${copy.name} — ${money.format(price)}${price > 0 ? t.pricing.perMonth : ''}`,
copy.desc,
...copy.features.map((f) => `- ${fmt(f, quota)}`),
'',
]
}
return [
`# ${t.meta.title}`,
'',
`Source: ${pageUrl(locale)}`,
'',
t.meta.description,
'',
`## ${t.facts.title}`,
'',
...t.facts.rows.map((row) => `- **${row.title}**: ${fmt(row.body, values)}`),
'',
`## ${t.features.title}`,
'',
t.features.subtitle,
'',
...t.features.items.flatMap((item) => [`### ${item.title}`, item.body, '']),
`## ${t.how.title}`,
'',
...t.how.steps.map((step, i) => `${i + 1}. **${step.title}** — ${step.body}`),
'',
`## ${t.privacy.title}`,
'',
t.privacy.subtitle,
'',
...t.privacy.points.map((p) => `- **${p.title}** — ${p.body}`),
'',
`## ${t.pricing.title}`,
'',
t.pricing.subtitle,
'',
...plan(t.pricing.free, PLAN_PRICE_KRW.free, 'free'),
...plan(t.pricing.pro, PLAN_PRICE_KRW.pro, 'pro'),
...plan(t.pricing.proPlus, PLAN_PRICE_KRW.pro_plus, 'pro_plus'),
t.pricing.note,
'',
`## ${t.faq.title}`,
'',
...t.faq.items.flatMap((item) => [`### ${item.q}`, item.a, '']),
]
}
export function buildLlmsFull(): string {
return [...fullText('en'), '---', '', ...fullText('ko')].join('\n')
}