diff --git a/MAIN_PLAN.md b/MAIN_PLAN.md
index 7df377c..2d1733e 100644
--- a/MAIN_PLAN.md
+++ b/MAIN_PLAN.md
@@ -272,6 +272,14 @@
- manifest JSON 파싱 검증 로직 추가
- 브랜치/PR: `feat/manifest-robots-auto` → CI 트리거 및 자동 머지 대상(automerge 라벨 조건)
+### SEO/Perf 개선(TDD): theme-color, hero width/height
+- 테스트: `lib/exporter/seoPerf.themeColor.heroDims.test.ts`
+ - `` 헤드 포함 검증(page.theme.primaryColor 사용)
+ - hero 이미지에 `width`/`height` 속성 존재 검증(CLS 저감)
+- 구현: `lib/exporter/html.ts`
+ - `buildHead`에 theme-color 메타 추가
+ - `renderHero` 이미지 태그에 width/height 기본값 추가(1200x675)
+- 브랜치/PR: `feat/seo-perf-themecolor-hero-dims` → CI 트리거 및 자동 머지 대상
### ZIP 검증 TDD Phase5(extras 내용 검증)
- 테스트: `lib/exporter/zip.validation.phase5.test.ts`
- `robots.txt` 텍스트 베이스라인 확인(`User-agent: *`)
diff --git a/lib/exporter/extras.overrides.test.ts b/lib/exporter/extras.overrides.test.ts
new file mode 100644
index 0000000..e1c6bbd
--- /dev/null
+++ b/lib/exporter/extras.overrides.test.ts
@@ -0,0 +1,47 @@
+import { describe, it, expect } from 'vitest'
+import { pageSchema, type Page } from '@/lib/schema/page'
+import { buildExtrasForPage } from '@/lib/exporter/extras'
+
+function makePage(): Page {
+ return pageSchema.parse({
+ title: 'Overrides Demo',
+ locale: 'en',
+ theme: { primaryColor: '#123456', fontFamily: 'Inter' },
+ sections: [
+ { type: 'hero', props: { heading: 'Hello', subheading: 'World', imageUrl: 'https://img.example/hero.png' } },
+ ],
+ form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
+ seo: { title: 'Overrides', description: 'Desc' },
+ })
+}
+
+describe('extras overrides', () => {
+ it('allows overriding robots.txt content', () => {
+ const page = makePage()
+ const extras = buildExtrasForPage(page, {
+ robotsText: 'User-agent: *\nDisallow: /private',
+ })
+ const robots = new TextDecoder().decode(extras['robots.txt'])
+ expect(robots).toContain('Disallow: /private')
+ })
+
+ it('allows overriding manifest fields (name, short_name, start_url, display, theme_color)', () => {
+ const page = makePage()
+ const extras = buildExtrasForPage(page, {
+ manifest: {
+ name: 'Custom Name',
+ short_name: 'Custom',
+ start_url: '/home',
+ display: 'minimal-ui',
+ theme_color: '#abcdef',
+ },
+ })
+ const manifestRaw = new TextDecoder().decode(extras['site.webmanifest'])
+ const manifest = JSON.parse(manifestRaw)
+ expect(manifest.name).toBe('Custom Name')
+ expect(manifest.short_name).toBe('Custom')
+ expect(manifest.start_url).toBe('/home')
+ expect(manifest.display).toBe('minimal-ui')
+ expect(manifest.theme_color).toBe('#abcdef')
+ })
+})
diff --git a/lib/exporter/extras.ts b/lib/exporter/extras.ts
index e112772..8b9fa4d 100644
--- a/lib/exporter/extras.ts
+++ b/lib/exporter/extras.ts
@@ -1,13 +1,25 @@
import type { Page } from '@/lib/schema/page'
+type ExtrasOverrides = {
+ robotsText?: string
+ manifest?: Partial<{
+ name: string
+ short_name: string
+ start_url: string
+ display: string
+ theme_color: string
+ }>
+}
+
// Sheets 템플릿 등 ZIP 루트/서브 경로에 포함할 추가 파일(extras)을 구성한다.
// - actionUrl이 비어있을 때만 Google Apps Script 템플릿을 포함한다.
-export function buildExtrasForPage(page: Page): Record {
+export function buildExtrasForPage(page: Page, overrides?: ExtrasOverrides): Record {
const extras: Record = {}
const te = new TextEncoder()
// Always provide a baseline robots.txt
- const robots = `User-agent: *\nDisallow:`
+ const robotsDefault = `User-agent: *\nDisallow:`
+ const robots = overrides?.robotsText ?? robotsDefault
extras['robots.txt'] = te.encode(robots)
// Provide a minimal site.webmanifest derived from Page
@@ -20,6 +32,13 @@ export function buildExtrasForPage(page: Page): Record {
start_url: '/',
display: 'standalone',
theme_color: page.theme?.primaryColor ?? '#000000',
+ ...((overrides?.manifest as object) || {}),
+ } as {
+ name: string
+ short_name: string
+ start_url: string
+ display: string
+ theme_color: string
}
extras['site.webmanifest'] = te.encode(JSON.stringify(manifest))
} catch {}