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
323
site/src/seo/documents.ts
Normal file
323
site/src/seo/documents.ts
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
// 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')
|
||||
}
|
||||
32
site/src/seo/fact-values.ts
Normal file
32
site/src/seo/fact-values.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// 요약·구조화 데이터·llms.txt 가 함께 쓰는 사실 값. 정본은 release.ts 와 packages/core plan-catalog 다.
|
||||
|
||||
import { PLAN_PRICE_KRW } from '../../../packages/core/src/plan-catalog'
|
||||
import { useI18n, localeMeta, type Locale } from '../i18n'
|
||||
import { DESKTOP_RELEASE_DATE, DESKTOP_VERSION } from '../release'
|
||||
|
||||
// fmt() 의 Record<string, …> 에 그대로 넘기려고 interface 대신 type 으로 둔다.
|
||||
export type FactValues = {
|
||||
version: string
|
||||
date: string
|
||||
pro: string
|
||||
proPlus: string
|
||||
}
|
||||
|
||||
export function factValues(locale: Locale): FactValues {
|
||||
const bcp47 = localeMeta(locale).bcp47
|
||||
const money = new Intl.NumberFormat(bcp47, { style: 'currency', currency: 'KRW', maximumFractionDigits: 0 })
|
||||
const date = new Intl.DateTimeFormat(bcp47, { dateStyle: 'long', timeZone: 'UTC' }).format(
|
||||
new Date(`${DESKTOP_RELEASE_DATE}T00:00:00Z`),
|
||||
)
|
||||
return {
|
||||
version: DESKTOP_VERSION,
|
||||
date,
|
||||
pro: money.format(PLAN_PRICE_KRW.pro),
|
||||
proPlus: money.format(PLAN_PRICE_KRW.pro_plus),
|
||||
}
|
||||
}
|
||||
|
||||
export function useFactValues(): FactValues {
|
||||
const { locale } = useI18n()
|
||||
return factValues(locale)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue