From 68c8c8ff15d8e07e92378a7c769057f0c73b224e Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sat, 15 Nov 2025 08:11:16 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(export-extras):=20manifest/robots=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=98=A4?= =?UTF-8?q?=EB=B2=84=EB=9D=BC=EC=9D=B4=EB=93=9C=20=EB=B3=B5=EA=B5=AC(TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/exporter/extras.ts | 64 +++++++++++++++++++++++ lib/exporter/manifest.robots.auto.test.ts | 32 ++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 lib/exporter/extras.ts create mode 100644 lib/exporter/manifest.robots.auto.test.ts diff --git a/lib/exporter/extras.ts b/lib/exporter/extras.ts new file mode 100644 index 0000000..84cd4dd --- /dev/null +++ b/lib/exporter/extras.ts @@ -0,0 +1,64 @@ +import type { Page } from '@/lib/schema/page' + +export type ExtrasOverrides = { + robotsText?: string + manifest?: Partial<{ + name: string + short_name: string + start_url: string + display: string + theme_color: string + }> +} + +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 out: Record = {} + + // robots.txt + const robotsText = buildRobots(overrides?.robotsText) + out['robots.txt'] = toBytes(robotsText) + + // site.webmanifest + const manifestBase = deriveManifest(page) + const manifestMerged = { ...manifestBase, ...(overrides?.manifest || {}) } + const manifestJson = JSON.stringify(manifestMerged, null, 2) + out['site.webmanifest'] = toBytes(manifestJson) + + // Google Sheets 템플릿: actionUrl 미지정 시 포함 + const action = page.form?.actionUrl?.trim() + const hasAction = !!(action && action.length > 0) + if (!hasAction) { + 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 = `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 out +} diff --git a/lib/exporter/manifest.robots.auto.test.ts b/lib/exporter/manifest.robots.auto.test.ts new file mode 100644 index 0000000..17b21e7 --- /dev/null +++ b/lib/exporter/manifest.robots.auto.test.ts @@ -0,0 +1,32 @@ +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: 'Auto Extras', + 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('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') + + const robots = Buffer.from(extras['robots.txt']).toString('utf8') + expect(robots).toContain('User-agent: *') + + const manifestStr = Buffer.from(extras['site.webmanifest']).toString('utf8') + const manifest = JSON.parse(manifestStr) + expect(manifest.start_url).toBeDefined() + expect(manifest.display).toBeDefined() + }) +}) -- 2.52.0 From 76889080e842ec7b840ca9395ca6ead679c71602 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sat, 15 Nov 2025 08:11:50 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(font):=20Google=20Fonts=20preconnect?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20system-ui=20=ED=8F=B4?= =?UTF-8?q?=EB=B0=B1=20TDD?= 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..f98d6fc --- /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('폰트 최적화', () => { + it('head에 Google Fonts preconnect 링크가 포함된다', () => { + const page = makePage() + const out = exportPage(page) + expect(out.html).toContain(' { + 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 2c6cd0b..952bfc6 100644 --- a/lib/exporter/html.ts +++ b/lib/exporter/html.ts @@ -254,6 +254,7 @@ function buildHead(page: Page) { + ${escapeHtml(page.seo.title)} -- 2.52.0 From 87578099285b228621be2568ee79985165a3bc56 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sat, 15 Nov 2025 12:44:41 +0900 Subject: [PATCH 3/5] =?UTF-8?q?test(extras):=20robots/manifest=20=EC=98=A4?= =?UTF-8?q?=EB=B2=84=EB=9D=BC=EC=9D=B4=EB=93=9C=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/exporter/extras.overrides.test.ts | 45 +++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) 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..367dc06 --- /dev/null +++ b/lib/exporter/extras.overrides.test.ts @@ -0,0 +1,45 @@ +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', + 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('extras 오버라이드', () => { + it('robots.txt Disallow 오버라이드가 반영된다', () => { + const page = makePage() + 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('manifest 필드 오버라이드(name/short_name/start_url/display/theme_color)가 반영된다', () => { + const page = makePage() + const extras = buildExtrasForPage(page, { + manifest: { + name: 'My App', + short_name: 'MyApp', + start_url: '/start', + display: 'minimal-ui', + theme_color: '#123abc', + }, + }) + 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('#123abc') + }) +}) -- 2.52.0 From 2caffe9950dde93ff22bafe99d60453e674a1dc8 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sat, 15 Nov 2025 12:45:56 +0900 Subject: [PATCH 4/5] =?UTF-8?q?test(zip):=20Phase5=20-=20robots/manifest?= =?UTF-8?q?=20=EB=82=B4=EC=9A=A9=20=EA=B2=80=EC=A6=9D=20=EB=B0=8F=20ZIP=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=EC=8A=A4=EB=AA=A8=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/exporter/zip.validation.phase5.test.ts | 39 ++++++++++++++++++++++ 1 file changed, 39 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..ba282ca --- /dev/null +++ b/lib/exporter/zip.validation.phase5.test.ts @@ -0,0 +1,39 @@ +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 makePage(): Page { + return pageSchema.parse({ + title: 'ZIP Phase5', + 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('ZIP 검증 Phase5', () => { + it('robots.txt 베이스라인과 site.webmanifest JSON 파싱 검증', async () => { + const page = makePage() + 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 }) + expect(zipBytes.byteLength).toBeGreaterThan(0) + }) +}) -- 2.52.0 From f385966f871523ccbd9a30f62f8a6ba4dfcf2016 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sat, 15 Nov 2025 13:07:40 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix(exporter/head):=20manifest=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=EC=99=80=20theme-color=20=EB=A9=94=ED=83=80=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=EB=A1=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/exporter/html.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/exporter/html.ts b/lib/exporter/html.ts index b378a3e..3b5655f 100644 --- a/lib/exporter/html.ts +++ b/lib/exporter/html.ts @@ -255,6 +255,8 @@ function buildHead(page: Page) { + + ${escapeHtml(page.seo.title)} -- 2.52.0