diff --git a/.forgejo/workflows/deploy-site.yml b/.forgejo/workflows/deploy-site.yml index 541d49a..06f0e3d 100644 --- a/.forgejo/workflows/deploy-site.yml +++ b/.forgejo/workflows/deploy-site.yml @@ -84,3 +84,12 @@ jobs: curl --fail --silent --show-error https://d3ro.chanpaca.net/privacy/ --output /dev/null curl --fail --silent --show-error https://d3ro.chanpaca.net/terms/ --output /dev/null curl --fail --silent --show-error https://d3ro.chanpaca.net/delete-account/ --output /dev/null + # 검색·AI 검색용 산출물(언어별 미리 그린 페이지, sitemap, robots, llms.txt) + for path in en/ ja/ sitemap.xml robots.txt llms.txt llms-full.txt; do + curl --fail --silent --show-error "https://d3ro.chanpaca.net/$path" --output /dev/null + done + curl --fail --silent --show-error https://d3ro.chanpaca.net/en/ | grep -q 'application/ld+json' + + - name: IndexNow 알림 (Bing·네이버·Yandex) + continue-on-error: true + run: node scripts/ci/ping-indexnow.mjs diff --git a/.gitignore b/.gitignore index 600a67a..3b8bf18 100644 --- a/.gitignore +++ b/.gitignore @@ -128,3 +128,6 @@ apps/mobile-rn/android/app/src/main/res/drawable-*/node_modules_* *.aab *.blockmap D3RO-Voice-Setup-*.exe + +# site 빌드 중간 산출물(서버 렌더링 번들) +site/dist-ssr/ diff --git a/design.md b/design.md index de26590..98b4703 100644 --- a/design.md +++ b/design.md @@ -204,3 +204,16 @@ - 제거: 이전 버튼식 예시(DictationDemo), WaveBars, Crosshair, CRT 화면 CSS — 이전 기록의 "CRT CSS 유지"는 이로써 종료. - 부수: `site/public/404.html`(없는 경로가 첫 화면으로 떨어지던 SPA 폴백 종료), 법률 페이지의 옛 주황 강조색을 파랑으로. - 앱 쪽 발견(미수정): 녹음 캡슐의 "처리 중..."은 HTML에 한국어로 고정돼 있어 앱 언어를 따르지 않는다. + +### 2026-09-27 추기 · 검색·AI 검색 최적화 (SEO + GEO) +- 문제: 사이트가 클라이언트 렌더링이라 크롤러가 받는 HTML 본문이 107자였다(Googlebot·GPTBot·ClaudeBot·PerplexityBot·네이버 Yeti 동일). AI 크롤러 대부분은 JS를 실행하지 않는다. +- **빌드 때 언어별 정적 HTML을 미리 그린다**(`site/scripts/prerender.mjs` + `src/entry-server.tsx`, React hydrateRoot로 이어받음). 주소: 한국어 `/`, 나머지 `/{en,ja,zh,es,fr,de,pt,ru,vi}/`. 언어 선택은 해당 주소로 가는 링크(크롤링 가능). 자동 언어 리다이렉트는 하지 않는다(구글 권장). +- ``는 언어마다 title·description·canonical·hreflang(10+x-default=/en/)·Open Graph·X 카드. 구조화 데이터(JSON-LD @graph): Organization, WebSite, WebPage, SoftwareApplication(가격 3종 KRW·버전·게시일·다운로드 URL — 정본에서), HowTo(작동 방식 4단계), FAQPage. 가짜 평점(aggregateRating)은 넣지 않는다. +- AI 검색용: `/llms.txt`(llmstxt.org 형식, 영어+한국어 요약·핵심 사실·주요 주소), `/llms-full.txt`(영·한 전문). 모두 i18n 번역과 정본 값에서 빌드 때 생성 — 사이트 문구와 어긋날 수 없다. +- `robots.txt`: 전체 허용 + `Content-Signal: search=yes, ai-input=yes, ai-train=yes`(홍보 목적), `/app/`·`/api/`·`/accept-invite/` 제외, sitemap 안내. Cloudflare의 AI 봇 차단·Bot Fight Mode는 꺼져 있음을 확인. +- `sitemap.xml`: 언어별 페이지(xhtml:link 대체 언어)+법률 문서. IndexNow 키 파일(`/5d886cca….txt`)과 배포 후 알림(`scripts/ci/ping-indexnow.mjs`) — Bing(ChatGPT·Copilot 검색 기반)·네이버·Yandex. +- 보이는 내용 보강: "한눈에 보기" 사실 요약 섹션(정의형 문장 8개, 버전·가격은 정본), FAQ 2개 추가(인식 언어 — 앱 설정의 자동/한·영·일·중, 데이터 저장 위치). +- 공유·아이콘: 언어별 1200×630 공유 이미지(`public/og/.png`), 파비콘 PNG·apple-touch-icon·PWA 아이콘·manifest. 생성: `npx vite-node site/scripts/render-brand-assets.ts`(문구·로고가 바뀌면 다시 실행해 커밋). +- 빌드 경로 `base: '/'`(언어 하위 경로에서도 자산 경로가 맞도록). +- 검증: 본문 3,600~7,700자/언어, 하이드레이션 경고 0, 10개 언어 320/1440 넘침 0, axe 0, Lighthouse(모바일) SEO·접근성·권장사항 100. +- 하지 않은 것: 언어별 별도 콘텐츠(번역 외 추가 페이지)·블로그, 가짜 후기·평점, 자동 언어 리다이렉트. diff --git a/scripts/ci/ping-indexnow.mjs b/scripts/ci/ping-indexnow.mjs new file mode 100644 index 0000000..311c171 --- /dev/null +++ b/scripts/ci/ping-indexnow.mjs @@ -0,0 +1,23 @@ +// scripts/ci/ping-indexnow.mjs +// 사이트 배포 뒤 IndexNow(Bing·네이버·Yandex 등 공유)에 바뀐 주소를 알린다. 계정이 필요 없고, +// 소유 확인은 사이트 루트의 키 파일(site/public/.txt)로 한다. 주소 목록은 prerender 가 만든 +// site/dist/indexnow-urls.json(sitemap 과 같은 목록)이다. + +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' + +const HOST = 'd3ro.chanpaca.net' +const publicDir = join(process.cwd(), 'site', 'public') +const keyFile = readdirSync(publicDir).find((name) => /^[0-9a-f]{32}\.txt$/.test(name)) +if (!keyFile) throw new Error('indexnow: site/public/<32 hex>.txt 키 파일이 없다') +const key = readFileSync(join(publicDir, keyFile), 'utf8').trim() +const urlList = JSON.parse(readFileSync(join(process.cwd(), 'site', 'dist', 'indexnow-urls.json'), 'utf8')) + +const response = await fetch('https://api.indexnow.org/indexnow', { + method: 'POST', + headers: { 'content-type': 'application/json; charset=utf-8' }, + body: JSON.stringify({ host: HOST, key, keyLocation: `https://${HOST}/${keyFile}`, urlList }), +}) +// 200/202 = 접수. 429 등은 배포 실패로 보지 않고 경고만 남긴다. +console.log(`[indexnow] ${response.status} ${response.statusText} (${urlList.length} urls)`) +if (response.status >= 400 && response.status !== 429) process.exitCode = 1 diff --git a/site/index.html b/site/index.html index 2b5b46e..278c57e 100644 --- a/site/index.html +++ b/site/index.html @@ -2,15 +2,15 @@ - - - - - - D3RO Voice — 내 PC에서 돌아가는 음성 받아쓰기 + + + + + + @@ -19,7 +19,7 @@ -
+
diff --git a/site/package.json b/site/package.json index 6154558..940a900 100644 --- a/site/package.json +++ b/site/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite --port 5199 --host", - "build": "tsc -b && vite build", + "build": "tsc -b && vite build && vite build --ssr src/entry-server.tsx --outDir dist-ssr && node scripts/prerender.mjs", "preview": "vite preview --port 5199 --host" }, "dependencies": { diff --git a/site/public/5d886cca4138946b8488203724b2f53d.txt b/site/public/5d886cca4138946b8488203724b2f53d.txt new file mode 100644 index 0000000..2d28b7c --- /dev/null +++ b/site/public/5d886cca4138946b8488203724b2f53d.txt @@ -0,0 +1 @@ +5d886cca4138946b8488203724b2f53d \ No newline at end of file diff --git a/site/public/icons/apple-touch-icon.png b/site/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..c0057cf Binary files /dev/null and b/site/public/icons/apple-touch-icon.png differ diff --git a/site/public/icons/favicon-32.png b/site/public/icons/favicon-32.png new file mode 100644 index 0000000..a81d7e6 Binary files /dev/null and b/site/public/icons/favicon-32.png differ diff --git a/site/public/icons/icon-192.png b/site/public/icons/icon-192.png new file mode 100644 index 0000000..bd2c2f4 Binary files /dev/null and b/site/public/icons/icon-192.png differ diff --git a/site/public/icons/icon-512.png b/site/public/icons/icon-512.png new file mode 100644 index 0000000..1fda572 Binary files /dev/null and b/site/public/icons/icon-512.png differ diff --git a/site/public/icons/icon-maskable-512.png b/site/public/icons/icon-maskable-512.png new file mode 100644 index 0000000..611ea1e Binary files /dev/null and b/site/public/icons/icon-maskable-512.png differ diff --git a/site/public/og/de.png b/site/public/og/de.png new file mode 100644 index 0000000..b78fa42 Binary files /dev/null and b/site/public/og/de.png differ diff --git a/site/public/og/en.png b/site/public/og/en.png new file mode 100644 index 0000000..7d02306 Binary files /dev/null and b/site/public/og/en.png differ diff --git a/site/public/og/es.png b/site/public/og/es.png new file mode 100644 index 0000000..e608aaa Binary files /dev/null and b/site/public/og/es.png differ diff --git a/site/public/og/fr.png b/site/public/og/fr.png new file mode 100644 index 0000000..a113e14 Binary files /dev/null and b/site/public/og/fr.png differ diff --git a/site/public/og/ja.png b/site/public/og/ja.png new file mode 100644 index 0000000..cf59d82 Binary files /dev/null and b/site/public/og/ja.png differ diff --git a/site/public/og/ko.png b/site/public/og/ko.png new file mode 100644 index 0000000..6b4e56a Binary files /dev/null and b/site/public/og/ko.png differ diff --git a/site/public/og/pt.png b/site/public/og/pt.png new file mode 100644 index 0000000..a255578 Binary files /dev/null and b/site/public/og/pt.png differ diff --git a/site/public/og/ru.png b/site/public/og/ru.png new file mode 100644 index 0000000..d0bbabb Binary files /dev/null and b/site/public/og/ru.png differ diff --git a/site/public/og/vi.png b/site/public/og/vi.png new file mode 100644 index 0000000..eaa9fe8 Binary files /dev/null and b/site/public/og/vi.png differ diff --git a/site/public/og/zh.png b/site/public/og/zh.png new file mode 100644 index 0000000..271b3dc Binary files /dev/null and b/site/public/og/zh.png differ diff --git a/site/public/site.webmanifest b/site/public/site.webmanifest new file mode 100644 index 0000000..de0aa8e --- /dev/null +++ b/site/public/site.webmanifest @@ -0,0 +1,15 @@ +{ + "name": "D3RO Voice", + "short_name": "D3RO Voice", + "description": "Voice typing that runs on your PC — hold a key, speak, and the text is typed at your cursor.", + "start_url": "/", + "scope": "/", + "display": "browser", + "background_color": "#08090c", + "theme_color": "#08090c", + "icons": [ + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/site/scripts/prerender.mjs b/site/scripts/prerender.mjs new file mode 100644 index 0000000..5dcf65a --- /dev/null +++ b/site/scripts/prerender.mjs @@ -0,0 +1,40 @@ +// site/scripts/prerender.mjs +// `vite build`(브라우저용)와 `vite build --ssr`(서버용) 뒤에 실행한다. +// 언어마다 본문이 채워진 HTML(dist/index.html, dist//index.html)과 +// sitemap.xml, robots.txt, llms.txt, llms-full.txt 를 만든다. +// 크롤러(검색·AI)는 JavaScript 를 실행하지 않아도 전체 내용을 읽을 수 있다. + +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const siteRoot = join(dirname(fileURLToPath(import.meta.url)), '..') +const dist = join(siteRoot, 'dist') +const ssrDir = join(siteRoot, 'dist-ssr') + +const server = await import(pathToFileURL(join(ssrDir, 'entry-server.js')).href) +const template = readFileSync(join(dist, 'index.html'), 'utf8') + +for (const marker of ['', '', '']) { + if (!template.includes(marker)) throw new Error(`prerender: index.html 에 ${marker} 가 없다`) +} + +for (const { code } of server.LOCALES) { + const html = template + .replace('', ``) + .replace('', server.buildHead(code)) + .replace('', server.render(code)) + const out = join(dist, server.localePath(code), 'index.html') + mkdirSync(dirname(out), { recursive: true }) + writeFileSync(out, html) +} + +const lastmod = new Date().toISOString().slice(0, 10) +writeFileSync(join(dist, 'sitemap.xml'), server.buildSitemap(lastmod)) +writeFileSync(join(dist, 'robots.txt'), server.buildRobots()) +writeFileSync(join(dist, 'llms.txt'), server.buildLlms()) +writeFileSync(join(dist, 'llms-full.txt'), server.buildLlmsFull()) +writeFileSync(join(dist, 'indexnow-urls.json'), JSON.stringify(server.sitemapUrls(), null, 2)) + +rmSync(ssrDir, { recursive: true, force: true }) +console.log(`[prerender] ${server.LOCALES.length} locales + sitemap.xml, robots.txt, llms.txt, llms-full.txt`) diff --git a/site/scripts/render-brand-assets.ts b/site/scripts/render-brand-assets.ts new file mode 100644 index 0000000..9630231 --- /dev/null +++ b/site/scripts/render-brand-assets.ts @@ -0,0 +1,114 @@ +// site/scripts/render-brand-assets.ts +// 검색·공유용 이미지를 만든다. 문구는 i18n 번역에서, 로고는 public/favicon.svg 에서 가져온다. +// public/og/.png 1200×630 공유 미리보기(Open Graph·X 카드) +// public/icons/*.png 파비콘·apple-touch-icon·PWA 아이콘 +// 실행: npx vite-node site/scripts/render-brand-assets.ts (Chrome 또는 Playwright Chromium, 인터넷으로 Pretendard 로드) +// 문구나 로고가 바뀌면 다시 실행해서 커밋한다. + +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import { LOCALES, translations } from '../src/i18n' + +const siteRoot = join(dirname(fileURLToPath(import.meta.url)), '..') +const publicDir = join(siteRoot, 'public') +const favicon = readFileSync(join(publicDir, 'favicon.svg'), 'utf8') + +const FONT_CSS = + 'https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css' + +// 앱 녹음 캡슐(recording-tip)과 같은 막대 색 +const BAR_COLORS = ['#93c5fd', '#60a5fa', '#4f8dfa', '#3b82f6', '#3b82f6', '#5b75f7', '#6e71f7', '#818cf8', '#a78bfa'] +const BAR_HEIGHTS = [10, 18, 28, 38, 44, 38, 28, 18, 10] + +function escapeHtml(value: string): string { + return value.replace(/&/g, '&').replace(//g, '>') +} + +function ogHtml(code: (typeof LOCALES)[number]['code']): string { + const t = translations[code] + const bars = BAR_HEIGHTS.map( + (h, i) => ``, + ).join('') + return ` + + +
+
${favicon}D3RO·VOICE
+

