d3ro-voice/site/src/seo/documents.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

323 lines
12 KiB
TypeScript

// site/src/seo/documents.ts
// 빌드 때 언어별 <head>, 구조화 데이터(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<Locale, string> = {
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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function escapeText(value: string): string {
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/** <script> 안에 넣어도 태그가 끊기지 않게 한다. */
function jsonForScript(value: unknown): string {
return JSON.stringify(value).replace(/</g, '\\u003c')
}
// ── 구조화 데이터 ──────────────────────────────────────────
export function structuredData(locale: Locale): Record<string, unknown> {
const t = translations[locale]
const { bcp47 } = localeMeta(locale)
const url = pageUrl(locale)
const orgId = `${PUBLIC_SITE_ORIGIN}/#organization`
const appId = `${PUBLIC_SITE_ORIGIN}/#software`
const monthly = (price: number) => ({
'@type': 'UnitPriceSpecification',
price,
priceCurrency: 'KRW',
unitText: 'MONTH',
})
return {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'Organization',
'@id': orgId,
name: BRAND,
url: `${PUBLIC_SITE_ORIGIN}/`,
logo: `${PUBLIC_SITE_ORIGIN}/icons/icon-512.png`,
},
{
'@type': 'WebSite',
'@id': `${PUBLIC_SITE_ORIGIN}/#website`,
url: `${PUBLIC_SITE_ORIGIN}/`,
name: BRAND,
inLanguage: LOCALES.map((l) => l.bcp47),
publisher: { '@id': orgId },
},
{
'@type': 'WebPage',
'@id': `${url}#webpage`,
url,
name: t.meta.title,
description: t.meta.description,
inLanguage: bcp47,
isPartOf: { '@id': `${PUBLIC_SITE_ORIGIN}/#website` },
about: { '@id': appId },
primaryImageOfPage: ogImageUrl(locale),
},
{
'@type': 'SoftwareApplication',
'@id': appId,
name: BRAND,
description: t.meta.description,
applicationCategory: 'UtilitiesApplication',
applicationSubCategory: 'Speech recognition, dictation',
operatingSystem: 'Windows 10, Windows 11',
softwareVersion: DESKTOP_VERSION,
datePublished: DESKTOP_RELEASE_DATE,
downloadUrl: DESKTOP_WINDOWS_INSTALLER_URL,
installUrl: `${url}#download`,
releaseNotes: DESKTOP_RELEASE_HUB_URL,
image: ogImageUrl(locale),
featureList: t.features.items.map((item) => item.title),
publisher: { '@id': orgId },
offers: [
{ '@type': 'Offer', name: t.pricing.free.name, price: PLAN_PRICE_KRW.free, priceCurrency: 'KRW', url: `${url}#pricing` },
{ '@type': 'Offer', name: t.pricing.pro.name, price: PLAN_PRICE_KRW.pro, priceCurrency: 'KRW', priceSpecification: monthly(PLAN_PRICE_KRW.pro), url: `${url}#pricing` },
{ '@type': 'Offer', name: t.pricing.proPlus.name, price: PLAN_PRICE_KRW.pro_plus, priceCurrency: 'KRW', priceSpecification: monthly(PLAN_PRICE_KRW.pro_plus), url: `${url}#pricing` },
],
},
{
'@type': 'HowTo',
'@id': `${url}#how`,
name: t.how.title,
description: t.how.subtitle,
inLanguage: bcp47,
step: t.how.steps.map((step, i) => ({
'@type': 'HowToStep',
position: i + 1,
name: step.title,
text: step.body,
})),
},
{
'@type': 'FAQPage',
'@id': `${url}#faq`,
inLanguage: bcp47,
mainEntity: t.faq.items.map((item) => ({
'@type': 'Question',
name: item.q,
acceptedAnswer: { '@type': 'Answer', text: item.a },
})),
},
],
}
}
// ── <head> ────────────────────────────────────────────────
export function buildHead(locale: Locale): string {
const t = translations[locale]
const url = pageUrl(locale)
const image = ogImageUrl(locale)
const alternates = LOCALES.map(
(l) => `<link rel="alternate" hreflang="${l.code}" href="${pageUrl(l.code)}" />`,
)
alternates.push(`<link rel="alternate" hreflang="x-default" href="${pageUrl(X_DEFAULT_LOCALE)}" />`)
return [
`<title>${escapeText(t.meta.title)}</title>`,
`<meta name="description" content="${escapeAttr(t.meta.description)}" />`,
`<link rel="canonical" href="${url}" />`,
...alternates,
`<meta property="og:type" content="website" />`,
`<meta property="og:site_name" content="${BRAND}" />`,
`<meta property="og:title" content="${escapeAttr(t.meta.title)}" />`,
`<meta property="og:description" content="${escapeAttr(t.meta.description)}" />`,
`<meta property="og:url" content="${url}" />`,
`<meta property="og:locale" content="${OG_LOCALE[locale]}" />`,
...LOCALES.filter((l) => l.code !== locale).map(
(l) => `<meta property="og:locale:alternate" content="${OG_LOCALE[l.code]}" />`,
),
`<meta property="og:image" content="${image}" />`,
`<meta property="og:image:width" content="1200" />`,
`<meta property="og:image:height" content="630" />`,
`<meta property="og:image:alt" content="${escapeAttr(t.meta.title)}" />`,
`<meta name="twitter:card" content="summary_large_image" />`,
`<meta name="twitter:title" content="${escapeAttr(t.meta.title)}" />`,
`<meta name="twitter:description" content="${escapeAttr(t.meta.description)}" />`,
`<meta name="twitter:image" content="${image}" />`,
`<script type="application/ld+json">${jsonForScript(structuredData(locale))}</script>`,
].join('\n ')
}
// ── sitemap.xml ───────────────────────────────────────────
export function buildSitemap(lastmod: string): string {
const alternates = [
...LOCALES.map((l) => ` <xhtml:link rel="alternate" hreflang="${l.code}" href="${pageUrl(l.code)}" />`),
` <xhtml:link rel="alternate" hreflang="x-default" href="${pageUrl(X_DEFAULT_LOCALE)}" />`,
].join('\n')
const localeEntries = LOCALES.map(
(l) => ` <url>\n <loc>${pageUrl(l.code)}</loc>\n <lastmod>${lastmod}</lastmod>\n${alternates}\n </url>`,
)
const legalEntries = [SITE_URLS.privacy, SITE_URLS.terms, SITE_URLS.deleteAccount].map(
(loc) => ` <url>\n <loc>${loc}</loc>\n <lastmod>${lastmod}</lastmod>\n </url>`,
)
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">',
...localeEntries,
...legalEntries,
'</urlset>',
'',
].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')
}