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,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
|
||||
}
|
||||
Reference in New Issue
Block a user