${escapeHtml(t.hero.title1)}${escapeHtml(t.hero.title2)}

+
+

${escapeHtml(t.hero.trust)}

+
${bars}
0:03
+
+
+` +} + +function iconHtml(size: number, padding: number, background: string): string { + return `${favicon}` +} + +// 설치된 Chrome 을 먼저 쓰고, 없으면 Playwright 가 받은 Chromium 을 쓴다. +const browser = await chromium.launch({ channel: 'chrome' }).catch(() => chromium.launch()) +try { + const page = await browser.newPage({ deviceScaleFactor: 1 }) + + mkdirSync(join(publicDir, 'og'), { recursive: true }) + for (const { code } of LOCALES) { + await page.setViewportSize({ width: 1200, height: 630 }) + await page.setContent(ogHtml(code), { waitUntil: 'networkidle' }) + await page.evaluate(() => document.fonts.ready) + writeFileSync(join(publicDir, 'og', `${code}.png`), await page.screenshot({ type: 'png' })) + } + + mkdirSync(join(publicDir, 'icons'), { recursive: true }) + const icons: Array<[name: string, size: number, padding: number, background: string]> = [ + ['favicon-32.png', 32, 0, 'transparent'], + ['apple-touch-icon.png', 180, 14, '#08090c'], + ['icon-192.png', 192, 0, 'transparent'], + ['icon-512.png', 512, 0, 'transparent'], + // 안드로이드 마스커블: 안전 영역(80%) 안에 로고를 둔다 + ['icon-maskable-512.png', 512, 64, '#19191b'], + ] + for (const [name, size, padding, background] of icons) { + await page.setViewportSize({ width: size, height: size }) + await page.setContent(iconHtml(size, padding, background)) + writeFileSync( + join(publicDir, 'icons', name), + await page.screenshot({ type: 'png', omitBackground: background === 'transparent' }), + ) + } +} finally { + await browser.close() +} + +console.log(`[brand-assets] og/*.png ×${LOCALES.length}, icons ×5`) diff --git a/site/src/App.tsx b/site/src/App.tsx index 7a77235..a1ce222 100644 --- a/site/src/App.tsx +++ b/site/src/App.tsx @@ -1,4 +1,4 @@ -import { I18nProvider, useI18n } from './i18n' +import { I18nProvider, useI18n, type Locale } from './i18n' import { Header } from './sections/Header' import { Hero } from './sections/Hero' import { Features } from './sections/Features' @@ -6,6 +6,7 @@ import { HowItWorks } from './sections/HowItWorks' import { Privacy } from './sections/Privacy' import { Pricing } from './sections/Pricing' import { Download } from './sections/Download' +import { Facts } from './sections/Facts' import { FAQ } from './sections/FAQ' import { Footer } from './sections/Footer' @@ -22,6 +23,7 @@ function Page() { +