Landing Builder
+Start building your landing page.
+ + Go to Builder + +diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..cb0b1e0
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,35 @@
+name: CI
+
+on:
+ push:
+ branches: [ main, master ]
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+
+ - name: Install deps
+ run: npm ci
+
+ - name: Unit/Component tests (Vitest)
+ run: npm test
+ env:
+ CI: true
+
+ - name: Install Playwright browsers
+ run: npx playwright install --with-deps
+
+ - name: E2E tests (Playwright)
+ run: npm run test:e2e
+ env:
+ CI: true
diff --git a/app/[locale]/builder/page.tsx b/app/[locale]/builder/page.tsx
new file mode 100644
index 0000000..dbf5eba
--- /dev/null
+++ b/app/[locale]/builder/page.tsx
@@ -0,0 +1,5 @@
+import BuilderClientPage from '@/components/BuilderClientPage'
+
+export default function BuilderPage() {
+ return
Start building your landing page.
+ + Go to Builder + +Main heading displayed prominently
+Button label and link for primary action
+ +Button label and link for primary action
+Add at least one FAQ item with Q and A
+Describe a single feature per line
+Add author and quote for each testimonial
+Toggle preview viewport for the builder preview area
+
')
+ })
+
+ 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/)
+ })
+})
diff --git a/lib/exporter/analytics.test.ts b/lib/exporter/analytics.test.ts
new file mode 100644
index 0000000..2cda737
--- /dev/null
+++ b/lib/exporter/analytics.test.ts
@@ -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: '' },
+ })
+ const out = exportPage(page)
+ expect(out.html).toContain('')
+ })
+})
diff --git a/lib/exporter/contrastFontTokens.test.ts b/lib/exporter/contrastFontTokens.test.ts
new file mode 100644
index 0000000..10972b8
--- /dev/null
+++ b/lib/exporter/contrastFontTokens.test.ts
@@ -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)
+ })
+})
diff --git a/lib/exporter/cssTokens.test.ts b/lib/exporter/cssTokens.test.ts
new file mode 100644
index 0000000..40e738e
--- /dev/null
+++ b/lib/exporter/cssTokens.test.ts
@@ -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\)/)
+ })
+})
diff --git a/lib/exporter/featuresTestimonials.aria.test.ts b/lib/exporter/featuresTestimonials.aria.test.ts
new file mode 100644
index 0000000..aac514d
--- /dev/null
+++ b/lib/exporter/featuresTestimonials.aria.test.ts
@@ -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('