d3ro-voice/site/scripts/render-brand-assets.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

114 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
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`)