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
40
site/scripts/prerender.mjs
Normal file
40
site/scripts/prerender.mjs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// site/scripts/prerender.mjs
|
||||
// `vite build`(브라우저용)와 `vite build --ssr`(서버용) 뒤에 실행한다.
|
||||
// 언어마다 본문이 채워진 HTML(dist/index.html, dist/<lang>/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 ['<!--app-head-->', '<!--app-html-->', '<html lang="ko">']) {
|
||||
if (!template.includes(marker)) throw new Error(`prerender: index.html 에 ${marker} 가 없다`)
|
||||
}
|
||||
|
||||
for (const { code } of server.LOCALES) {
|
||||
const html = template
|
||||
.replace('<html lang="ko">', `<html lang="${code}">`)
|
||||
.replace('<!--app-head-->', server.buildHead(code))
|
||||
.replace('<!--app-html-->', 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`)
|
||||
114
site/scripts/render-brand-assets.ts
Normal file
114
site/scripts/render-brand-assets.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// site/scripts/render-brand-assets.ts
|
||||
// 검색·공유용 이미지를 만든다. 문구는 i18n 번역에서, 로고는 public/favicon.svg 에서 가져온다.
|
||||
// public/og/<lang>.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, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
function ogHtml(code: (typeof LOCALES)[number]['code']): string {
|
||||
const t = translations[code]
|
||||
const bars = BAR_HEIGHTS.map(
|
||||
(h, i) => `<span style="height:${h}px;background:${BAR_COLORS[i]}"></span>`,
|
||||
).join('')
|
||||
return `<!doctype html><html lang="${code}"><head><meta charset="utf-8">
|
||||
<link rel="stylesheet" href="${FONT_CSS}">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
html, body { width: 1200px; height: 630px; }
|
||||
body {
|
||||
font-family: "Pretendard Variable", Pretendard, "Malgun Gothic", "Noto Sans CJK", sans-serif;
|
||||
background: #08090c; color: #f4f4f5; position: relative; overflow: hidden;
|
||||
background-image: linear-gradient(rgba(59,130,246,.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(59,130,246,.05) 1px, transparent 1px);
|
||||
background-size: 64px 64px;
|
||||
}
|
||||
.wrap { position: absolute; inset: 72px 80px; display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.brand { display: flex; align-items: center; gap: 14px; font-size: 26px; font-weight: 500; letter-spacing: -0.01em; }
|
||||
.brand svg { width: 44px; height: 44px; }
|
||||
.brand .dot { color: #60a5fa; }
|
||||
h1 { font-size: 64px; line-height: 1.22; font-weight: 500; letter-spacing: -0.02em; max-width: 820px; word-break: keep-all; }
|
||||
h1 .accent { color: #60a5fa; display: block; }
|
||||
.foot { display: flex; align-items: center; justify-content: space-between; gap: 32px; }
|
||||
.trust { font-size: 24px; color: #a1a1aa; max-width: 700px; word-break: keep-all; line-height: 1.4; }
|
||||
.capsule { display: flex; align-items: center; gap: 16px; padding: 16px 28px; border-radius: 999px;
|
||||
border: 1px solid rgba(255,255,255,.1); background: rgba(15,17,22,.9);
|
||||
box-shadow: 0 16px 36px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.12); }
|
||||
.bars { display: flex; align-items: center; gap: 5px; height: 48px; }
|
||||
.bars span { width: 6px; border-radius: 999px; display: block; }
|
||||
.time { font-family: ui-monospace, Consolas, monospace; font-size: 22px; font-weight: 600; }
|
||||
</style></head><body>
|
||||
<div class="wrap">
|
||||
<div class="brand">${favicon}<span>D3RO<span class="dot">·</span>VOICE</span></div>
|
||||
<h1>${escapeHtml(t.hero.title1)}<span class="accent">${escapeHtml(t.hero.title2)}</span></h1>
|
||||
<div class="foot">
|
||||
<p class="trust">${escapeHtml(t.hero.trust)}</p>
|
||||
<div class="capsule"><div class="bars">${bars}</div><span class="time">0:03</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>`
|
||||
}
|
||||
|
||||
function iconHtml(size: number, padding: number, background: string): string {
|
||||
return `<!doctype html><html><head><style>
|
||||
* { margin: 0 } html, body { width: ${size}px; height: ${size}px; background: ${background}; }
|
||||
body { display: grid; place-items: center; }
|
||||
svg { width: ${size - padding * 2}px; height: ${size - padding * 2}px; }
|
||||
</style></head><body>${favicon}</body></html>`
|
||||
}
|
||||
|
||||
// 설치된 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`)
|
||||
Loading…
Add table
Add a link
Reference in a new issue