diff --git a/components/BuilderClientPage.tsx b/components/BuilderClientPage.tsx index 9729e2f..56a8da2 100644 --- a/components/BuilderClientPage.tsx +++ b/components/BuilderClientPage.tsx @@ -6,6 +6,7 @@ import { pageSchema, type Page } from '@/lib/schema/page' import { exportPage } from '@/lib/exporter/html' import { AssetManager } from '@/lib/assets/assetManager' import { buildZip } from '@/lib/exporter/zip' +import { buildExtrasForPage } from '@/lib/exporter/extras' import type { Section } from '@/lib/state/store' import { createFormStore, type FormState } from '@/lib/state/formStore' import FormBuilderPanel from '@/components/FormBuilderPanel' @@ -196,7 +197,8 @@ export default function BuilderClientPage() { } } catch {} const assets = am ? am.toZipStructure() : {} - const zipBytes = await buildZip({ html: exported.html, css: exported.css, js: exported.js, assets }) + const extras = buildExtrasForPage(valid) + const zipBytes = await buildZip({ html: exported.html, css: exported.css, js: exported.js, assets, extras }) download('landing.zip', zipBytes) }, [pageData, am]) 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 new file mode 100644 index 0000000..8b9fa4d --- /dev/null +++ b/lib/exporter/extras.ts @@ -0,0 +1,81 @@ +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, overrides?: ExtrasOverrides): Record { + const extras: Record = {} + const te = new TextEncoder() + + // Always provide a baseline robots.txt + const robotsDefault = `User-agent: *\nDisallow:` + const robots = overrides?.robotsText ?? robotsDefault + extras['robots.txt'] = te.encode(robots) + + // 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 {} + + const hasAction = !!(page.form?.actionUrl && page.form.actionUrl.trim().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 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) + } + + return extras +} diff --git a/lib/exporter/font.optimization.test.ts b/lib/exporter/font.optimization.test.ts new file mode 100644 index 0000000..de7a852 --- /dev/null +++ b/lib/exporter/font.optimization.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest' +import { exportPage } from '@/lib/exporter/html' +import { pageSchema, type Page } from '@/lib/schema/page' + +function makePage(): Page { + return pageSchema.parse({ + title: 'Font Opt', + locale: 'en', + 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: 't', description: 'd' }, + }) +} + +describe('Exporter - 폰트 최적화', () => { + it('head에 폰트 preconnect 링크가 포함된다(googleapis/gstatic)', () => { + const out = exportPage(makePage()) + const html = out.html + expect(html).toMatch(//) + expect(html).toMatch(//) + }) + + it('CSS에 system-ui 폴백이 포함된다', () => { + const out = exportPage(makePage()) + const css = out.css + expect(css).toMatch(/font-family:[^;]*system-ui/i) + }) +}) diff --git a/lib/exporter/html.ts b/lib/exporter/html.ts index 2c6cd0b..cfd0265 100644 --- a/lib/exporter/html.ts +++ b/lib/exporter/html.ts @@ -124,7 +124,7 @@ function renderHero(section: { props: { heading: string; subheading?: string; im ${subheading.length > 0 ? `

${escapeHtml(subheading)}

` : ''}
- ${escapeHtml(heading)} + ${escapeHtml(heading)}
@@ -254,6 +254,9 @@ function buildHead(page: Page) { + + + ${escapeHtml(page.seo.title)} @@ -336,6 +339,7 @@ export function exportPage(page: Page, opts?: ExportOptions): Exported { ${buildHead(page)} + diff --git a/lib/exporter/manifest.link.tokens.test.ts b/lib/exporter/manifest.link.tokens.test.ts new file mode 100644 index 0000000..3c72170 --- /dev/null +++ b/lib/exporter/manifest.link.tokens.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest' +import { exportPage } from '@/lib/exporter/html' +import { pageSchema, type Page } from '@/lib/schema/page' + +function makePage(): Page { + return pageSchema.parse({ + title: 'Head Links', + locale: 'en', + theme: { primaryColor: '#2563eb', fontFamily: 'Inter' }, + sections: [], + form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } }, + seo: { title: 't', description: 'd' }, + }) +} + +describe('Exporter - head 링크 토큰', () => { + it('site.webmanifest 링크를 포함한다', () => { + const out = exportPage(makePage()) + expect(out.html).toMatch(//) + }) +}) diff --git a/lib/exporter/manifest.robots.auto.test.ts b/lib/exporter/manifest.robots.auto.test.ts new file mode 100644 index 0000000..faeeee4 --- /dev/null +++ b/lib/exporter/manifest.robots.auto.test.ts @@ -0,0 +1,37 @@ +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', + locale: 'en', + theme: { primaryColor: '#2563eb', fontFamily: 'Inter' }, + sections: [], + form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } }, + seo: { title: 'SEO Title', description: 'desc' }, + }) + 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' } }) + const extras = buildExtrasForPage(page) + + // robots.txt 존재 및 베이스라인 확인 + expect(Object.keys(extras)).toContain('robots.txt') + const robots = new TextDecoder().decode(extras['robots.txt']) + expect(robots).toMatch(/User-agent: \*/) + + // manifest 존재 및 필수 키 확인 + expect(Object.keys(extras)).toContain('site.webmanifest') + const manifestStr = new TextDecoder().decode(extras['site.webmanifest']) + 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') + }) +}) diff --git a/lib/exporter/seoPerf.themeColor.heroDims.test.ts b/lib/exporter/seoPerf.themeColor.heroDims.test.ts new file mode 100644 index 0000000..3d730cf --- /dev/null +++ b/lib/exporter/seoPerf.themeColor.heroDims.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest' +import { exportPage } from '@/lib/exporter/html' +import { pageSchema, type Page } from '@/lib/schema/page' + +function makePage(): Page { + return pageSchema.parse({ + title: 'SEO/Perf 2', + locale: 'en', + theme: { primaryColor: '#0ea5e9', 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: 'SEO2', description: 'Desc2' }, + }) +} + +describe('Exporter - theme-color meta and hero image intrinsic size', () => { + it('includes theme-color meta using page.theme.primaryColor', () => { + const out = exportPage(makePage()) + const html = out.html + expect(html).toMatch(//) + }) + + it('renders hero image with width/height attributes for CLS reduction', () => { + const out = exportPage(makePage()) + const html = out.html + expect(html).toMatch(/]*\swidth="[0-9]+"/) + expect(html).toMatch(/]*\sheight="[0-9]+"/) + }) +}) diff --git a/lib/exporter/sheets.template.test.ts b/lib/exporter/sheets.template.test.ts new file mode 100644 index 0000000..3a756a3 --- /dev/null +++ b/lib/exporter/sheets.template.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest' +import { buildZip } from '@/lib/exporter/zip' +import { exportPage } from '@/lib/exporter/html' +import { pageSchema, type Page } from '@/lib/schema/page' +import { buildExtrasForPage } from '@/lib/exporter/extras' + +function makePageNoAction(): Page { + return pageSchema.parse({ + title: 'Sheets Tpl', + locale: 'en', + theme: { primaryColor: '#2563eb', fontFamily: 'Inter' }, + sections: [ { type: 'hero', props: { heading: 'Hello', subheading: 'World', imageUrl: 'https://img/h.png' } } ], + form: { actionUrl: '', fields: [ { type: 'text', name: 'email', label: 'Email', required: true } ], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } }, + seo: { title: 't', description: 'd' }, + }) +} + +function makePageWithAction(): Page { + return pageSchema.parse({ + title: 'Sheets Off', + locale: 'en', + theme: { primaryColor: '#2563eb', fontFamily: 'Inter' }, + sections: [ { type: 'hero', props: { heading: 'Hello', subheading: 'World', imageUrl: 'https://img/h.png' } } ], + form: { actionUrl: 'https://example.com/submit', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } }, + seo: { title: 't', description: 'd' }, + }) +} + +describe('Sheets 템플릿 Export', () => { + it('actionUrl 미지정 시 extras에 Sheets 템플릿 파일을 포함한다', async () => { + const page = makePageNoAction() + const extras = buildExtrasForPage(page) + expect(Object.keys(extras)).toContain('sheets/Code.gs') + expect(Object.keys(extras)).toContain('sheets/README.txt') + + const out = exportPage(page) + const zipBytes = await buildZip({ html: out.html, css: out.css, js: out.js, assets: {}, extras }) + expect(zipBytes.byteLength).toBeGreaterThan(0) + }) + + it('actionUrl 지정 시 extras에는 Sheets 템플릿이 포함되지 않는다', () => { + const page = makePageWithAction() + const extras = buildExtrasForPage(page) + expect(Object.keys(extras)).not.toContain('sheets/Code.gs') + expect(Object.keys(extras)).not.toContain('sheets/README.txt') + }) +}) diff --git a/lib/exporter/zip.validation.phase5.test.ts b/lib/exporter/zip.validation.phase5.test.ts new file mode 100644 index 0000000..5469c82 --- /dev/null +++ b/lib/exporter/zip.validation.phase5.test.ts @@ -0,0 +1,40 @@ +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 { pageSchema, type Page } from '@/lib/schema/page' + +function makePage(): Page { + return pageSchema.parse({ + title: 'Phase5', + locale: 'en', + theme: { primaryColor: '#2563eb', fontFamily: 'Inter' }, + sections: [], + 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 () => { + const page = makePage() + const out = exportPage(page) + const extras = buildExtrasForPage(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') + }) +})