Compare commits

...

7 Commits

Author SHA1 Message Date
jaybe 39318dd40d feat(export): 앱 Export 흐름에 extras 통합(buildExtrasForPage → buildZip)
Auto PR / open-pr (push) Successful in 19s
Auto Label PR / add-automerge-label (pull_request) Successful in 6s
CI / test (pull_request) Successful in 54s
2025-11-15 01:21:02 +09:00
jaybe be80f9339b feat(export): Google Sheets Apps Script 템플릿 extras 추가\n\n- actionUrl 미지정 시 sheets/Code.gs, README.txt 포함\n- buildExtrasForPage 유닛/통합 테스트 추가 2025-11-15 01:00:49 +09:00
jaybe df41aac2b1 feat(exporter): 폰트 최적화 - preconnect(googleapis/gstatic) 및 system-ui 폴백 테스트 추가
Auto PR / open-pr (push) Successful in 20s
Auto Label PR / add-automerge-label (pull_request) Successful in 7s
CI / test (pull_request) Successful in 56s
2025-11-15 00:41:48 +09:00
jaybe a620ff970a Merge branch 'main' of https://gitea.jaybe.dev/jaybe/landing-builder
CI / test (push) Successful in 54s
2025-11-15 00:23:32 +09:00
jaybe 524abda49c ignore 수정 2025-11-15 00:18:12 +09:00
jaybe fe62656d37 Merge pull request 'auto: PR for feat/zip-validation-phase4' (#25) from feat/zip-validation-phase4 into main 2025-11-14 15:12:40 +00:00
jaybe e9a87773ea Merge pull request 'auto: PR for feat/export-zip-validation' (#22) from feat/export-zip-validation into main 2025-11-14 14:53:48 +00:00
6 changed files with 125 additions and 1 deletions
+1
View File
@@ -39,3 +39,4 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
MAIN_PLAN.md
+3 -1
View File
@@ -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])
+44
View File
@@ -0,0 +1,44 @@
import type { Page } from '@/lib/schema/page'
// Sheets 템플릿 등 ZIP 루트/서브 경로에 포함할 추가 파일(extras)을 구성한다.
// - actionUrl이 비어있을 때만 Google Apps Script 템플릿을 포함한다.
export function buildExtrasForPage(page: Page): Record<string, Uint8Array> {
const extras: Record<string, Uint8Array> = {}
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
}
+29
View File
@@ -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(/<link rel="preconnect" href="https:\/\/fonts\.googleapis\.com">/)
expect(html).toMatch(/<link rel="preconnect" href="https:\/\/fonts\.gstatic\.com" crossorigin>/)
})
it('CSS에 system-ui 폴백이 포함된다', () => {
const out = exportPage(makePage())
const css = out.css
expect(css).toMatch(/font-family:[^;]*system-ui/i)
})
})
+1
View File
@@ -336,6 +336,7 @@ export function exportPage(page: Page, opts?: ExportOptions): Exported {
<html lang="${escapeHtml(page.locale)}">
<head>
${buildHead(page)}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
</head>
<body>
+47
View File
@@ -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')
})
})