test(ci): add exporter heading levels and a11y typography smoke; update MAIN_PLAN
CI / test (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>/)
|
||||
})
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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">')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>')
|
||||
})
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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"')
|
||||
})
|
||||
})
|
||||
@@ -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"')
|
||||
})
|
||||
})
|
||||
@@ -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>/)
|
||||
})
|
||||
})
|
||||
@@ -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>/)
|
||||
})
|
||||
})
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
|
||||
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() }
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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\"/)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const locales = ['en', 'ko'] as const
|
||||
export type AppLocale = typeof locales[number]
|
||||
export const defaultLocale: AppLocale = 'en'
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})),
|
||||
}))
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 }),
|
||||
}))
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user