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.
323 lines
12 KiB
TypeScript
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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
|
}
|
|
|
|
function escapeText(value: string): string {
|
|
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
}
|
|
|
|
/** <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')
|
|
}
|