test(ci): add exporter heading levels and a11y typography smoke; update MAIN_PLAN
CI / test (pull_request) Has been cancelled

This commit is contained in:
2025-11-14 16:40:45 +09:00
parent aaa624a046
commit cd6a6ea069
93 changed files with 8543 additions and 13 deletions
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest'
import { AssetManager } from '@/lib/assets/assetManager'
function buf(bytes: number[]): Uint8Array {
return new Uint8Array(bytes)
}
describe('AssetManager', () => {
it('업로드 이미지를 해시하여 assets/<hash>.<ext>로 관리하고 중복을 제거한다', async () => {
const am = new AssetManager({
maxBytes: 10 * 1024 * 1024,
allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'],
})
const a1 = await am.add({ name: 'hero.png', type: 'image/png', bytes: buf([1,2,3,4]) })
const a2 = await am.add({ name: 'another.png', type: 'image/png', bytes: buf([1,2,3,4]) })
const a3 = await am.add({ name: 'logo.svg', type: 'image/svg+xml', bytes: buf([60,115,118,103,62]) })
expect(a1.path).toMatch(/^assets\/[a-f0-9]{8}\.png$/)
expect(a2.path).toBe(a1.path) // 동일 바이트 -> 동일 해시 -> 중복 제거
expect(a3.path).toMatch(/^assets\/[a-f0-9]{8}\.svg$/)
const list = am.list()
expect(list.length).toBe(2)
const zipStruct = am.toZipStructure()
const keys = Object.keys(zipStruct)
expect(keys).toContain(a1.path)
expect(keys).toContain(a3.path)
expect(zipStruct[a1.path]).toBeInstanceOf(Uint8Array)
})
it('허용되지 않은 확장자/크기는 거절한다', async () => {
const am = new AssetManager({ maxBytes: 5, allowedExts: ['png'] })
await expect(
am.add({ name: 'bad.gif', type: 'image/gif', bytes: buf([1]) })
).rejects.toThrow(/extension/i)
await expect(
am.add({ name: 'big.png', type: 'image/png', bytes: buf([1,2,3,4,5,6]) })
).rejects.toThrow(/size/i)
})
it('rewriteUrl: http/https는 그대로, local:<name>은 assets 경로로 치환한다', async () => {
const am = new AssetManager({ maxBytes: 1024, allowedExts: ['png'] })
const external = am.rewriteUrl('https://cdn.example.com/x.png')
expect(external).toBe('https://cdn.example.com/x.png')
const ref = await am.add({ name: 'pic.png', type: 'image/png', bytes: buf([9,9,9]) })
const rewritten = am.rewriteUrl(`local:${ref.originalName}`)
expect(rewritten).toBe(ref.path)
})
})
+98
View File
@@ -0,0 +1,98 @@
export type AssetInput = {
name: string
type: string
bytes: Uint8Array
}
export type AssetRef = {
originalName: string
mimeType: string
bytes: Uint8Array
hash8: string
ext: string
path: string // assets/<hash8>.<ext>
}
export type AssetManagerOptions = {
maxBytes: number
allowedExts: string[] // e.g., ['png','jpg','jpeg','webp','svg']
}
function extOf(name: string): string {
const i = name.lastIndexOf('.')
return i >= 0 ? name.slice(i + 1).toLowerCase() : ''
}
// Simple FNV-1a 32-bit hash -> 8-hex chars
function hash8(bytes: Uint8Array): string {
let h = 0x811c9dc5 >>> 0
for (let i = 0; i < bytes.length; i++) {
h ^= bytes[i]
h = Math.imul(h, 0x01000193) >>> 0
}
return ('00000000' + h.toString(16)).slice(-8)
}
export class AssetManager {
private readonly maxBytes: number
private readonly allowed: Set<string>
private byHash = new Map<string, AssetRef>()
private byOriginal = new Map<string, string>() // originalName -> path
constructor(opts: AssetManagerOptions) {
this.maxBytes = opts.maxBytes
this.allowed = new Set(opts.allowedExts.map((e) => e.toLowerCase()))
}
async add(file: AssetInput): Promise<AssetRef> {
const ext = extOf(file.name)
if (!this.allowed.has(ext)) {
throw new Error('Invalid extension')
}
if (file.bytes.length > this.maxBytes) {
throw new Error('File size exceeded')
}
const h = hash8(file.bytes)
const existing = this.byHash.get(h)
if (existing) {
// map original name for rewrite convenience
if (!this.byOriginal.has(file.name)) this.byOriginal.set(file.name, existing.path)
return existing
}
const path = `assets/${h}.${ext}`
const ref: AssetRef = {
originalName: file.name,
mimeType: file.type,
bytes: file.bytes,
hash8: h,
ext,
path,
}
this.byHash.set(h, ref)
if (!this.byOriginal.has(file.name)) this.byOriginal.set(file.name, path)
return ref
}
list(): AssetRef[] {
return Array.from(this.byHash.values())
}
toZipStructure(): Record<string, Uint8Array> {
const out: Record<string, Uint8Array> = {}
for (const ref of this.byHash.values()) {
out[ref.path] = ref.bytes
}
return out
}
rewriteUrl(urlOrLocalRef: string): string {
if (/^https?:\/\//i.test(urlOrLocalRef)) return urlOrLocalRef
if (urlOrLocalRef.startsWith('local:')) {
const key = urlOrLocalRef.slice('local:'.length)
const p = this.byOriginal.get(key)
return p ?? urlOrLocalRef
}
return urlOrLocalRef
}
}
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
function makePage(): Page {
return pageSchema.parse({
title: 'T',
locale: 'en',
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
sections: [
{ type: 'hero', props: { heading: 'Welcome', subheading: '', imageUrl: 'https://example.com/hero.png' } },
{ type: 'faq', props: { items: [{ q: 'Q1', a: 'A1' }] } },
{ type: 'cta', props: { text: 'Go', href: '#' } },
] as Page['sections'],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
seo: { title: 'SEO', description: 'Desc' },
})
}
describe('Exporter a11y/structure - main contains sections and form', () => {
test('main landmark wraps sections and form markup', () => {
const out = exportPage(makePage())
expect(out.html).toContain('<main id="main" role="main">')
// sections inside main
expect(out.html).toMatch(/<main[\s\S]*<section class="hero"[\s\S]*<section class="faq"[\s\S]*<section class="cta"[\s\S]*<\/main>/)
// form inside main
expect(out.html).toMatch(/<main[\s\S]*<form method="post"[\s\S]*<\/main>/)
})
})
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
function makePage(overrides: Partial<Page> = {}): Page {
const base: Page = {
title: 'T',
locale: 'en',
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
sections: [
{ type: 'hero', props: { heading: 'Welcome', subheading: 'Sub', imageUrl: 'https://example.com/img.png' } },
{ type: 'faq', props: { items: [{ q: 'Q1', a: 'A1' }] } },
{ type: 'features', props: { items: ['One', 'Two'] } },
{ type: 'testimonials', props: { items: [{ author: 'Jane', quote: 'Great' }] } },
{ type: 'cta', props: { text: 'Go', href: '#' } },
] as Page['sections'],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
seo: { title: 'SEO', description: 'Desc' },
}
return pageSchema.parse({ ...base, ...overrides })
}
describe('Exporter a11y/responsive enhancements', () => {
test('includes skip link and main landmark', () => {
const out = exportPage(makePage())
expect(out.html).toContain('<a href="#main" class="skip-link">')
expect(out.html).toContain('<main id="main" role="main">')
})
test('sections use aria-labelledby with h2 ids', () => {
const out = exportPage(makePage())
expect(out.html).toContain('<section class="faq" aria-labelledby="faq-heading">')
expect(out.html).toContain('<h2 id="faq-heading">FAQ</h2>')
expect(out.html).toContain('<section class="features" aria-labelledby="features-heading">')
expect(out.html).toContain('<h2 id="features-heading">Features</h2>')
expect(out.html).toContain('<section class="testimonials" aria-labelledby="testimonials-heading">')
expect(out.html).toContain('<h2 id="testimonials-heading">Testimonials</h2>')
})
test('hero image alt derives from heading', () => {
const out = exportPage(makePage())
expect(out.html).toContain('<h1 id="hero-heading">Welcome</h1>')
expect(out.html).toContain('<img src="https://example.com/img.png" alt="Welcome" />')
})
test('responsive and a11y CSS tokens exist', () => {
const out = exportPage(makePage())
expect(out.css).toMatch(/@media \(max-width: 640px\)/)
expect(out.css).toMatch(/@media \(prefers-reduced-motion: reduce\)/)
expect(out.css).toMatch(/a\.skip-link:focus/)
expect(out.css).toMatch(/outline: 3px solid/)
})
})
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest'
import { pageSchema, type Page } from '@/lib/schema/page'
import { exportPage } from '@/lib/exporter/html'
describe('Analytics snippet injection', () => {
const base = {
title: 'Analytics Test',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 'Analytics Test', description: 'desc' },
}
it('GA4 measurement ID가 있으면 GA4 스니펫을 삽입한다', () => {
const page: Page = pageSchema.parse({
...base,
analytics: { ga4MeasurementId: 'G-ABC123', metaPixelId: '' },
})
const out = exportPage(page)
expect(out.html).toContain('https://www.googletagmanager.com/gtag/js?id=G-ABC123')
expect(out.html).toContain("gtag('config', 'G-ABC123')")
})
it('Meta Pixel ID가 있으면 Pixel 스니펫을 삽입한다', () => {
const page: Page = pageSchema.parse({
...base,
analytics: { ga4MeasurementId: '', metaPixelId: '1234567890' },
})
const out = exportPage(page)
expect(out.html).toContain('fbq(')
expect(out.html).toContain("fbq('init', '1234567890')")
})
it('customHeadHtml이 있으면 head에 그대로 삽입한다', () => {
const page: Page = pageSchema.parse({
...base,
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '<meta name="robots" content="noindex">' },
})
const out = exportPage(page)
expect(out.html).toContain('<meta name="robots" content="noindex">')
})
})
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
const makePage = (): Page =>
pageSchema.parse({
title: 'T',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 'T', description: 'D' },
})
describe('Exporter CSS contrast/font tokens', () => {
test('contains base text color and responsive font adjustments', () => {
const out = exportPage(makePage())
// base body text color token present
expect(out.css).toMatch(/body\{[^}]*color:\s*#0f172a/i)
// focus outline thickness and offset present
expect(out.css).toMatch(/outline:\s*3px\s*solid/i)
expect(out.css).toMatch(/outline-offset:\s*2px/i)
// responsive font size change for h1 in mobile media query
expect(out.css).toMatch(/@media \(max-width: 640px\)[\s\S]*h1\{[^}]*font-size:\s*1\.5rem/i)
})
})
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
function makePage(): Page {
return pageSchema.parse({
title: 'T',
locale: 'en',
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
sections: [],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
seo: { title: 'SEO', description: 'Desc' },
})
}
describe('Exporter CSS tokens', () => {
test('includes focus outline, skip-link, and responsive MQ tokens', () => {
const out = exportPage(makePage())
expect(out.css).toMatch(/a\.skip-link:focus/)
expect(out.css).toMatch(/focus-visible|outline: 3px solid|outline-offset/)
expect(out.css).toMatch(/@media \(max-width: 640px\)/)
expect(out.css).toMatch(/@media \(prefers-reduced-motion: reduce\)/)
})
})
@@ -0,0 +1,28 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
function makePage(): Page {
return pageSchema.parse({
title: 'T',
locale: 'en',
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
sections: [
{ type: 'features', props: { items: ['One', 'Two'] } },
{ type: 'testimonials', props: { items: [{ author: 'Jane', quote: 'Great' }] } },
] as Page['sections'],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
seo: { title: 'SEO', description: 'Desc' },
})
}
describe('Exporter - Features/Testimonials aria-labelledby', () => {
test('has aria-labelledby and matching heading ids', () => {
const out = exportPage(makePage())
expect(out.html).toContain('<section class="features" aria-labelledby="features-heading">')
expect(out.html).toContain('<h2 id="features-heading">Features</h2>')
expect(out.html).toContain('<section class="testimonials" aria-labelledby="testimonials-heading">')
expect(out.html).toContain('<h2 id="testimonials-heading">Testimonials</h2>')
})
})
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
const makePage = (): Page =>
pageSchema.parse({
title: 'Focus Tokens',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 'SEO', description: 'Desc' },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
})
describe('CSS focus-visible tokens smoke', () => {
test('outline uses 3px solid and hex color token', () => {
const { css } = exportPage(makePage())
expect(css).toMatch(/outline:\s*3px\s*solid/i)
// check presence of our hex color used for outline (example: #2563eb)
expect(css).toMatch(/outline:\s*3px\s*solid\s*#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})/)
// ensure focus-visible styles exist on common interactive elements
expect(css).toMatch(/button:focus|:focus-visible|a:focus|input:focus|textarea:focus|select:focus/)
})
})
+61
View File
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest'
import { pageSchema, type Page } from '@/lib/schema/page'
import { exportPage } from '@/lib/exporter/html'
const makePage = (withAction = false): Page =>
pageSchema.parse({
title: 'Form Test',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [],
form: {
actionUrl: withAction ? 'https://example.com/submit' : '',
fields: [
{ type: 'text', name: 'name', label: 'Name', required: true },
{ type: 'email', name: 'email', label: 'Email', required: true },
{ type: 'select', name: 'plan', label: 'Plan', required: false, options: ['A','B'] },
{ type: 'radio', name: 'size', label: 'Size', required: false, options: ['S','M','L'] },
{ type: 'checkbox', name: 'agree', label: 'Agree', required: true },
{ type: 'textarea', name: 'msg', label: 'Message', required: false },
],
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
},
seo: { title: 'Form Test', description: 'desc' },
})
describe('Exporter - form rendering', () => {
it('필드 라벨/required/옵션 및 스팸 방지 요소를 렌더링한다 (action 미지정)', () => {
const page = makePage(false)
const out = exportPage(page)
// 필수 required
expect(out.html).toContain('name="name"')
expect(out.html).toContain('required')
expect(out.html).toContain('name="email"')
// select/radio 옵션 존재
expect(out.html).toContain('<select')
expect(out.html).toContain('<option value="A">A</option>')
expect(out.html).toContain('<input type="radio"')
// checkbox/textarea
expect(out.html).toContain('type="checkbox"')
expect(out.html).toContain('<textarea')
// 스팸 방지: honeypot + timestamp
expect(out.html).toContain('type="hidden"')
expect(out.html).toContain('name="_hp"')
expect(out.html).toContain('name="_ts"')
// action 미지정 → Sheets fallback 힌트
expect(out.html).toContain('data-sheets-fallback="true"')
})
it('action URL이 있으면 form에 action 속성이 포함된다', () => {
const page = makePage(true)
const out = exportPage(page)
expect(out.html).toContain('action="https://example.com/submit"')
expect(out.html).not.toContain('data-sheets-fallback="true"')
})
})
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest'
import { pageSchema, type Page } from '@/lib/schema/page'
import { exportPage } from '@/lib/exporter/html'
const makePage = (overrides: Partial<Page> = {}): Page =>
pageSchema.parse({
title: 'Validate',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [],
form: {
actionUrl: '',
fields: [
{ type: 'text', name: 'name', label: 'Name', required: true, pattern: '^[A-Za-z ]+$', maxLength: 30 },
{ type: 'email', name: 'email', label: 'Email', required: true },
{ type: 'textarea', name: 'msg', label: 'Message', required: false, maxLength: 200 },
],
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
},
seo: { title: 'Validate', description: 'desc' },
...overrides,
})
describe('Exporter - field validation attributes', () => {
it('text에 pattern/maxLength, textarea에 maxLength를 렌더링한다', () => {
const page = makePage()
const out = exportPage(page)
// text: pattern + maxlength
expect(out.html).toContain('name="name"')
expect(out.html).toContain('pattern="^[A-Za-z ]+$"')
expect(out.html).toContain('maxlength="30"')
// textarea: maxlength
expect(out.html).toContain('<textarea')
expect(out.html).toContain('name="msg"')
expect(out.html).toContain('maxlength="200"')
})
})
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
const makeFullPage = (): Page =>
pageSchema.parse({
title: 'Heading Levels All',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [
{ type: 'hero', props: { heading: 'Hero Title', subheading: 'Sub', imageUrl: 'https://example.com/hero.png' } },
{ type: 'features', props: { items: ['Feat A', 'Feat B'] } },
{ type: 'testimonials', props: { items: [{ author: 'Ada', quote: 'Nice' }] } },
{ type: 'faq', props: { items: [{ q: 'Q1', a: 'A1' }, { q: 'Q2', a: 'A2' }] } },
{ type: 'cta', props: { text: 'Try', href: '#' } },
] as Page['sections'],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 'SEO', description: 'Desc' },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
})
describe('Exporter heading level consistency (full)', () => {
test('hero=h1, others=h2, faq items=h3', () => {
const { html } = exportPage(makeFullPage())
// hero
expect(html).toContain('<h1 id="hero-heading">Hero Title</h1>')
// features/testimonials/faq/cta sections labeled by h2
expect(html).toContain('<h2 id="features-heading">')
expect(html).toContain('<section class="features" aria-labelledby="features-heading">')
expect(html).toContain('<h2 id="testimonials-heading">')
expect(html).toContain('<section class="testimonials" aria-labelledby="testimonials-heading">')
expect(html).toContain('<h2 id="faq-heading">')
expect(html).toContain('<section class="faq" aria-labelledby="faq-heading">')
expect(html).toContain('<h2 id="cta-heading">')
expect(html).toContain('<section class="cta" aria-labelledby="cta-heading">')
// faq items use h3
expect(html).toMatch(/<h3[^>]*>Q1<\/h3>/)
expect(html).toMatch(/<h3[^>]*>Q2<\/h3>/)
})
})
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
function makePage(): Page {
return pageSchema.parse({
title: 'My Landing',
locale: 'en',
theme: { primaryColor: '#222222', fontFamily: 'Inter' },
sections: [
{ type: 'hero', props: { heading: 'Welcome', subheading: 'Sub', imageUrl: 'https://example.com/hero.png' } },
{ type: 'features', props: { items: ['Fast', 'Reliable'] } },
{ type: 'testimonials', props: { items: [{ author: 'Jane', quote: 'Great' }] } },
{ type: 'faq', props: { items: [{ q: 'Q1', a: 'A1' }] } },
{ type: 'cta', props: { text: 'Start', href: '#' } },
] as Page['sections'],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
seo: { title: 'SEO', description: 'Desc' },
})
}
describe('Exporter heading levels', () => {
test('hero uses h1, sections use h2, faq questions use h3', () => {
const out = exportPage(makePage())
// hero h1
expect(out.html).toMatch(/<section class="hero"[\s\S]*<h1[\s\S]*>Welcome<\/h1>/)
// features/testimonials/faq/cta use h2 for section titles
expect(out.html).toContain('<h2 id="features-heading">')
expect(out.html).toContain('<h2 id="testimonials-heading">')
expect(out.html).toContain('<h2 id="faq-heading">')
expect(out.html).toContain('<h2 id="cta-heading">')
// faq item question headings h3
expect(out.html).toMatch(/<section class="faq"[\s\S]*<h3[\s\S]*>Q1<\/h3>/)
})
})
+278
View File
@@ -0,0 +1,278 @@
import type { Page, FormField } from '@/lib/schema/page'
import type { AssetManager } from '@/lib/assets/assetManager'
export type Exported = { html: string; css: string; js: string }
export type ExportOptions = { assetManager?: AssetManager }
function renderFaq(section: { props: { items: Array<{ q: string; a: string }> } }) {
const items: Array<{ q: string; a: string }> = Array.isArray(section.props.items) ? section.props.items : []
return `
<section class="faq" aria-labelledby="faq-heading">
<div class="container">
<h2 id="faq-heading">FAQ</h2>
<ul class="faq-list">
${items
.map(
(it) => `
<li class="faq-item">
<h3 class="faq-q">${escapeHtml(it.q)}</h3>
<p class="faq-a">${escapeHtml(it.a)}</p>
</li>`
)
.join('')}
</ul>
</div>
</section>
`
}
function renderFeatures(section: { props: { items: string[] } }) {
const items: string[] = Array.isArray(section.props?.items) ? section.props.items : []
return `
<section class="features" aria-labelledby="features-heading">
<div class="container">
<h2 id="features-heading">Features</h2>
<ul class="features-list">
${items.map((it) => `<li class="feature-item">${escapeHtml(String(it))}</li>`).join('')}
</ul>
</div>
</section>
`
}
function renderTestimonials(section: { props: { items: Array<{ author: string; quote: string }> } }) {
const items: Array<{ author: string; quote: string }> = Array.isArray(section.props?.items)
? section.props.items
: []
return `
<section class="testimonials" aria-labelledby="testimonials-heading">
<div class="container">
<h2 id="testimonials-heading">Testimonials</h2>
${items
.map(
(it) => `
<figure class="testimonial">
<blockquote>“${escapeHtml(it.quote || '')}”</blockquote>
<figcaption>— ${escapeHtml(it.author || '')}</figcaption>
</figure>`
)
.join('')}
</div>
</section>
`
}
function renderCta(section: { props: { text: string; href?: string } }) {
const { text, href } = section.props
return `
<section class="cta" aria-labelledby="cta-heading">
<div class="container">
<h2 id="cta-heading">CTA</h2>
<a class="btn-cta" href="${escapeHtml(href || '#')}">${escapeHtml(text)}</a>
</div>
</section>
`
}
const escapeHtml = (s: string) =>
s
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
function renderHero(section: { props: { heading: string; subheading?: string; imageUrl: string } }, opts?: ExportOptions) {
const { heading, subheading } = section.props
const am = opts?.assetManager
const imageUrlRaw: string = section.props.imageUrl
const imageUrl = am ? am.rewriteUrl(imageUrlRaw) : imageUrlRaw
return `
<section class="hero" aria-labelledby="hero-heading">
<div class="container">
<div class="text">
<h1 id="hero-heading">${escapeHtml(heading)}</h1>
${subheading ? `<p>${escapeHtml(subheading)}</p>` : ''}
</div>
<div class="media">
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(heading)}" />
</div>
</div>
</section>
`
}
function renderField(field: FormField) {
const pat = 'pattern' in field && field.pattern ? ` pattern="${escapeHtml(field.pattern)}"` : ''
const max = 'maxLength' in field && field.maxLength ? ` maxlength="${field.maxLength}"` : ''
const common = `name="${escapeHtml(field.name)}" aria-label="${escapeHtml(field.label)}" ${field.required ? 'required' : ''}${pat}${max}`
switch (field.type) {
case 'text':
case 'email':
case 'tel':
case 'date':
return `<label>${escapeHtml(field.label)}<input type="${field.type}" ${common} /></label>`
case 'textarea':
return `<label>${escapeHtml(field.label)}<textarea ${common}></textarea></label>`
case 'checkbox':
return `<label><input type="checkbox" ${common} />${escapeHtml(field.label)}</label>`
case 'radio': {
const { options } = field as Extract<FormField, { type: 'radio' }>
return `
<fieldset>
<legend>${escapeHtml(field.label)}</legend>
${options
.map(
(opt: string) =>
`<label><input type="radio" ${common} value="${escapeHtml(
opt
)}" />${escapeHtml(opt)}</label>`
)
.join('')}
</fieldset>
`
}
case 'select': {
const { options } = field as Extract<FormField, { type: 'select' }>
return `
<label>${escapeHtml(field.label)}
<select ${common}>
${options
.map((opt: string) => `<option value="${escapeHtml(opt)}">${escapeHtml(opt)}</option>`)
.join('')}
</select>
</label>
`
}
default:
return ''
}
}
function renderForm(page: Page) {
const { form } = page
const hasAction = !!(form.actionUrl && form.actionUrl.trim().length > 0)
const hp = form.spamProtection.honeypotFieldName
const dataFallback = hasAction ? '' : ' data-sheets-fallback="true"'
return `
<form method="post"${hasAction ? ` action="${escapeHtml(form.actionUrl!)}"` : ''}${dataFallback}>
<input type="hidden" name="${escapeHtml(hp)}" />
<input type="hidden" name="_ts" value="" />
${form.fields.map(renderField).join('\n')}
<button type="submit">Submit</button>
</form>
`
}
function buildHead(page: Page) {
const ogImage = page.seo.ogImage
const analyticsParts: string[] = []
const ga = page.analytics?.ga4MeasurementId
if (ga && ga.trim()) {
analyticsParts.push(`
<script async src="https://www.googletagmanager.com/gtag/js?id=${ga}"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${ga}');
</script>`)
}
const pixel = page.analytics?.metaPixelId
if (pixel && pixel.trim()) {
analyticsParts.push(`
<script>
!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;
n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', '${pixel}');
fbq('track', 'PageView');
</script>`)
}
const custom = page.analytics?.customHeadHtml || ''
return `
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(page.seo.title)}</title>
<meta name="description" content="${escapeHtml(page.seo.description)}" />
<meta property="og:title" content="${escapeHtml(page.seo.title)}" />
<meta property="og:description" content="${escapeHtml(page.seo.description)}" />
${ogImage ? `<meta property="og:image" content="${escapeHtml(ogImage)}" />` : ''}
${analyticsParts.join('\n')}
${custom}
`
}
function buildCss(page: Page) {
return `
:root{ --primary:${page.theme.primaryColor}; }
body{ font-family: ${page.theme.fontFamily}, system-ui, -apple-system, Segoe UI, Roboto, Noto Sans, Ubuntu, Cantarell, Helvetica Neue, Arial, "Apple Color Emoji","Segoe UI Emoji"; margin:0; color:#0f172a }
a.skip-link{ position:absolute; left:-9999px; top:auto; width:1px; height:1px; overflow:hidden }
a.skip-link:focus{ position:absolute; left:1rem; top:1rem; width:auto; height:auto; padding:.5rem 1rem; background:#111827; color:#fff; border-radius:.375rem; z-index:1000 }
.container{ max-width: 960px; margin: 0 auto; padding: 2rem }
.hero .text{ margin-bottom: 1rem }
img{ max-width: 100%; height: auto }
label{ display:block; margin: .5rem 0 }
button{ background: var(--primary); color:#fff; border:none; padding:.75rem 1rem; border-radius:.5rem }
button:focus, a:focus, input:focus, select:focus, textarea:focus{ outline: 3px solid #2563eb; outline-offset:2px }
.faq-list{ list-style:none; padding:0; margin:0 }
.faq-item{ margin: .75rem 0 }
.faq-q{ font-size: 1rem; font-weight:600; }
.faq-a{ color:#334155 }
.btn-cta{ background: var(--primary); color:#fff; padding:.75rem 1rem; border-radius:.5rem; display:inline-block; text-decoration:none }
.features{ background:#f8fafc }
.features-list{ list-style:none; padding:0; margin:0; display:grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: .75rem }
.feature-item{ background:#fff; border:1px solid #e2e8f0; border-radius:.5rem; padding:.75rem }
.testimonials{ background:#ffffff }
.testimonial{ border-left:4px solid var(--primary); padding:.5rem 1rem; margin: .75rem 0; background:#fefefe }
.testimonial blockquote{ margin:0; font-style:italic; color:#1f2937 }
.testimonial figcaption{ color:#475569; margin-top:.25rem }
@media (max-width: 640px){ .container{ padding: 1rem } h1{ font-size: 1.5rem } }
@media (prefers-reduced-motion: reduce){ *{ transition: none !important; animation: none !important } }
`
}
function buildJs() {
// timestamp 채우기(스팸 방지용 제출 지연 체크용 데이터)
return `
(function(){
var ts = document.querySelector('input[name="_ts"]');
if(ts){ ts.value = String(Date.now()); }
})();
`
}
export function exportPage(page: Page, opts?: ExportOptions): Exported {
const sections = page.sections
.map((s: Page['sections'][number]) => {
if (s.type === 'hero') return renderHero(s, opts)
if (s.type === 'faq') return renderFaq(s)
if (s.type === 'cta') return renderCta(s)
if (s.type === 'features') return renderFeatures(s)
if (s.type === 'testimonials') return renderTestimonials(s)
return ''
})
.join('\n')
const formHtml = renderForm(page)
const html = `<!DOCTYPE html>
<html lang="${escapeHtml(page.locale)}">
<head>
${buildHead(page)}
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
</head>
<body>
<a href="#main" class="skip-link">Skip to content</a>
<main id="main" role="main">
${sections}
${formHtml}
</main>
<script>${buildJs()}</script>
<style>${buildCss(page)}</style>
</body>
</html>`
return { html, css: buildCss(page), js: buildJs() }
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest'
import { pageSchema, type Page } from '@/lib/schema/page'
import { exportPage } from '@/lib/exporter/html'
import { AssetManager } from '@/lib/assets/assetManager'
const makeLocalImagePage = async () => {
const am = new AssetManager({ maxBytes: 1024 * 1024, allowedExts: ['png'] })
const hero = await am.add({ name: 'hero.png', type: 'image/png', bytes: new Uint8Array([1,2,3]) })
const page: Page = pageSchema.parse({
title: 'Local Image',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [
{
type: 'hero',
props: { heading: 'Hi', subheading: '', imageUrl: `local:${hero.originalName}` },
},
],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 'Local Image', description: 'd' },
})
return { am, page }
}
describe('HTML Exporter - asset rewrite', () => {
it('local: 프리픽스 이미지는 assets/<hash>.<ext>로 치환된다', async () => {
const { am, page } = await makeLocalImagePage()
const out = exportPage(page, { assetManager: am })
// assets 경로가 HTML 내 포함되어야 함
expect(out.html).toMatch(/assets\/[a-f0-9]{8}\.png/)
})
})
+72
View File
@@ -0,0 +1,72 @@
import { describe, it, expect } from 'vitest'
import { pageSchema, type Page } from '@/lib/schema/page'
import { exportPage } from '@/lib/exporter/html'
const makeSamplePage = (): Page =>
pageSchema.parse({
title: 'Sample Landing',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [
{
type: 'hero',
props: {
heading: 'Welcome',
subheading: 'Build fast',
imageUrl: 'https://cdn.example.com/hero.png',
},
},
],
form: {
actionUrl: '',
fields: [
{ type: 'text', name: 'name', label: 'Name', required: true },
{ type: 'email', name: 'email', label: 'Email', required: true },
{ type: 'checkbox', name: 'agree', label: 'Agree', required: true },
],
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
},
analytics: { ga4MeasurementId: '', metaPixelId: '' },
seo: { title: 'Sample Landing', description: 'desc', ogImage: 'https://cdn.example.com/og.png' },
})
describe('HTML Exporter', () => {
it('페이지를 정적 HTML/CSS/JS로 내보낸다', () => {
const page = makeSamplePage()
const out = exportPage(page)
expect(typeof out.html).toBe('string')
expect(typeof out.css).toBe('string')
expect(typeof out.js).toBe('string')
// 기본 요소 포함
expect(out.html).toContain('<!DOCTYPE html>')
expect(out.html).toContain('<html lang="en">')
expect(out.html).toContain('<title>Sample Landing</title>')
// OG/SEO
expect(out.html).toContain('meta property="og:title"')
expect(out.html).toContain('meta property="og:image"')
// Hero 섹션 렌더링
expect(out.html).toContain('Welcome')
expect(out.html).toContain('Build fast')
expect(out.html).toContain('https://cdn.example.com/hero.png')
// Form 렌더링 및 스팸방지 요소
expect(out.html).toContain('name="name"')
expect(out.html).toContain('name="email"')
expect(out.html).toContain('name="agree"')
expect(out.html).toContain('type="hidden"') // honeypot 또는 타임스탬프
// actionUrl 미지정이면 data-hint 포함(향후 Sheets 연결 힌트)
expect(out.html).toContain('data-sheets-fallback="true"')
})
it('파일 input은 생성하지 않는다', () => {
const page = makeSamplePage()
const out = exportPage(page)
expect(out.html).not.toMatch(/type=\"file\"/)
})
})
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
describe('Exporter - OG image meta', () => {
test('includes og:image meta when seo.ogImage is provided', () => {
const page: Page = pageSchema.parse({
title: 'T',
locale: 'en',
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
sections: [],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
seo: { title: 'SEO', description: 'Desc', ogImage: 'https://example.com/og.png' },
})
const out = exportPage(page)
expect(out.html).toContain('<meta property="og:image" content="https://example.com/og.png" />')
})
test('omits og:image meta when seo.ogImage is empty', () => {
const page: Page = pageSchema.parse({
title: 'T',
locale: 'en',
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
sections: [],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
seo: { title: 'SEO', description: 'Desc' },
})
const out = exportPage(page)
expect(out.html).not.toContain('og:image')
})
})
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
const makePage = (): Page =>
pageSchema.parse({
title: 'Snapshot',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [
{ type: 'hero', props: { heading: 'Welcome', subheading: 'Sub', imageUrl: 'https://example.com/hero.png' } },
{ type: 'features', props: { items: ['One', 'Two'] } },
{ type: 'testimonials', props: { items: [{ author: 'Jane', quote: 'Great' }] } },
{ type: 'faq', props: { items: [{ q: 'Q1', a: 'A1' }] } },
{ type: 'cta', props: { text: 'Go', href: '#' } },
] as Page['sections'],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 'SEO', description: 'Desc' },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
})
describe('Exporter regression snapshot (markers only)', () => {
test('combined markers for headings/aria/responsive exist', () => {
const out = exportPage(makePage())
const { html, css } = out
// headings/aria markers
expect(html).toContain('<h1 id="hero-heading">Welcome</h1>')
expect(html).toContain('<section class="features" aria-labelledby="features-heading">')
expect(html).toContain('<h2 id="features-heading">')
expect(html).toContain('<section class="testimonials" aria-labelledby="testimonials-heading">')
expect(html).toContain('<h2 id="testimonials-heading">')
expect(html).toContain('<section class="faq" aria-labelledby="faq-heading">')
expect(html).toContain('<h2 id="faq-heading">')
expect(html).toContain('<section class="cta" aria-labelledby="cta-heading">')
expect(html).toContain('<h2 id="cta-heading">')
// responsive/a11y CSS markers
expect(css).toMatch(/@media \(max-width: 640px\)/)
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)/)
expect(css).toMatch(/a\.skip-link:focus/)
expect(css).toMatch(/outline:\s*3px\s*solid/)
})
})
@@ -0,0 +1,48 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
function makeBase(overrides: Partial<Page>): Page {
const base: Page = {
title: 'T',
locale: 'en',
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
sections: [],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
seo: { title: 'SEO', description: 'Desc' },
}
return pageSchema.parse({ ...base, ...overrides })
}
describe('Exporter - Features and Testimonials sections', () => {
test('renders features list with items and classes', () => {
const sections = ([
{ type: 'features', props: { items: ['One', 'Two', 'Three'] } },
] as unknown) as Page['sections']
const page = makeBase({ sections })
const out = exportPage(page)
expect(out.html).toContain('<section class="features" aria-labelledby="features-heading">')
expect(out.html).toContain('<ul class="features-list">')
expect(out.html).toContain('<li class="feature-item">One</li>')
expect(out.html).toContain('<li class="feature-item">Two</li>')
expect(out.html).toContain('<li class="feature-item">Three</li>')
})
test('renders testimonials with figure/blockquote/figcaption structure', () => {
const sections = ([
{ type: 'testimonials', props: { items: [
{ author: 'Jane', quote: 'Great!' },
{ author: 'John', quote: 'Nice.' },
] } },
] as unknown) as Page['sections']
const page = makeBase({ sections })
const out = exportPage(page)
expect(out.html).toContain('<section class="testimonials" aria-labelledby="testimonials-heading">')
expect(out.html).toContain('<figure class="testimonial">')
expect(out.html).toContain('<blockquote>“Great!”</blockquote>')
expect(out.html).toContain('<figcaption>— Jane</figcaption>')
expect(out.html).toContain('<blockquote>“Nice.”</blockquote>')
expect(out.html).toContain('<figcaption>— John</figcaption>')
})
})
+34
View File
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest'
import { pageSchema, type Page } from '@/lib/schema/page'
import { exportPage } from '@/lib/exporter/html'
const makePage = (): Page =>
pageSchema.parse({
title: 'Sections',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [
{ type: 'faq', props: { items: [ { q: 'Q1?', a: 'A1.' }, { q: 'Q2?', a: 'A2.' } ] } },
{ type: 'cta', props: { text: 'Get Started', href: '#start' } },
],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 'Sections', description: 'desc' },
})
describe('Exporter - FAQ/CTA sections', () => {
it('FAQ와 CTA 섹션을 렌더링한다', () => {
const page = makePage()
const out = exportPage(page)
// FAQ
expect(out.html).toContain('Q1?')
expect(out.html).toContain('A1.')
expect(out.html).toContain('Q2?')
expect(out.html).toContain('A2.')
// CTA
expect(out.html).toContain('Get Started')
expect(out.html).toContain('#start')
})
})
@@ -0,0 +1,29 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
const makePage = (): Page =>
pageSchema.parse({
title: 'A11y Tokens',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 'SEO', description: 'Desc' },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
})
describe('A11y color/typography token smoke', () => {
test('base text color, font-family and mobile h1 size markers exist', () => {
const { css } = exportPage(makePage())
// base text color token presence
expect(css.replace(/\s+/g, ' ')).toMatch(/body\{[^}]*color:\s*#0f172a/i)
// font-family token (contains "Inter" and common fallbacks)
expect(css).toMatch(/body\{[^}]*font-family:\s*Inter/i)
// mobile h1 size within max-width:640px media query
expect(css).toMatch(/@media \(max-width: 640px\)[\s\S]*h1\{[^}]*font-size:\s*1\.5rem/i)
})
})
+31
View File
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest'
import JSZip from 'jszip'
import { buildZip } from '@/lib/exporter/zip'
const textEncoder = new TextEncoder()
describe('ZIP 유틸', () => {
it('index.html / styles.css / app.js 와 assets/* 를 포함한 ZIP을 생성한다', async () => {
const html = '<!DOCTYPE html><html><head><title>x</title></head><body>hi</body></html>'
const css = 'body{color:#000}'
const js = 'console.log(1)'
const assets: Record<string, Uint8Array> = {
'assets/a1.png': new Uint8Array([1,2,3]),
'assets/b2.svg': textEncoder.encode('<svg></svg>'),
}
const zipBytes = await buildZip({ html, css, js, assets })
const zip = await JSZip.loadAsync(zipBytes)
const names = Object.keys(zip.files)
expect(names).toContain('index.html')
expect(names).toContain('styles.css')
expect(names).toContain('app.js')
expect(names).toContain('assets/a1.png')
expect(names).toContain('assets/b2.svg')
const htmlContent = await zip.files['index.html'].async('string')
expect(htmlContent).toContain('<title>x</title>')
})
})
+24
View File
@@ -0,0 +1,24 @@
import JSZip from 'jszip'
export type BuildZipInput = {
html: string
css: string
js: string
assets?: Record<string, Uint8Array>
}
export async function buildZip(input: BuildZipInput): Promise<Uint8Array> {
const zip = new JSZip()
zip.file('index.html', input.html)
zip.file('styles.css', input.css)
zip.file('app.js', input.js)
if (input.assets) {
for (const [path, bytes] of Object.entries(input.assets)) {
// In Node/Vitest, use Buffer for robust binary handling
const buf = Buffer.from(bytes)
zip.file(path, buf as unknown as Buffer)
}
}
const content = await zip.generateAsync({ type: 'uint8array' })
return content
}
+8
View File
@@ -0,0 +1,8 @@
import 'server-only'
import { locales, type AppLocale } from '@/lib/i18n/locales'
export async function getMessages(locale: AppLocale) {
const loc = locales.includes(locale) ? locale : 'en'
const messages = (await import(`@/messages/${loc}.json`)).default
return messages as Record<string, string>
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest'
import { createI18n } from '@/lib/i18n'
describe('i18n util', () => {
const i18n = createI18n({
defaultLocale: 'en',
messages: {
en: { hello: 'Hello {name}', onlyEn: 'Only EN' },
ko: { hello: '안녕하세요 {name}' },
},
})
it('로케일별 번역과 변수 치환을 지원한다', () => {
expect(i18n.t('en', 'hello', { name: 'Jay' })).toBe('Hello Jay')
expect(i18n.t('ko', 'hello', { name: '제이' })).toBe('안녕하세요 제이')
})
it('키가 없으면 기본 로케일로 폴백한다', () => {
expect(i18n.t('ko', 'onlyEn')).toBe('Only EN')
})
it('완전히 없는 키는 키 그대로 반환한다', () => {
expect(i18n.t('en', 'missing.key')).toBe('missing.key')
})
})
+23
View File
@@ -0,0 +1,23 @@
type Messages = Record<string, Record<string, string>>
export function createI18n({ defaultLocale, messages }: { defaultLocale: string; messages: Messages }) {
function format(template: string, params?: Record<string, string | number>) {
if (!params) return template
return template.replace(/\{(\w+)\}/g, (_, k) => (params[k] !== undefined ? String(params[k]) : `{${k}}`))
}
function getMessage(locale: string, key: string): string | undefined {
const m = messages[locale] || {}
if (m[key] !== undefined) return m[key]
const base = messages[defaultLocale] || {}
return base[key]
}
function t(locale: string, key: string, params?: Record<string, string | number>): string {
const msg = getMessage(locale, key)
if (typeof msg === 'string') return format(msg, params)
return key
}
return { t }
}
+3
View File
@@ -0,0 +1,3 @@
export const locales = ['en', 'ko'] as const
export type AppLocale = typeof locales[number]
export const defaultLocale: AppLocale = 'en'
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest'
import { generateAppsScriptTemplate } from '@/lib/integrations/sheets'
describe('Google Sheets Apps Script 템플릿', () => {
it('시트 ID와 허용 오리진으로 Apps Script 코드와 가이드를 생성한다', () => {
const out = generateAppsScriptTemplate({
sheetId: '1AbcDEFghiJKL_sheet_id',
allowedOrigins: ['https://example.com', 'http://localhost:3000'],
minSubmitSeconds: 2,
honeypotFieldName: '_hp',
sheetName: 'Responses',
})
expect(typeof out.code).toBe('string')
expect(typeof out.readme).toBe('string')
// 필수 키워드 포함 검증
expect(out.code).toContain('function doPost(e)')
expect(out.code).toContain('SpreadsheetApp.openById')
expect(out.code).toContain('e.postData')
expect(out.code).toContain('ContentService.createTextOutput')
expect(out.code).toContain('Access-Control-Allow-Origin')
// 환경설정 치환값 반영
expect(out.code).toContain('1AbcDEFghiJKL_sheet_id')
expect(out.code).toContain('Responses')
expect(out.code).toContain('_hp')
expect(out.code).toContain('minSubmitSeconds')
// 배포 가이드에 웹 앱 배포 안내 포함
expect(out.readme).toMatch(/Deploy.*as Web app/i)
})
})
+132
View File
@@ -0,0 +1,132 @@
type TemplateParams = {
sheetId: string
allowedOrigins: string[]
minSubmitSeconds: number
honeypotFieldName: string
sheetName?: string
}
export function generateAppsScriptTemplate(params: TemplateParams) {
const { sheetId, allowedOrigins, minSubmitSeconds, honeypotFieldName, sheetName = 'Responses' } = params
const code = `/**
* Apps Script Web App for accepting HTML form submissions and writing to Google Sheets.
* Deployed as Web App with Anyone (even anonymous) or Anyone within domain access, as needed.
*/
const CONFIG = {
SHEET_ID: '${sheetId}',
SHEET_NAME: '${sheetName}',
ALLOWED_ORIGINS: ${JSON.stringify(allowedOrigins)},
minSubmitSeconds: ${minSubmitSeconds},
HONEYPOT: '${honeypotFieldName}',
};
function doOptions(e) {
return corsResponse('', 204);
}
function doPost(e) {
try {
const origin = (e?.parameter?.origin) || (e?.headers && e.headers['Origin']) || '';
const now = Date.now();
const params = parseBody(e);
// CORS check
enforceCors(origin);
// Spam protection: honeypot must be empty
if (params[CONFIG.HONEYPOT]) {
return corsResponse(JSON.stringify({ ok: true }), 200, origin); // pretend success
}
// Spam protection: minimum submit time
const ts = Number(params['_ts'] || 0);
if (!isFinite(ts) || now - ts < CONFIG.minSubmitSeconds * 1000) {
return corsResponse(JSON.stringify({ ok: true }), 200, origin); // pretend success
}
// Write to sheet
const ss = SpreadsheetApp.openById(CONFIG.SHEET_ID);
const sh = ss.getSheetByName(CONFIG.SHEET_NAME) || ss.getSheets()[0];
const entries = Object.keys(params).filter(k => k !== CONFIG.HONEYPOT);
const values = entries.map(k => String(params[k]));
sh.appendRow([new Date()].concat(values));
return corsResponse(JSON.stringify({ ok: true }), 200, origin);
} catch (err) {
return corsResponse(JSON.stringify({ ok: false, error: String(err) }), 500);
}
}
function parseBody(e) {
// Accept form-urlencoded or JSON
if (e?.postData?.type === 'application/json') {
try { return JSON.parse(e.postData.contents || '{}'); } catch (_) { return {}; }
}
// Parse form-urlencoded
const raw = (e?.postData?.contents || '');
const out = {};
raw.split('&').forEach(p => {
const [k, v] = p.split('=');
if (k) out[decodeURIComponent(k)] = decodeURIComponent((v || '').replace(/\+/g, ' '));
});
// Also include e.parameter for safety
if (e?.parameter) {
Object.keys(e.parameter).forEach(k => { if (!(k in out)) out[k] = e.parameter[k]; });
}
return out;
}
function enforceCors(origin) {
if (!CONFIG.ALLOWED_ORIGINS || CONFIG.ALLOWED_ORIGINS.length === 0) return;
if (CONFIG.ALLOWED_ORIGINS.indexOf(origin) === -1) {
throw new Error('CORS: Origin not allowed: ' + origin);
}
}
function corsResponse(body, status, origin) {
const output = ContentService.createTextOutput(body).setMimeType(ContentService.MimeType.JSON);
const resp = HtmlService.createHtmlOutput().getResponse();
resp.setContent(output.getContent());
resp.setResponseCode(status || 200);
resp.setHeader('Access-Control-Allow-Origin', origin || '*');
resp.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
resp.setHeader('Access-Control-Allow-Headers', 'Content-Type');
return resp;
}
`;
const readme = `# Google Apps Script Web App (Google Sheets 저장)
이 스크립트는 HTML 폼 제출을 받아 Google 스프레드시트에 행을 추가합니다.
## 준비물
- Google 스프레드시트 생성 후 시트 ID 확인(주소창의 /d/ 와 /edit 사이)
- Apps Script 에디터 열기(스프레드시트 -> 확장 프로그램 -> Apps Script)
## 사용 방법
1. 새 프로젝트 생성 후, Code.gs 에 아래 코드 붙여넣기
2. CONFIG 내 값을 편집
- SHEET_ID: ${sheetId}
- SHEET_NAME: ${sheetName}
- ALLOWED_ORIGINS: ${JSON.stringify(allowedOrigins)}
- MIN_SUBMIT_SECONDS: ${minSubmitSeconds}
- HONEYPOT: ${honeypotFieldName}
3. 배포
- 상단 메뉴: Deploy -> Deploy as Web app
- 새 배포 -> Entry point: doPost
- Access: Anyone (권장) 또는 필요 정책에 맞게 설정
- Deploy를 눌러 Web app URL 획득
4. 빌더에서 폼 action URL 에 위 Web app URL 입력
## CORS 및 스팸 방지
- Access-Control-Allow-Origin 헤더가 설정됩니다.
- 허용 오리진만 통과하도록 enforceCors를 사용합니다.
- honeypot 필드와 제출 타임스탬프(_ts)로 기본 스팸 방지 동작을 합니다.
## 테스트
- doPost, SpreadsheetApp 관련 호출은 Apps Script 런타임에서 동작합니다.
`
return { code, readme }
}
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest'
// TDD: 아직 구현되지 않은 스키마를 가정
import { pageSchema } from '@/lib/schema/page'
describe('pageSchema', () => {
it('유효한 랜딩 페이지 스키마를 통과시킨다', () => {
const valid = {
title: 'My Landing',
locale: 'en',
theme: {
primaryColor: '#0ea5e9',
fontFamily: 'Inter',
},
sections: [
{
type: 'hero',
props: {
heading: 'Hello',
subheading: 'World',
imageUrl: 'https://cdn.example.com/hero.png',
},
},
],
form: {
actionUrl: '', // 빈 문자열이면 Sheets로 제출(향후 로직)
fields: [
{ type: 'text', name: 'name', label: 'Name', required: true },
{ type: 'email', name: 'email', label: 'Email', required: true },
{ type: 'checkbox', name: 'agree', label: 'Agree', required: true },
],
spamProtection: {
honeypotFieldName: '_hp',
minSubmitSeconds: 2,
},
},
analytics: {
ga4MeasurementId: '',
metaPixelId: '',
customHeadHtml: '',
},
seo: {
title: 'My Landing',
description: 'Best product',
ogImage: 'https://cdn.example.com/og.png',
},
}
const parsed = pageSchema.parse(valid)
expect(parsed.title).toBe('My Landing')
expect(parsed.sections.length).toBe(1)
})
it('파일 업로드 필드는 허용하지 않는다', () => {
const invalid = {
title: 'No File Please',
locale: 'en',
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
sections: [],
form: {
actionUrl: '',
fields: [
// 파일 타입은 금지
{ type: 'file', name: 'attachment', label: 'Upload' },
],
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
},
analytics: {},
seo: { title: 'x', description: '', ogImage: '' },
}
expect(() => pageSchema.parse(invalid)).toThrow()
})
})
+118
View File
@@ -0,0 +1,118 @@
import { z } from 'zod'
const hexColor = z
.string()
.regex(/^#([0-9a-fA-F]{3}){1,2}$/i, 'invalid hex color')
const url = z.string().url()
const sectionHeroSchema = z.object({
type: z.literal('hero'),
props: z.object({
heading: z.string().min(1),
subheading: z.string().optional().default(''),
imageUrl: url,
}),
})
const sectionFaqSchema = z.object({
type: z.literal('faq'),
props: z.object({
items: z.array(z.object({ q: z.string().min(1), a: z.string().min(1) })).min(1),
}),
})
const sectionCtaSchema = z.object({
type: z.literal('cta'),
props: z.object({ text: z.string().min(1), href: z.string().optional().default('#') }),
})
const sectionFeaturesSchema = z.object({
type: z.literal('features'),
props: z.object({ items: z.array(z.string().min(1)).min(1) }),
})
const sectionTestimonialsSchema = z.object({
type: z.literal('testimonials'),
props: z.object({ items: z.array(z.object({ author: z.string().min(1), quote: z.string().min(1) })).min(1) }),
})
// 확장: hero, faq, cta, features, testimonials 허용
const sectionSchema = z.union([
sectionHeroSchema,
sectionFaqSchema,
sectionCtaSchema,
sectionFeaturesSchema,
sectionTestimonialsSchema,
])
const baseField = {
name: z.string().min(1),
label: z.string().min(1),
required: z.boolean().optional().default(false),
pattern: z.string().optional(),
maxLength: z.number().int().positive().optional(),
}
const textField = z.object({ type: z.literal('text'), ...baseField })
const emailField = z.object({ type: z.literal('email'), ...baseField })
const telField = z.object({ type: z.literal('tel'), ...baseField })
const textareaField = z.object({ type: z.literal('textarea'), ...baseField })
const dateField = z.object({ type: z.literal('date'), ...baseField })
const checkboxField = z.object({ type: z.literal('checkbox'), ...baseField })
const radioField = z.object({
type: z.literal('radio'),
...baseField,
options: z.array(z.string().min(1)).min(1),
})
const selectField = z.object({
type: z.literal('select'),
...baseField,
options: z.array(z.string().min(1)).min(1),
})
// 파일 업로드는 제외
export const formFieldSchema = z.union([
textField,
emailField,
telField,
textareaField,
dateField,
checkboxField,
radioField,
selectField,
])
export const pageSchema = z.object({
title: z.string().min(1),
locale: z.string().min(2),
theme: z.object({
primaryColor: hexColor,
fontFamily: z.string().min(1),
}),
sections: z.array(sectionSchema),
form: z.object({
actionUrl: z.string().optional().default(''),
fields: z.array(formFieldSchema).min(0),
spamProtection: z.object({
honeypotFieldName: z.string().min(1).default('_hp'),
minSubmitSeconds: z.number().min(0).default(2),
}),
}),
analytics: z
.object({
ga4MeasurementId: z.string().optional().default(''),
metaPixelId: z.string().optional().default(''),
customHeadHtml: z.string().optional().default(''),
})
.optional()
.default({ ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' }),
seo: z.object({
title: z.string().min(1),
description: z.string().min(1),
ogImage: url.optional(),
}),
})
export type Page = z.infer<typeof pageSchema>
export type FormField = z.infer<typeof formFieldSchema>
+47
View File
@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest'
import { createFormStore, type FormFieldInput, type FormState, type FormField } from '@/lib/state/formStore'
const text = (name: string, label = name): FormFieldInput => ({ type: 'text', name, label, required: false })
describe('form store', () => {
it('필드 추가/삭제/이동/업데이트와 action URL, spam 옵션을 관리한다', () => {
const useForm = createFormStore()
// 초기
let s: FormState = useForm.getState()
expect(s.fields.length).toBe(0)
expect(s.actionUrl).toBe('')
expect(s.spam.honeypotFieldName).toBe('_hp')
// 추가
useForm.getState().addField(text('name', 'Name'))
useForm.getState().addField(text('email', 'Email'))
useForm.getState().addField({ type: 'select', name: 'plan', label: 'Plan', required: true, options: ['A','B'] })
s = useForm.getState()
expect(s.fields.map((f: FormField) => f.name)).toEqual(['name','email','plan'])
// 이동 (0 -> 2)
useForm.getState().moveField(0, 2)
s = useForm.getState()
expect(s.fields.map((f: FormField) => f.name)).toEqual(['email','plan','name'])
// 업데이트
useForm.getState().updateField(1, { label: 'Pricing Plan', required: false })
s = useForm.getState()
expect(s.fields[1].label).toBe('Pricing Plan')
expect(s.fields[1].required).toBe(false)
// 삭제
useForm.getState().removeField(0)
s = useForm.getState()
expect(s.fields.map((f: FormField) => f.name)).toEqual(['plan','name'])
// action & spam
useForm.getState().setActionUrl('https://example.com/form')
useForm.getState().setSpam({ honeypotFieldName: '_trap', minSubmitSeconds: 3 })
s = useForm.getState()
expect(s.actionUrl).toBe('https://example.com/form')
expect(s.spam.honeypotFieldName).toBe('_trap')
expect(s.spam.minSubmitSeconds).toBe(3)
})
})
+69
View File
@@ -0,0 +1,69 @@
import { createStore } from 'zustand/vanilla'
export type BaseField = {
name: string
label: string
required?: boolean
pattern?: string
maxLength?: number
}
export type TextField = BaseField & { type: 'text' | 'email' | 'tel' | 'date' | 'textarea' }
export type CheckboxField = BaseField & { type: 'checkbox' }
export type RadioField = BaseField & { type: 'radio'; options: string[] }
export type SelectField = BaseField & { type: 'select'; options: string[] }
export type FormField = TextField | CheckboxField | RadioField | SelectField
export type FormFieldInput = FormField
export type FormState = {
fields: FormField[]
actionUrl: string
spam: { honeypotFieldName: string; minSubmitSeconds: number }
addField: (f: FormFieldInput) => void
moveField: (from: number, to: number) => void
removeField: (index: number) => void
updateField: (index: number, patch: Partial<FormField>) => void
setActionUrl: (url: string) => void
setSpam: (s: { honeypotFieldName?: string; minSubmitSeconds?: number }) => void
}
export function createFormStore() {
return createStore<FormState>((set) => ({
fields: [],
actionUrl: '',
spam: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
addField: (f) => set((st) => ({ fields: [...st.fields, f] })),
moveField: (from, to) =>
set((st) => {
const arr = [...st.fields]
if (from < 0 || from >= arr.length || to < 0 || to >= arr.length) return st
const [spliced] = arr.splice(from, 1)
arr.splice(to, 0, spliced)
return { fields: arr }
}),
removeField: (index) =>
set((st) => {
if (index < 0 || index >= st.fields.length) return st
const arr = st.fields.slice()
arr.splice(index, 1)
return { fields: arr }
}),
updateField: (index, patch) =>
set((st) => {
if (index < 0 || index >= st.fields.length) return st
const next = st.fields.slice()
next[index] = { ...next[index], ...patch } as FormField
return { fields: next }
}),
setActionUrl: (url) => set({ actionUrl: url }),
setSpam: (s) =>
set((st) => ({
spam: {
honeypotFieldName: s.honeypotFieldName ?? st.spam.honeypotFieldName,
minSubmitSeconds: s.minSubmitSeconds ?? st.spam.minSubmitSeconds,
},
})),
}))
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest'
import { createBuilderStore, type Section } from '@/lib/state/store'
describe('builder store (zustand)', () => {
it('섹션 추가/이동/삭제와 로케일 변경을 지원한다', () => {
const useStore = createBuilderStore()
const s1: Section = { type: 'hero', props: { heading: 'H1' } }
const s2: Section = { type: 'faq', props: { items: [] } }
const s3: Section = { type: 'cta', props: { text: 'Go' } }
// 초기 상태
expect(useStore.getState().locale).toBe('en')
expect(useStore.getState().sections.length).toBe(0)
// 추가
useStore.getState().addSection(s1)
useStore.getState().addSection(s2)
useStore.getState().addSection(s3)
expect(useStore.getState().sections.map(s => s.type)).toEqual(['hero','faq','cta'])
// 이동 (index 0 -> 2)
useStore.getState().moveSection(0, 2)
expect(useStore.getState().sections.map(s => s.type)).toEqual(['faq','cta','hero'])
// 삭제 (index 1: 'cta')
useStore.getState().removeSection(1)
expect(useStore.getState().sections.map(s => s.type)).toEqual(['faq','hero'])
// 로케일 변경
useStore.getState().setLocale('ko')
expect(useStore.getState().locale).toBe('ko')
})
})
+42
View File
@@ -0,0 +1,42 @@
import { createStore } from 'zustand/vanilla'
export type HeroSection = { type: 'hero'; props: { heading?: string; subheading?: string } }
export type FaqSection = { type: 'faq'; props: { items: Array<{ q: string; a: string }> } }
export type CtaSection = { type: 'cta'; props: { text: string; href?: string } }
export type FeaturesSection = { type: 'features'; props: { items: string[] } }
export type TestimonialsSection = { type: 'testimonials'; props: { items: Array<{ author: string; quote: string }> } }
export type Section = HeroSection | FaqSection | CtaSection | FeaturesSection | TestimonialsSection
export type BuilderState = {
locale: string
sections: Section[]
addSection: (s: Section) => void
moveSection: (from: number, to: number) => void
removeSection: (index: number) => void
setLocale: (loc: string) => void
}
export function createBuilderStore() {
return createStore<BuilderState>((set, get) => ({
locale: 'en',
sections: [],
addSection: (s) => set((st) => ({ sections: [...st.sections, s] })),
moveSection: (from, to) =>
set((st) => {
const arr = [...st.sections]
if (from < 0 || from >= arr.length || to < 0 || to >= arr.length) return st
const [spliced] = arr.splice(from, 1)
arr.splice(to, 0, spliced)
return { sections: arr }
}),
removeSection: (index) =>
set((st) => {
if (index < 0 || index >= st.sections.length) return st
const arr = st.sections.slice()
arr.splice(index, 1)
return { sections: arr }
}),
setLocale: (loc) => set({ locale: loc }),
}))
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest'
import { move } from './reorder'
describe('reorder.move', () => {
it('같은 배열 인스턴스를 변형하지 않고 from→to로 아이템을 이동한다', () => {
const src = ['a', 'b', 'c', 'd']
const out = move(src, 1, 3)
expect(src).toEqual(['a', 'b', 'c', 'd'])
expect(out).toEqual(['a', 'c', 'd', 'b'])
})
it('경계 밖 인덱스는 원본을 그대로 반환한다', () => {
const src = ['x', 'y']
expect(move(src, -1, 1)).toBe(src)
expect(move(src, 0, 5)).toBe(src)
expect(move(src, 2, 0)).toBe(src)
})
it('from과 to가 같으면 원본을 그대로 반환한다', () => {
const src = [1, 2, 3]
expect(move(src, 1, 1)).toBe(src)
})
})
+15
View File
@@ -0,0 +1,15 @@
export function move<T>(arr: T[], from: number, to: number): T[] {
if (
from === to ||
from < 0 ||
to < 0 ||
from >= arr.length ||
to >= arr.length
) {
return arr
}
const next = arr.slice()
const [sp] = next.splice(from, 1)
next.splice(to, 0, sp)
return next
}