// 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') }