diff --git a/lib/exporter/extras.overrides.test.ts b/lib/exporter/extras.overrides.test.ts index e1c6bbd..367dc06 100644 --- a/lib/exporter/extras.overrides.test.ts +++ b/lib/exporter/extras.overrides.test.ts @@ -4,44 +4,42 @@ import { buildExtrasForPage } from '@/lib/exporter/extras' function makePage(): Page { return pageSchema.parse({ - title: 'Overrides Demo', + title: 'Overrides', locale: 'en', - theme: { primaryColor: '#123456', fontFamily: 'Inter' }, - sections: [ - { type: 'hero', props: { heading: 'Hello', subheading: 'World', imageUrl: 'https://img.example/hero.png' } }, - ], + theme: { primaryColor: '#2563eb', fontFamily: 'Inter' }, + sections: [ { type: 'hero', props: { heading: 'Hello', subheading: 'World', imageUrl: 'https://img/h.png' } } ], form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } }, - seo: { title: 'Overrides', description: 'Desc' }, + seo: { title: 't', description: 'd' }, }) } -describe('extras overrides', () => { - it('allows overriding robots.txt content', () => { +describe('extras 오버라이드', () => { + it('robots.txt Disallow 오버라이드가 반영된다', () => { const page = makePage() - const extras = buildExtrasForPage(page, { - robotsText: 'User-agent: *\nDisallow: /private', - }) - const robots = new TextDecoder().decode(extras['robots.txt']) + const extras = buildExtrasForPage(page, { robotsText: 'User-agent: *\nDisallow: /admin\nDisallow: /private' }) + const robots = Buffer.from(extras['robots.txt']).toString('utf8') + expect(robots).toContain('User-agent: *') + expect(robots).toContain('Disallow: /admin') expect(robots).toContain('Disallow: /private') }) - it('allows overriding manifest fields (name, short_name, start_url, display, theme_color)', () => { + it('manifest 필드 오버라이드(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', + name: 'My App', + short_name: 'MyApp', + start_url: '/start', display: 'minimal-ui', - theme_color: '#abcdef', + theme_color: '#123abc', }, }) - 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') + const manifestStr = Buffer.from(extras['site.webmanifest']).toString('utf8') + const manifest = JSON.parse(manifestStr) + expect(manifest.name).toBe('My App') + expect(manifest.short_name).toBe('MyApp') + expect(manifest.start_url).toBe('/start') expect(manifest.display).toBe('minimal-ui') - expect(manifest.theme_color).toBe('#abcdef') + expect(manifest.theme_color).toBe('#123abc') }) }) diff --git a/lib/exporter/extras.ts b/lib/exporter/extras.ts index 8b9fa4d..84cd4dd 100644 --- a/lib/exporter/extras.ts +++ b/lib/exporter/extras.ts @@ -1,6 +1,6 @@ import type { Page } from '@/lib/schema/page' -type ExtrasOverrides = { +export type ExtrasOverrides = { robotsText?: string manifest?: Partial<{ name: string @@ -11,71 +11,54 @@ type ExtrasOverrides = { }> } -// Sheets 템플릿 등 ZIP 루트/서브 경로에 포함할 추가 파일(extras)을 구성한다. -// - actionUrl이 비어있을 때만 Google Apps Script 템플릿을 포함한다. +function toBytes(s: string): Uint8Array { + return new TextEncoder().encode(s) +} + +function buildRobots(base?: string): string { + if (base && base.trim().length > 0) return base + return 'User-agent: *\n' +} + +function deriveManifest(page: Page) { + const name = page.title || 'Landing' + const short_name = name.length > 12 ? name.slice(0, 12) : name + const start_url = '/' + const display = 'standalone' + const theme_color = page.theme?.primaryColor || '#0ea5e9' + return { name, short_name, start_url, display, theme_color } +} + export function buildExtrasForPage(page: Page, overrides?: ExtrasOverrides): Record { - const extras: Record = {} - const te = new TextEncoder() + const out: Record = {} - // Always provide a baseline robots.txt - const robotsDefault = `User-agent: *\nDisallow:` - const robots = overrides?.robotsText ?? robotsDefault - extras['robots.txt'] = te.encode(robots) + // robots.txt + const robotsText = buildRobots(overrides?.robotsText) + out['robots.txt'] = toBytes(robotsText) - // Provide a minimal site.webmanifest derived from Page - try { - const name = page.title || 'App' - const short = name.length > 12 ? name.slice(0, 12) : name - const manifest = { - name, - short_name: short, - 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 {} + // site.webmanifest + const manifestBase = deriveManifest(page) + const manifestMerged = { ...manifestBase, ...(overrides?.manifest || {}) } + const manifestJson = JSON.stringify(manifestMerged, null, 2) + out['site.webmanifest'] = toBytes(manifestJson) - const hasAction = !!(page.form?.actionUrl && page.form.actionUrl.trim().length > 0) + // Google Sheets 템플릿: actionUrl 미지정 시 포함 + const action = page.form?.actionUrl?.trim() + const hasAction = !!(action && action.length > 0) if (!hasAction) { - const code = `// Google Apps Script (Code.gs) -// - Web App 으로 배포 후, 아래 doPost 핸들러가 폼 데이터를 수신합니다. -// - 시트에 저장하거나 이메일 전송 로직을 추가하세요. -function doPost(e) { - try { - var data = e.parameter || {} - // TODO: 시트에 저장 예시 - // var ss = SpreadsheetApp.getActiveSpreadsheet() - // var sheet = ss.getSheetByName('Responses') || ss.insertSheet('Responses') - // sheet.appendRow([new Date(), JSON.stringify(data)]) - return ContentService.createTextOutput(JSON.stringify({ ok: true })) - .setMimeType(ContentService.MimeType.JSON) - } catch (err) { - return ContentService.createTextOutput(JSON.stringify({ ok: false, error: String(err) })) - .setMimeType(ContentService.MimeType.JSON) - } + const code = `/** + * Google Apps Script Web App for form submissions + * - Deploy as Web App and set URL as form action if needed. + */ +function doPost(e){ + // TODO: handle submission to Google Sheets + return ContentService.createTextOutput('OK') } ` - const readme = `README (Google Sheets Apps Script 템플릿) - -1) Google Drive에서 새 스크립트(Apps Script) 프로젝트를 생성합니다. -2) Code.gs 파일 내용을 붙여넣습니다. -3) Deploy > New deployment > Web app 으로 배포합니다. - - Execute as: Me - - Who has access: Anyone -4) 배포 URL을 복사하여 빌더의 Form action URL에 넣습니다. -5) 필요한 경우 doPost에서 스프레드시트 저장 로직을 추가하세요. -` - extras['sheets/Code.gs'] = te.encode(code) - extras['sheets/README.txt'] = te.encode(readme) + const readme = `Google Sheets Apps Script Template\n\n1) Apps Script에서 새 프로젝트를 만들고 아래 Code.gs를 붙여넣으세요.\n2) 배포 > 웹 앱으로 배포 후 URL을 복사해 사용하세요.` + out['sheets/Code.gs'] = toBytes(code) + out['sheets/README.txt'] = toBytes(readme) } - return extras + return out } diff --git a/lib/exporter/font.optimization.test.ts b/lib/exporter/font.optimization.test.ts index de7a852..f98d6fc 100644 --- a/lib/exporter/font.optimization.test.ts +++ b/lib/exporter/font.optimization.test.ts @@ -13,17 +13,17 @@ function makePage(): Page { }) } -describe('Exporter - 폰트 최적화', () => { - it('head에 폰트 preconnect 링크가 포함된다(googleapis/gstatic)', () => { - const out = exportPage(makePage()) - const html = out.html - expect(html).toMatch(//) - expect(html).toMatch(//) +describe('폰트 최적화', () => { + it('head에 Google Fonts preconnect 링크가 포함된다', () => { + const page = makePage() + const out = exportPage(page) + expect(out.html).toContain(' { - const out = exportPage(makePage()) - const css = out.css - expect(css).toMatch(/font-family:[^;]*system-ui/i) + const page = makePage() + const out = exportPage(page) + expect(out.css).toMatch(/font-family:[^;]*system-ui/i) }) }) diff --git a/lib/exporter/html.ts b/lib/exporter/html.ts index cfd0265..3b5655f 100644 --- a/lib/exporter/html.ts +++ b/lib/exporter/html.ts @@ -254,9 +254,9 @@ function buildHead(page: Page) { + - - + ${escapeHtml(page.seo.title)} diff --git a/lib/exporter/manifest.robots.auto.test.ts b/lib/exporter/manifest.robots.auto.test.ts index faeeee4..17b21e7 100644 --- a/lib/exporter/manifest.robots.auto.test.ts +++ b/lib/exporter/manifest.robots.auto.test.ts @@ -2,36 +2,31 @@ import { describe, it, expect } from 'vitest' import { pageSchema, type Page } from '@/lib/schema/page' import { buildExtrasForPage } from '@/lib/exporter/extras' -function makePage(overrides: Partial = {}): Page { - const base: Page = pageSchema.parse({ - title: 'My Landing', +function makePage(): Page { + return pageSchema.parse({ + title: 'Auto Extras', locale: 'en', theme: { primaryColor: '#2563eb', fontFamily: 'Inter' }, - sections: [], + sections: [ { type: 'hero', props: { heading: 'Hello', subheading: 'World', imageUrl: 'https://img/h.png' } } ], form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } }, - seo: { title: 'SEO Title', description: 'desc' }, + seo: { title: 't', description: 'd' }, }) - return { ...base, ...overrides } } -describe('extras 자동 생성: site.webmanifest / robots.txt', () => { - it('Page에서 파생된 site.webmanifest JSON과 robots.txt를 extras에 포함한다', async () => { - const page = makePage({ title: 'Awesome App', theme: { primaryColor: '#123456', fontFamily: 'Inter' } }) +describe('extras 자동 생성', () => { + it('robots.txt와 site.webmanifest가 extras에 포함된다', () => { + const page = makePage() const extras = buildExtrasForPage(page) + const keys = Object.keys(extras) + expect(keys).toContain('robots.txt') + expect(keys).toContain('site.webmanifest') - // robots.txt 존재 및 베이스라인 확인 - expect(Object.keys(extras)).toContain('robots.txt') - const robots = new TextDecoder().decode(extras['robots.txt']) - expect(robots).toMatch(/User-agent: \*/) + const robots = Buffer.from(extras['robots.txt']).toString('utf8') + expect(robots).toContain('User-agent: *') - // manifest 존재 및 필수 키 확인 - expect(Object.keys(extras)).toContain('site.webmanifest') - const manifestStr = new TextDecoder().decode(extras['site.webmanifest']) + const manifestStr = Buffer.from(extras['site.webmanifest']).toString('utf8') const manifest = JSON.parse(manifestStr) - expect(manifest.name).toBe('Awesome App') - expect(manifest.short_name.length).toBeGreaterThan(0) - expect(manifest.start_url).toBe('/') - expect(manifest.display).toBe('standalone') - expect(manifest.theme_color).toBe('#123456') + expect(manifest.start_url).toBeDefined() + expect(manifest.display).toBeDefined() }) }) diff --git a/lib/exporter/zip.validation.phase5.test.ts b/lib/exporter/zip.validation.phase5.test.ts index 5469c82..ba282ca 100644 --- a/lib/exporter/zip.validation.phase5.test.ts +++ b/lib/exporter/zip.validation.phase5.test.ts @@ -1,40 +1,39 @@ import { describe, it, expect } from 'vitest' -import JSZip from 'jszip' -import { exportPage } from '@/lib/exporter/html' import { buildZip } from '@/lib/exporter/zip' -import { buildExtrasForPage } from '@/lib/exporter/extras' +import { exportPage } from '@/lib/exporter/html' import { pageSchema, type Page } from '@/lib/schema/page' +import { buildExtrasForPage } from '@/lib/exporter/extras' function makePage(): Page { return pageSchema.parse({ - title: 'Phase5', + title: 'ZIP Phase5', locale: 'en', theme: { primaryColor: '#2563eb', fontFamily: 'Inter' }, - sections: [], + sections: [ { type: 'hero', props: { heading: 'Hello', subheading: 'World', imageUrl: 'https://img/h.png' } } ], form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } }, seo: { title: 't', description: 'd' }, }) } -describe('Export ZIP 검증 강화 5차: extras MIME/내용 검증', () => { - it('robots.txt는 텍스트이고, site.webmanifest는 유효한 JSON이다', async () => { +describe('ZIP 검증 Phase5', () => { + it('robots.txt 베이스라인과 site.webmanifest JSON 파싱 검증', async () => { const page = makePage() - const out = exportPage(page) const extras = buildExtrasForPage(page) + // baseline robots + const robots = Buffer.from(extras['robots.txt']).toString('utf8') + expect(robots).toContain('User-agent: *') + + // manifest JSON parseable + const manifestStr = Buffer.from(extras['site.webmanifest']).toString('utf8') + const manifest = JSON.parse(manifestStr) + expect(manifest && typeof manifest === 'object').toBe(true) + expect(manifest.start_url).toBeDefined() + expect(manifest.display).toBeDefined() + + // ZIP build should succeed + const out = exportPage(page) const zipBytes = await buildZip({ html: out.html, css: out.css, js: out.js, assets: {}, extras }) - const zip = await JSZip.loadAsync(zipBytes) - - // robots.txt 존재 및 텍스트 확인 - expect(Object.keys(zip.files)).toContain('robots.txt') - const robots = await zip.files['robots.txt'].async('string') - expect(robots).toMatch(/User-agent: \*/) // baseline - - // site.webmanifest 존재 및 JSON 파싱 가능 - expect(Object.keys(zip.files)).toContain('site.webmanifest') - const manifestStr = await zip.files['site.webmanifest'].async('string') - const parsed = JSON.parse(manifestStr) - expect(parsed.start_url).toBe('/') - expect(parsed.display).toBe('standalone') + expect(zipBytes.byteLength).toBeGreaterThan(0) }) })