test(ci): add exporter heading levels and a11y typography smoke; update MAIN_PLAN
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user