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
+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>