From df41aac2b1f47f4f26c07c1f022fae7e75c76470 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sat, 15 Nov 2025 00:41:48 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat(exporter):=20=ED=8F=B0=ED=8A=B8=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94=20-=20preconnect(googleapis/gstatic?= =?UTF-8?q?)=20=EB=B0=8F=20system-ui=20=ED=8F=B4=EB=B0=B1=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/exporter/font.optimization.test.ts | 29 ++++++++++++++++++++++++++ lib/exporter/html.ts | 1 + 2 files changed, 30 insertions(+) create mode 100644 lib/exporter/font.optimization.test.ts 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..4ff9c78 100644 --- a/lib/exporter/html.ts +++ b/lib/exporter/html.ts @@ -336,6 +336,7 @@ export function exportPage(page: Page, opts?: ExportOptions): Exported { ${buildHead(page)} + -- 2.52.0 From be80f9339be3eff227d270f6582d42c15cb24d97 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sat, 15 Nov 2025 01:00:49 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat(export):=20Google=20Sheets=20Apps=20Sc?= =?UTF-8?q?ript=20=ED=85=9C=ED=94=8C=EB=A6=BF=20extras=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80\n\n-=20actionUrl=20=EB=AF=B8=EC=A7=80=EC=A0=95=20?= =?UTF-8?q?=EC=8B=9C=20sheets/Code.gs,=20README.txt=20=ED=8F=AC=ED=95=A8\n?= =?UTF-8?q?-=20buildExtrasForPage=20=EC=9C=A0=EB=8B=9B/=ED=86=B5=ED=95=A9?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/exporter/extras.ts | 44 ++++++++++++++++++++++++++ lib/exporter/sheets.template.test.ts | 47 ++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 lib/exporter/extras.ts create mode 100644 lib/exporter/sheets.template.test.ts diff --git a/lib/exporter/extras.ts b/lib/exporter/extras.ts new file mode 100644 index 0000000..a29b7fa --- /dev/null +++ b/lib/exporter/extras.ts @@ -0,0 +1,44 @@ +import type { Page } from '@/lib/schema/page' + +// Sheets 템플릿 등 ZIP 루트/서브 경로에 포함할 추가 파일(extras)을 구성한다. +// - actionUrl이 비어있을 때만 Google Apps Script 템플릿을 포함한다. +export function buildExtrasForPage(page: Page): Record { + const extras: Record = {} + const te = new TextEncoder() + + 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/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') + }) +}) -- 2.52.0 From 39318dd40d257b8abdbf6276a7f47dcba8c778f3 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sat, 15 Nov 2025 01:21:02 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat(export):=20=EC=95=B1=20Export=20?= =?UTF-8?q?=ED=9D=90=EB=A6=84=EC=97=90=20extras=20=ED=86=B5=ED=95=A9(build?= =?UTF-8?q?ExtrasForPage=20=E2=86=92=20buildZip)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/BuilderClientPage.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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]) -- 2.52.0 From e6c897a863e8f6a0f20ab6d5bd6c7c8e1bd98c55 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sat, 15 Nov 2025 01:26:04 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat(export):=20site.webmanifest/robots.txt?= =?UTF-8?q?=20=EC=9E=90=EB=8F=99=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20extras?= =?UTF-8?q?=20=ED=8F=AC=ED=95=A8=20(TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/exporter/extras.ts | 18 +++++++++++ lib/exporter/manifest.robots.auto.test.ts | 37 +++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 lib/exporter/manifest.robots.auto.test.ts diff --git a/lib/exporter/extras.ts b/lib/exporter/extras.ts index a29b7fa..e112772 100644 --- a/lib/exporter/extras.ts +++ b/lib/exporter/extras.ts @@ -6,6 +6,24 @@ export function buildExtrasForPage(page: Page): Record { const extras: Record = {} const te = new TextEncoder() + // Always provide a baseline robots.txt + const robots = `User-agent: *\nDisallow:` + 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', + } + 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) 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') + }) +}) -- 2.52.0 From 0561c5d63cbc563ae6798187060a48d13ba82a27 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sat, 15 Nov 2025 01:41:54 +0900 Subject: [PATCH 5/8] =?UTF-8?q?test(zip):=20ZIP=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EA=B0=95=ED=99=94=205=EC=B0=A8=20-=20extras(robots.txt/manifes?= =?UTF-8?q?t)=20=EB=82=B4=EC=9A=A9=20=EA=B2=80=EC=A6=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/exporter/zip.validation.phase5.test.ts | 40 ++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 lib/exporter/zip.validation.phase5.test.ts 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') + }) +}) -- 2.52.0 From cc2b3039a3d7291fbfcda70d9022fbcf7ba5db7c Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sat, 15 Nov 2025 02:15:44 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat(head):=20manifest/ico=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=EB=B0=98=EC=98=81=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80(manifest.link.tokens)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/exporter/html.ts | 2 ++ lib/exporter/manifest.link.tokens.test.ts | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 lib/exporter/manifest.link.tokens.test.ts diff --git a/lib/exporter/html.ts b/lib/exporter/html.ts index 4ff9c78..24c33e8 100644 --- a/lib/exporter/html.ts +++ b/lib/exporter/html.ts @@ -254,6 +254,8 @@ function buildHead(page: Page) { + + ${escapeHtml(page.seo.title)} 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(//) + }) +}) -- 2.52.0 From 5c23cf3873e0fc95e68a4a0b003dc78b23911c92 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sat, 15 Nov 2025 02:20:47 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feat(seo):=20theme-color=20=EB=A9=94?= =?UTF-8?q?=ED=83=80=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20hero=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20width/height=EB=A1=9C=20CLS=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0(TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/exporter/html.ts | 3 +- .../seoPerf.themeColor.heroDims.test.ts | 31 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 lib/exporter/seoPerf.themeColor.heroDims.test.ts diff --git a/lib/exporter/html.ts b/lib/exporter/html.ts index 24c33e8..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)}
@@ -256,6 +256,7 @@ function buildHead(page: Page) { + ${escapeHtml(page.seo.title)} 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]+"/) + }) +}) -- 2.52.0 From 1a405a54fa3dc3605868d5ae8d14a4a307ea6228 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sat, 15 Nov 2025 02:26:54 +0900 Subject: [PATCH 8/8] =?UTF-8?q?feat(export):=20extras=20=EC=98=A4=EB=B2=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=93=9C=20=EC=A7=80=EC=9B=90(robots/manifes?= =?UTF-8?q?t)=20TDD=20=EB=B0=8F=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/exporter/extras.overrides.test.ts | 47 +++++++++++++++++++++++++++ lib/exporter/extras.ts | 23 +++++++++++-- 2 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 lib/exporter/extras.overrides.test.ts 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 {} -- 2.52.0