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
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
function makePage(): Page {
return pageSchema.parse({
title: 'T',
locale: 'en',
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
sections: [
{ type: 'hero', props: { heading: 'Welcome', subheading: '', imageUrl: 'https://example.com/hero.png' } },
{ type: 'faq', props: { items: [{ q: 'Q1', a: 'A1' }] } },
{ type: 'cta', props: { text: 'Go', href: '#' } },
] as Page['sections'],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
seo: { title: 'SEO', description: 'Desc' },
})
}
describe('Exporter a11y/structure - main contains sections and form', () => {
test('main landmark wraps sections and form markup', () => {
const out = exportPage(makePage())
expect(out.html).toContain('<main id="main" role="main">')
// sections inside main
expect(out.html).toMatch(/<main[\s\S]*<section class="hero"[\s\S]*<section class="faq"[\s\S]*<section class="cta"[\s\S]*<\/main>/)
// form inside main
expect(out.html).toMatch(/<main[\s\S]*<form method="post"[\s\S]*<\/main>/)
})
})
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
function makePage(overrides: Partial<Page> = {}): Page {
const base: Page = {
title: 'T',
locale: 'en',
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
sections: [
{ type: 'hero', props: { heading: 'Welcome', subheading: 'Sub', imageUrl: 'https://example.com/img.png' } },
{ type: 'faq', props: { items: [{ q: 'Q1', a: 'A1' }] } },
{ type: 'features', props: { items: ['One', 'Two'] } },
{ type: 'testimonials', props: { items: [{ author: 'Jane', quote: 'Great' }] } },
{ type: 'cta', props: { text: 'Go', href: '#' } },
] as Page['sections'],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
seo: { title: 'SEO', description: 'Desc' },
}
return pageSchema.parse({ ...base, ...overrides })
}
describe('Exporter a11y/responsive enhancements', () => {
test('includes skip link and main landmark', () => {
const out = exportPage(makePage())
expect(out.html).toContain('<a href="#main" class="skip-link">')
expect(out.html).toContain('<main id="main" role="main">')
})
test('sections use aria-labelledby with h2 ids', () => {
const out = exportPage(makePage())
expect(out.html).toContain('<section class="faq" aria-labelledby="faq-heading">')
expect(out.html).toContain('<h2 id="faq-heading">FAQ</h2>')
expect(out.html).toContain('<section class="features" aria-labelledby="features-heading">')
expect(out.html).toContain('<h2 id="features-heading">Features</h2>')
expect(out.html).toContain('<section class="testimonials" aria-labelledby="testimonials-heading">')
expect(out.html).toContain('<h2 id="testimonials-heading">Testimonials</h2>')
})
test('hero image alt derives from heading', () => {
const out = exportPage(makePage())
expect(out.html).toContain('<h1 id="hero-heading">Welcome</h1>')
expect(out.html).toContain('<img src="https://example.com/img.png" alt="Welcome" />')
})
test('responsive and a11y CSS tokens exist', () => {
const out = exportPage(makePage())
expect(out.css).toMatch(/@media \(max-width: 640px\)/)
expect(out.css).toMatch(/@media \(prefers-reduced-motion: reduce\)/)
expect(out.css).toMatch(/a\.skip-link:focus/)
expect(out.css).toMatch(/outline: 3px solid/)
})
})
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest'
import { pageSchema, type Page } from '@/lib/schema/page'
import { exportPage } from '@/lib/exporter/html'
describe('Analytics snippet injection', () => {
const base = {
title: 'Analytics Test',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 'Analytics Test', description: 'desc' },
}
it('GA4 measurement ID가 있으면 GA4 스니펫을 삽입한다', () => {
const page: Page = pageSchema.parse({
...base,
analytics: { ga4MeasurementId: 'G-ABC123', metaPixelId: '' },
})
const out = exportPage(page)
expect(out.html).toContain('https://www.googletagmanager.com/gtag/js?id=G-ABC123')
expect(out.html).toContain("gtag('config', 'G-ABC123')")
})
it('Meta Pixel ID가 있으면 Pixel 스니펫을 삽입한다', () => {
const page: Page = pageSchema.parse({
...base,
analytics: { ga4MeasurementId: '', metaPixelId: '1234567890' },
})
const out = exportPage(page)
expect(out.html).toContain('fbq(')
expect(out.html).toContain("fbq('init', '1234567890')")
})
it('customHeadHtml이 있으면 head에 그대로 삽입한다', () => {
const page: Page = pageSchema.parse({
...base,
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '<meta name="robots" content="noindex">' },
})
const out = exportPage(page)
expect(out.html).toContain('<meta name="robots" content="noindex">')
})
})
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
const makePage = (): Page =>
pageSchema.parse({
title: 'T',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 'T', description: 'D' },
})
describe('Exporter CSS contrast/font tokens', () => {
test('contains base text color and responsive font adjustments', () => {
const out = exportPage(makePage())
// base body text color token present
expect(out.css).toMatch(/body\{[^}]*color:\s*#0f172a/i)
// focus outline thickness and offset present
expect(out.css).toMatch(/outline:\s*3px\s*solid/i)
expect(out.css).toMatch(/outline-offset:\s*2px/i)
// responsive font size change for h1 in mobile media query
expect(out.css).toMatch(/@media \(max-width: 640px\)[\s\S]*h1\{[^}]*font-size:\s*1\.5rem/i)
})
})
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
function makePage(): Page {
return pageSchema.parse({
title: 'T',
locale: 'en',
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
sections: [],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
seo: { title: 'SEO', description: 'Desc' },
})
}
describe('Exporter CSS tokens', () => {
test('includes focus outline, skip-link, and responsive MQ tokens', () => {
const out = exportPage(makePage())
expect(out.css).toMatch(/a\.skip-link:focus/)
expect(out.css).toMatch(/focus-visible|outline: 3px solid|outline-offset/)
expect(out.css).toMatch(/@media \(max-width: 640px\)/)
expect(out.css).toMatch(/@media \(prefers-reduced-motion: reduce\)/)
})
})
@@ -0,0 +1,28 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
function makePage(): Page {
return pageSchema.parse({
title: 'T',
locale: 'en',
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
sections: [
{ type: 'features', props: { items: ['One', 'Two'] } },
{ type: 'testimonials', props: { items: [{ author: 'Jane', quote: 'Great' }] } },
] as Page['sections'],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
seo: { title: 'SEO', description: 'Desc' },
})
}
describe('Exporter - Features/Testimonials aria-labelledby', () => {
test('has aria-labelledby and matching heading ids', () => {
const out = exportPage(makePage())
expect(out.html).toContain('<section class="features" aria-labelledby="features-heading">')
expect(out.html).toContain('<h2 id="features-heading">Features</h2>')
expect(out.html).toContain('<section class="testimonials" aria-labelledby="testimonials-heading">')
expect(out.html).toContain('<h2 id="testimonials-heading">Testimonials</h2>')
})
})
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
const makePage = (): Page =>
pageSchema.parse({
title: 'Focus Tokens',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 'SEO', description: 'Desc' },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
})
describe('CSS focus-visible tokens smoke', () => {
test('outline uses 3px solid and hex color token', () => {
const { css } = exportPage(makePage())
expect(css).toMatch(/outline:\s*3px\s*solid/i)
// check presence of our hex color used for outline (example: #2563eb)
expect(css).toMatch(/outline:\s*3px\s*solid\s*#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})/)
// ensure focus-visible styles exist on common interactive elements
expect(css).toMatch(/button:focus|:focus-visible|a:focus|input:focus|textarea:focus|select:focus/)
})
})
+61
View File
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest'
import { pageSchema, type Page } from '@/lib/schema/page'
import { exportPage } from '@/lib/exporter/html'
const makePage = (withAction = false): Page =>
pageSchema.parse({
title: 'Form Test',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [],
form: {
actionUrl: withAction ? 'https://example.com/submit' : '',
fields: [
{ type: 'text', name: 'name', label: 'Name', required: true },
{ type: 'email', name: 'email', label: 'Email', required: true },
{ type: 'select', name: 'plan', label: 'Plan', required: false, options: ['A','B'] },
{ type: 'radio', name: 'size', label: 'Size', required: false, options: ['S','M','L'] },
{ type: 'checkbox', name: 'agree', label: 'Agree', required: true },
{ type: 'textarea', name: 'msg', label: 'Message', required: false },
],
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
},
seo: { title: 'Form Test', description: 'desc' },
})
describe('Exporter - form rendering', () => {
it('필드 라벨/required/옵션 및 스팸 방지 요소를 렌더링한다 (action 미지정)', () => {
const page = makePage(false)
const out = exportPage(page)
// 필수 required
expect(out.html).toContain('name="name"')
expect(out.html).toContain('required')
expect(out.html).toContain('name="email"')
// select/radio 옵션 존재
expect(out.html).toContain('<select')
expect(out.html).toContain('<option value="A">A</option>')
expect(out.html).toContain('<input type="radio"')
// checkbox/textarea
expect(out.html).toContain('type="checkbox"')
expect(out.html).toContain('<textarea')
// 스팸 방지: honeypot + timestamp
expect(out.html).toContain('type="hidden"')
expect(out.html).toContain('name="_hp"')
expect(out.html).toContain('name="_ts"')
// action 미지정 → Sheets fallback 힌트
expect(out.html).toContain('data-sheets-fallback="true"')
})
it('action URL이 있으면 form에 action 속성이 포함된다', () => {
const page = makePage(true)
const out = exportPage(page)
expect(out.html).toContain('action="https://example.com/submit"')
expect(out.html).not.toContain('data-sheets-fallback="true"')
})
})
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest'
import { pageSchema, type Page } from '@/lib/schema/page'
import { exportPage } from '@/lib/exporter/html'
const makePage = (overrides: Partial<Page> = {}): Page =>
pageSchema.parse({
title: 'Validate',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [],
form: {
actionUrl: '',
fields: [
{ type: 'text', name: 'name', label: 'Name', required: true, pattern: '^[A-Za-z ]+$', maxLength: 30 },
{ type: 'email', name: 'email', label: 'Email', required: true },
{ type: 'textarea', name: 'msg', label: 'Message', required: false, maxLength: 200 },
],
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
},
seo: { title: 'Validate', description: 'desc' },
...overrides,
})
describe('Exporter - field validation attributes', () => {
it('text에 pattern/maxLength, textarea에 maxLength를 렌더링한다', () => {
const page = makePage()
const out = exportPage(page)
// text: pattern + maxlength
expect(out.html).toContain('name="name"')
expect(out.html).toContain('pattern="^[A-Za-z ]+$"')
expect(out.html).toContain('maxlength="30"')
// textarea: maxlength
expect(out.html).toContain('<textarea')
expect(out.html).toContain('name="msg"')
expect(out.html).toContain('maxlength="200"')
})
})
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
const makeFullPage = (): Page =>
pageSchema.parse({
title: 'Heading Levels All',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [
{ type: 'hero', props: { heading: 'Hero Title', subheading: 'Sub', imageUrl: 'https://example.com/hero.png' } },
{ type: 'features', props: { items: ['Feat A', 'Feat B'] } },
{ type: 'testimonials', props: { items: [{ author: 'Ada', quote: 'Nice' }] } },
{ type: 'faq', props: { items: [{ q: 'Q1', a: 'A1' }, { q: 'Q2', a: 'A2' }] } },
{ type: 'cta', props: { text: 'Try', href: '#' } },
] as Page['sections'],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 'SEO', description: 'Desc' },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
})
describe('Exporter heading level consistency (full)', () => {
test('hero=h1, others=h2, faq items=h3', () => {
const { html } = exportPage(makeFullPage())
// hero
expect(html).toContain('<h1 id="hero-heading">Hero Title</h1>')
// features/testimonials/faq/cta sections labeled by h2
expect(html).toContain('<h2 id="features-heading">')
expect(html).toContain('<section class="features" aria-labelledby="features-heading">')
expect(html).toContain('<h2 id="testimonials-heading">')
expect(html).toContain('<section class="testimonials" aria-labelledby="testimonials-heading">')
expect(html).toContain('<h2 id="faq-heading">')
expect(html).toContain('<section class="faq" aria-labelledby="faq-heading">')
expect(html).toContain('<h2 id="cta-heading">')
expect(html).toContain('<section class="cta" aria-labelledby="cta-heading">')
// faq items use h3
expect(html).toMatch(/<h3[^>]*>Q1<\/h3>/)
expect(html).toMatch(/<h3[^>]*>Q2<\/h3>/)
})
})
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
function makePage(): Page {
return pageSchema.parse({
title: 'My Landing',
locale: 'en',
theme: { primaryColor: '#222222', fontFamily: 'Inter' },
sections: [
{ type: 'hero', props: { heading: 'Welcome', subheading: 'Sub', imageUrl: 'https://example.com/hero.png' } },
{ type: 'features', props: { items: ['Fast', 'Reliable'] } },
{ type: 'testimonials', props: { items: [{ author: 'Jane', quote: 'Great' }] } },
{ type: 'faq', props: { items: [{ q: 'Q1', a: 'A1' }] } },
{ type: 'cta', props: { text: 'Start', href: '#' } },
] as Page['sections'],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
seo: { title: 'SEO', description: 'Desc' },
})
}
describe('Exporter heading levels', () => {
test('hero uses h1, sections use h2, faq questions use h3', () => {
const out = exportPage(makePage())
// hero h1
expect(out.html).toMatch(/<section class="hero"[\s\S]*<h1[\s\S]*>Welcome<\/h1>/)
// features/testimonials/faq/cta use h2 for section titles
expect(out.html).toContain('<h2 id="features-heading">')
expect(out.html).toContain('<h2 id="testimonials-heading">')
expect(out.html).toContain('<h2 id="faq-heading">')
expect(out.html).toContain('<h2 id="cta-heading">')
// faq item question headings h3
expect(out.html).toMatch(/<section class="faq"[\s\S]*<h3[\s\S]*>Q1<\/h3>/)
})
})
+278
View File
@@ -0,0 +1,278 @@
import type { Page, FormField } from '@/lib/schema/page'
import type { AssetManager } from '@/lib/assets/assetManager'
export type Exported = { html: string; css: string; js: string }
export type ExportOptions = { assetManager?: AssetManager }
function renderFaq(section: { props: { items: Array<{ q: string; a: string }> } }) {
const items: Array<{ q: string; a: string }> = Array.isArray(section.props.items) ? section.props.items : []
return `
<section class="faq" aria-labelledby="faq-heading">
<div class="container">
<h2 id="faq-heading">FAQ</h2>
<ul class="faq-list">
${items
.map(
(it) => `
<li class="faq-item">
<h3 class="faq-q">${escapeHtml(it.q)}</h3>
<p class="faq-a">${escapeHtml(it.a)}</p>
</li>`
)
.join('')}
</ul>
</div>
</section>
`
}
function renderFeatures(section: { props: { items: string[] } }) {
const items: string[] = Array.isArray(section.props?.items) ? section.props.items : []
return `
<section class="features" aria-labelledby="features-heading">
<div class="container">
<h2 id="features-heading">Features</h2>
<ul class="features-list">
${items.map((it) => `<li class="feature-item">${escapeHtml(String(it))}</li>`).join('')}
</ul>
</div>
</section>
`
}
function renderTestimonials(section: { props: { items: Array<{ author: string; quote: string }> } }) {
const items: Array<{ author: string; quote: string }> = Array.isArray(section.props?.items)
? section.props.items
: []
return `
<section class="testimonials" aria-labelledby="testimonials-heading">
<div class="container">
<h2 id="testimonials-heading">Testimonials</h2>
${items
.map(
(it) => `
<figure class="testimonial">
<blockquote>“${escapeHtml(it.quote || '')}”</blockquote>
<figcaption>— ${escapeHtml(it.author || '')}</figcaption>
</figure>`
)
.join('')}
</div>
</section>
`
}
function renderCta(section: { props: { text: string; href?: string } }) {
const { text, href } = section.props
return `
<section class="cta" aria-labelledby="cta-heading">
<div class="container">
<h2 id="cta-heading">CTA</h2>
<a class="btn-cta" href="${escapeHtml(href || '#')}">${escapeHtml(text)}</a>
</div>
</section>
`
}
const escapeHtml = (s: string) =>
s
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
function renderHero(section: { props: { heading: string; subheading?: string; imageUrl: string } }, opts?: ExportOptions) {
const { heading, subheading } = section.props
const am = opts?.assetManager
const imageUrlRaw: string = section.props.imageUrl
const imageUrl = am ? am.rewriteUrl(imageUrlRaw) : imageUrlRaw
return `
<section class="hero" aria-labelledby="hero-heading">
<div class="container">
<div class="text">
<h1 id="hero-heading">${escapeHtml(heading)}</h1>
${subheading ? `<p>${escapeHtml(subheading)}</p>` : ''}
</div>
<div class="media">
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(heading)}" />
</div>
</div>
</section>
`
}
function renderField(field: FormField) {
const pat = 'pattern' in field && field.pattern ? ` pattern="${escapeHtml(field.pattern)}"` : ''
const max = 'maxLength' in field && field.maxLength ? ` maxlength="${field.maxLength}"` : ''
const common = `name="${escapeHtml(field.name)}" aria-label="${escapeHtml(field.label)}" ${field.required ? 'required' : ''}${pat}${max}`
switch (field.type) {
case 'text':
case 'email':
case 'tel':
case 'date':
return `<label>${escapeHtml(field.label)}<input type="${field.type}" ${common} /></label>`
case 'textarea':
return `<label>${escapeHtml(field.label)}<textarea ${common}></textarea></label>`
case 'checkbox':
return `<label><input type="checkbox" ${common} />${escapeHtml(field.label)}</label>`
case 'radio': {
const { options } = field as Extract<FormField, { type: 'radio' }>
return `
<fieldset>
<legend>${escapeHtml(field.label)}</legend>
${options
.map(
(opt: string) =>
`<label><input type="radio" ${common} value="${escapeHtml(
opt
)}" />${escapeHtml(opt)}</label>`
)
.join('')}
</fieldset>
`
}
case 'select': {
const { options } = field as Extract<FormField, { type: 'select' }>
return `
<label>${escapeHtml(field.label)}
<select ${common}>
${options
.map((opt: string) => `<option value="${escapeHtml(opt)}">${escapeHtml(opt)}</option>`)
.join('')}
</select>
</label>
`
}
default:
return ''
}
}
function renderForm(page: Page) {
const { form } = page
const hasAction = !!(form.actionUrl && form.actionUrl.trim().length > 0)
const hp = form.spamProtection.honeypotFieldName
const dataFallback = hasAction ? '' : ' data-sheets-fallback="true"'
return `
<form method="post"${hasAction ? ` action="${escapeHtml(form.actionUrl!)}"` : ''}${dataFallback}>
<input type="hidden" name="${escapeHtml(hp)}" />
<input type="hidden" name="_ts" value="" />
${form.fields.map(renderField).join('\n')}
<button type="submit">Submit</button>
</form>
`
}
function buildHead(page: Page) {
const ogImage = page.seo.ogImage
const analyticsParts: string[] = []
const ga = page.analytics?.ga4MeasurementId
if (ga && ga.trim()) {
analyticsParts.push(`
<script async src="https://www.googletagmanager.com/gtag/js?id=${ga}"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${ga}');
</script>`)
}
const pixel = page.analytics?.metaPixelId
if (pixel && pixel.trim()) {
analyticsParts.push(`
<script>
!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;
n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', '${pixel}');
fbq('track', 'PageView');
</script>`)
}
const custom = page.analytics?.customHeadHtml || ''
return `
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(page.seo.title)}</title>
<meta name="description" content="${escapeHtml(page.seo.description)}" />
<meta property="og:title" content="${escapeHtml(page.seo.title)}" />
<meta property="og:description" content="${escapeHtml(page.seo.description)}" />
${ogImage ? `<meta property="og:image" content="${escapeHtml(ogImage)}" />` : ''}
${analyticsParts.join('\n')}
${custom}
`
}
function buildCss(page: Page) {
return `
:root{ --primary:${page.theme.primaryColor}; }
body{ font-family: ${page.theme.fontFamily}, system-ui, -apple-system, Segoe UI, Roboto, Noto Sans, Ubuntu, Cantarell, Helvetica Neue, Arial, "Apple Color Emoji","Segoe UI Emoji"; margin:0; color:#0f172a }
a.skip-link{ position:absolute; left:-9999px; top:auto; width:1px; height:1px; overflow:hidden }
a.skip-link:focus{ position:absolute; left:1rem; top:1rem; width:auto; height:auto; padding:.5rem 1rem; background:#111827; color:#fff; border-radius:.375rem; z-index:1000 }
.container{ max-width: 960px; margin: 0 auto; padding: 2rem }
.hero .text{ margin-bottom: 1rem }
img{ max-width: 100%; height: auto }
label{ display:block; margin: .5rem 0 }
button{ background: var(--primary); color:#fff; border:none; padding:.75rem 1rem; border-radius:.5rem }
button:focus, a:focus, input:focus, select:focus, textarea:focus{ outline: 3px solid #2563eb; outline-offset:2px }
.faq-list{ list-style:none; padding:0; margin:0 }
.faq-item{ margin: .75rem 0 }
.faq-q{ font-size: 1rem; font-weight:600; }
.faq-a{ color:#334155 }
.btn-cta{ background: var(--primary); color:#fff; padding:.75rem 1rem; border-radius:.5rem; display:inline-block; text-decoration:none }
.features{ background:#f8fafc }
.features-list{ list-style:none; padding:0; margin:0; display:grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: .75rem }
.feature-item{ background:#fff; border:1px solid #e2e8f0; border-radius:.5rem; padding:.75rem }
.testimonials{ background:#ffffff }
.testimonial{ border-left:4px solid var(--primary); padding:.5rem 1rem; margin: .75rem 0; background:#fefefe }
.testimonial blockquote{ margin:0; font-style:italic; color:#1f2937 }
.testimonial figcaption{ color:#475569; margin-top:.25rem }
@media (max-width: 640px){ .container{ padding: 1rem } h1{ font-size: 1.5rem } }
@media (prefers-reduced-motion: reduce){ *{ transition: none !important; animation: none !important } }
`
}
function buildJs() {
// timestamp 채우기(스팸 방지용 제출 지연 체크용 데이터)
return `
(function(){
var ts = document.querySelector('input[name="_ts"]');
if(ts){ ts.value = String(Date.now()); }
})();
`
}
export function exportPage(page: Page, opts?: ExportOptions): Exported {
const sections = page.sections
.map((s: Page['sections'][number]) => {
if (s.type === 'hero') return renderHero(s, opts)
if (s.type === 'faq') return renderFaq(s)
if (s.type === 'cta') return renderCta(s)
if (s.type === 'features') return renderFeatures(s)
if (s.type === 'testimonials') return renderTestimonials(s)
return ''
})
.join('\n')
const formHtml = renderForm(page)
const html = `<!DOCTYPE html>
<html lang="${escapeHtml(page.locale)}">
<head>
${buildHead(page)}
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
</head>
<body>
<a href="#main" class="skip-link">Skip to content</a>
<main id="main" role="main">
${sections}
${formHtml}
</main>
<script>${buildJs()}</script>
<style>${buildCss(page)}</style>
</body>
</html>`
return { html, css: buildCss(page), js: buildJs() }
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest'
import { pageSchema, type Page } from '@/lib/schema/page'
import { exportPage } from '@/lib/exporter/html'
import { AssetManager } from '@/lib/assets/assetManager'
const makeLocalImagePage = async () => {
const am = new AssetManager({ maxBytes: 1024 * 1024, allowedExts: ['png'] })
const hero = await am.add({ name: 'hero.png', type: 'image/png', bytes: new Uint8Array([1,2,3]) })
const page: Page = pageSchema.parse({
title: 'Local Image',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [
{
type: 'hero',
props: { heading: 'Hi', subheading: '', imageUrl: `local:${hero.originalName}` },
},
],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 'Local Image', description: 'd' },
})
return { am, page }
}
describe('HTML Exporter - asset rewrite', () => {
it('local: 프리픽스 이미지는 assets/<hash>.<ext>로 치환된다', async () => {
const { am, page } = await makeLocalImagePage()
const out = exportPage(page, { assetManager: am })
// assets 경로가 HTML 내 포함되어야 함
expect(out.html).toMatch(/assets\/[a-f0-9]{8}\.png/)
})
})
+72
View File
@@ -0,0 +1,72 @@
import { describe, it, expect } from 'vitest'
import { pageSchema, type Page } from '@/lib/schema/page'
import { exportPage } from '@/lib/exporter/html'
const makeSamplePage = (): Page =>
pageSchema.parse({
title: 'Sample Landing',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [
{
type: 'hero',
props: {
heading: 'Welcome',
subheading: 'Build fast',
imageUrl: 'https://cdn.example.com/hero.png',
},
},
],
form: {
actionUrl: '',
fields: [
{ type: 'text', name: 'name', label: 'Name', required: true },
{ type: 'email', name: 'email', label: 'Email', required: true },
{ type: 'checkbox', name: 'agree', label: 'Agree', required: true },
],
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
},
analytics: { ga4MeasurementId: '', metaPixelId: '' },
seo: { title: 'Sample Landing', description: 'desc', ogImage: 'https://cdn.example.com/og.png' },
})
describe('HTML Exporter', () => {
it('페이지를 정적 HTML/CSS/JS로 내보낸다', () => {
const page = makeSamplePage()
const out = exportPage(page)
expect(typeof out.html).toBe('string')
expect(typeof out.css).toBe('string')
expect(typeof out.js).toBe('string')
// 기본 요소 포함
expect(out.html).toContain('<!DOCTYPE html>')
expect(out.html).toContain('<html lang="en">')
expect(out.html).toContain('<title>Sample Landing</title>')
// OG/SEO
expect(out.html).toContain('meta property="og:title"')
expect(out.html).toContain('meta property="og:image"')
// Hero 섹션 렌더링
expect(out.html).toContain('Welcome')
expect(out.html).toContain('Build fast')
expect(out.html).toContain('https://cdn.example.com/hero.png')
// Form 렌더링 및 스팸방지 요소
expect(out.html).toContain('name="name"')
expect(out.html).toContain('name="email"')
expect(out.html).toContain('name="agree"')
expect(out.html).toContain('type="hidden"') // honeypot 또는 타임스탬프
// actionUrl 미지정이면 data-hint 포함(향후 Sheets 연결 힌트)
expect(out.html).toContain('data-sheets-fallback="true"')
})
it('파일 input은 생성하지 않는다', () => {
const page = makeSamplePage()
const out = exportPage(page)
expect(out.html).not.toMatch(/type=\"file\"/)
})
})
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
describe('Exporter - OG image meta', () => {
test('includes og:image meta when seo.ogImage is provided', () => {
const page: Page = pageSchema.parse({
title: 'T',
locale: 'en',
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
sections: [],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
seo: { title: 'SEO', description: 'Desc', ogImage: 'https://example.com/og.png' },
})
const out = exportPage(page)
expect(out.html).toContain('<meta property="og:image" content="https://example.com/og.png" />')
})
test('omits og:image meta when seo.ogImage is empty', () => {
const page: Page = pageSchema.parse({
title: 'T',
locale: 'en',
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
sections: [],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
seo: { title: 'SEO', description: 'Desc' },
})
const out = exportPage(page)
expect(out.html).not.toContain('og:image')
})
})
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
const makePage = (): Page =>
pageSchema.parse({
title: 'Snapshot',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [
{ type: 'hero', props: { heading: 'Welcome', subheading: 'Sub', imageUrl: 'https://example.com/hero.png' } },
{ type: 'features', props: { items: ['One', 'Two'] } },
{ type: 'testimonials', props: { items: [{ author: 'Jane', quote: 'Great' }] } },
{ type: 'faq', props: { items: [{ q: 'Q1', a: 'A1' }] } },
{ type: 'cta', props: { text: 'Go', href: '#' } },
] as Page['sections'],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 'SEO', description: 'Desc' },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
})
describe('Exporter regression snapshot (markers only)', () => {
test('combined markers for headings/aria/responsive exist', () => {
const out = exportPage(makePage())
const { html, css } = out
// headings/aria markers
expect(html).toContain('<h1 id="hero-heading">Welcome</h1>')
expect(html).toContain('<section class="features" aria-labelledby="features-heading">')
expect(html).toContain('<h2 id="features-heading">')
expect(html).toContain('<section class="testimonials" aria-labelledby="testimonials-heading">')
expect(html).toContain('<h2 id="testimonials-heading">')
expect(html).toContain('<section class="faq" aria-labelledby="faq-heading">')
expect(html).toContain('<h2 id="faq-heading">')
expect(html).toContain('<section class="cta" aria-labelledby="cta-heading">')
expect(html).toContain('<h2 id="cta-heading">')
// responsive/a11y CSS markers
expect(css).toMatch(/@media \(max-width: 640px\)/)
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)/)
expect(css).toMatch(/a\.skip-link:focus/)
expect(css).toMatch(/outline:\s*3px\s*solid/)
})
})
@@ -0,0 +1,48 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
function makeBase(overrides: Partial<Page>): Page {
const base: Page = {
title: 'T',
locale: 'en',
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
sections: [],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
seo: { title: 'SEO', description: 'Desc' },
}
return pageSchema.parse({ ...base, ...overrides })
}
describe('Exporter - Features and Testimonials sections', () => {
test('renders features list with items and classes', () => {
const sections = ([
{ type: 'features', props: { items: ['One', 'Two', 'Three'] } },
] as unknown) as Page['sections']
const page = makeBase({ sections })
const out = exportPage(page)
expect(out.html).toContain('<section class="features" aria-labelledby="features-heading">')
expect(out.html).toContain('<ul class="features-list">')
expect(out.html).toContain('<li class="feature-item">One</li>')
expect(out.html).toContain('<li class="feature-item">Two</li>')
expect(out.html).toContain('<li class="feature-item">Three</li>')
})
test('renders testimonials with figure/blockquote/figcaption structure', () => {
const sections = ([
{ type: 'testimonials', props: { items: [
{ author: 'Jane', quote: 'Great!' },
{ author: 'John', quote: 'Nice.' },
] } },
] as unknown) as Page['sections']
const page = makeBase({ sections })
const out = exportPage(page)
expect(out.html).toContain('<section class="testimonials" aria-labelledby="testimonials-heading">')
expect(out.html).toContain('<figure class="testimonial">')
expect(out.html).toContain('<blockquote>“Great!”</blockquote>')
expect(out.html).toContain('<figcaption>— Jane</figcaption>')
expect(out.html).toContain('<blockquote>“Nice.”</blockquote>')
expect(out.html).toContain('<figcaption>— John</figcaption>')
})
})
+34
View File
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest'
import { pageSchema, type Page } from '@/lib/schema/page'
import { exportPage } from '@/lib/exporter/html'
const makePage = (): Page =>
pageSchema.parse({
title: 'Sections',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [
{ type: 'faq', props: { items: [ { q: 'Q1?', a: 'A1.' }, { q: 'Q2?', a: 'A2.' } ] } },
{ type: 'cta', props: { text: 'Get Started', href: '#start' } },
],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 'Sections', description: 'desc' },
})
describe('Exporter - FAQ/CTA sections', () => {
it('FAQ와 CTA 섹션을 렌더링한다', () => {
const page = makePage()
const out = exportPage(page)
// FAQ
expect(out.html).toContain('Q1?')
expect(out.html).toContain('A1.')
expect(out.html).toContain('Q2?')
expect(out.html).toContain('A2.')
// CTA
expect(out.html).toContain('Get Started')
expect(out.html).toContain('#start')
})
})
@@ -0,0 +1,29 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
const makePage = (): Page =>
pageSchema.parse({
title: 'A11y Tokens',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 'SEO', description: 'Desc' },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
})
describe('A11y color/typography token smoke', () => {
test('base text color, font-family and mobile h1 size markers exist', () => {
const { css } = exportPage(makePage())
// base text color token presence
expect(css.replace(/\s+/g, ' ')).toMatch(/body\{[^}]*color:\s*#0f172a/i)
// font-family token (contains "Inter" and common fallbacks)
expect(css).toMatch(/body\{[^}]*font-family:\s*Inter/i)
// mobile h1 size within max-width:640px media query
expect(css).toMatch(/@media \(max-width: 640px\)[\s\S]*h1\{[^}]*font-size:\s*1\.5rem/i)
})
})
+31
View File
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest'
import JSZip from 'jszip'
import { buildZip } from '@/lib/exporter/zip'
const textEncoder = new TextEncoder()
describe('ZIP 유틸', () => {
it('index.html / styles.css / app.js 와 assets/* 를 포함한 ZIP을 생성한다', async () => {
const html = '<!DOCTYPE html><html><head><title>x</title></head><body>hi</body></html>'
const css = 'body{color:#000}'
const js = 'console.log(1)'
const assets: Record<string, Uint8Array> = {
'assets/a1.png': new Uint8Array([1,2,3]),
'assets/b2.svg': textEncoder.encode('<svg></svg>'),
}
const zipBytes = await buildZip({ html, css, js, assets })
const zip = await JSZip.loadAsync(zipBytes)
const names = Object.keys(zip.files)
expect(names).toContain('index.html')
expect(names).toContain('styles.css')
expect(names).toContain('app.js')
expect(names).toContain('assets/a1.png')
expect(names).toContain('assets/b2.svg')
const htmlContent = await zip.files['index.html'].async('string')
expect(htmlContent).toContain('<title>x</title>')
})
})
+24
View File
@@ -0,0 +1,24 @@
import JSZip from 'jszip'
export type BuildZipInput = {
html: string
css: string
js: string
assets?: Record<string, Uint8Array>
}
export async function buildZip(input: BuildZipInput): Promise<Uint8Array> {
const zip = new JSZip()
zip.file('index.html', input.html)
zip.file('styles.css', input.css)
zip.file('app.js', input.js)
if (input.assets) {
for (const [path, bytes] of Object.entries(input.assets)) {
// In Node/Vitest, use Buffer for robust binary handling
const buf = Buffer.from(bytes)
zip.file(path, buf as unknown as Buffer)
}
}
const content = await zip.generateAsync({ type: 'uint8array' })
return content
}