From ae250f3eaac2d40e5b63b205442b10c4d4166a82 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Fri, 14 Nov 2025 23:15:05 +0900 Subject: [PATCH] =?UTF-8?q?feat(exporter):=20=EC=84=B9=EC=85=98=20?= =?UTF-8?q?=EC=97=A3=EC=A7=80=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95(=EA=B3=B5=EB=B0=B1=20=EC=A0=9C=EA=B1=B0/=EC=A4=84?= =?UTF-8?q?=EB=B0=94=EA=BF=88=20=ED=86=A0=ED=81=B0/CTA)=20+=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/exporter/cta.edge-cases.test.ts | 32 +++++++++++++ lib/exporter/html.ts | 36 ++++++++++----- lib/exporter/sections.edge-cases.test.ts | 57 ++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 10 deletions(-) create mode 100644 lib/exporter/cta.edge-cases.test.ts create mode 100644 lib/exporter/sections.edge-cases.test.ts diff --git a/lib/exporter/cta.edge-cases.test.ts b/lib/exporter/cta.edge-cases.test.ts new file mode 100644 index 0000000..c019200 --- /dev/null +++ b/lib/exporter/cta.edge-cases.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest' +import { exportPage } from '@/lib/exporter/html' +import { pageSchema, type Page } from '@/lib/schema/page' + +function makePage(overrides: Partial = {}): Page { + const base: Page = { + title: 'CTA Edge', + locale: 'en', + theme: { primaryColor: '#2563eb', fontFamily: 'Inter' }, + sections: [ + { type: 'cta', props: { text: ' ', href: '#' } }, + { type: 'features', props: { items: ['One'] } }, + ] as Page['sections'], + form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } }, + analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' }, + seo: { title: 't', description: 'd' }, + } + return pageSchema.parse({ ...base, ...overrides }) +} + +describe('Exporter - CTA edge cases', () => { + it('drops CTA section when text is whitespace-only', () => { + const out = exportPage(makePage()) + expect(out.html).not.toContain('
{ + const out = exportPage(makePage({ sections: [ { type: 'cta', props: { text: ' Buy Now ', href: '#' } } ] as Page['sections'] })) + expect(out.html).toContain('Buy Now') + }) +}) diff --git a/lib/exporter/html.ts b/lib/exporter/html.ts index 8368979..adb110e 100644 --- a/lib/exporter/html.ts +++ b/lib/exporter/html.ts @@ -5,7 +5,12 @@ 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 : [] + const itemsRaw: Array<{ q: string; a: string }> = Array.isArray(section.props.items) ? section.props.items : [] + const isBlank = (s: string | undefined | null) => !s || s.trim().length === 0 + const items = itemsRaw + .map((it) => ({ q: (it.q || '').trim(), a: (it.a || '').trim() })) + // drop when both question and answer are blank + .filter((it) => !(isBlank(it.q) && isBlank(it.a))) return `
@@ -27,7 +32,9 @@ function renderFaq(section: { props: { items: Array<{ q: string; a: string }> } } function renderFeatures(section: { props: { items: string[] } }) { - const items: string[] = Array.isArray(section.props?.items) ? section.props.items : [] + const items: string[] = (Array.isArray(section.props?.items) ? section.props.items : []) + .map((s) => String(s ?? '').trim()) + .filter((s) => s.length > 0) return `
@@ -41,7 +48,9 @@ function renderFeatures(section: { props: { items: string[] } }) { } function renderBenefits(section: { props: { items: string[] } }) { - const items: string[] = Array.isArray(section.props?.items) ? section.props.items : [] + const items: string[] = (Array.isArray(section.props?.items) ? section.props.items : []) + .map((s) => String(s ?? '').trim()) + .filter((s) => s.length > 0) return `
@@ -55,9 +64,12 @@ function renderBenefits(section: { props: { items: string[] } }) { } function renderTestimonials(section: { props: { items: Array<{ author: string; quote: string }> } }) { - const items: Array<{ author: string; quote: string }> = Array.isArray(section.props?.items) + const items: Array<{ author: string; quote: string }> = (Array.isArray(section.props?.items) ? section.props.items - : [] + : []) + .map((it) => ({ author: (it.author || '').trim(), quote: (it.quote || '').trim() })) + // drop when both are blank + .filter((it) => !(it.author.length === 0 && it.quote.length === 0)) return `
@@ -66,8 +78,8 @@ function renderTestimonials(section: { props: { items: Array<{ author: string; q .map( (it) => `
-
“${escapeHtml(it.quote || '')}”
-
— ${escapeHtml(it.author || '')}
+
“${escapeHtml(it.quote)}”
+
— ${escapeHtml(it.author)}
` ) .join('')} @@ -77,7 +89,9 @@ function renderTestimonials(section: { props: { items: Array<{ author: string; q } function renderCta(section: { props: { text: string; href?: string } }) { - const { text, href } = section.props + const href = section.props.href + const text = (section.props.text || '').trim() + if (text.length === 0) return '' return `
@@ -267,10 +281,12 @@ function buildCss(page: Page) { .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 } + .feature-item{ background:#fff; border:1px solid #e2e8f0; border-radius:.5rem; padding:.75rem; overflow-wrap:anywhere } + .benefits-list{ list-style:none; padding:0; margin:0 } + .benefit-item{ overflow-wrap:anywhere } .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 blockquote{ margin:0; font-style:italic; color:#1f2937; overflow-wrap:anywhere } .testimonial figcaption{ color:#475569; margin-top:.25rem } @media (max-width: 640px){ .container{ padding: 1rem } h1{ font-size: 1.5rem } } @media (prefers-color-scheme: dark){ body{ color:#e5e7eb; background:#0b1020 } .feature-item{ background:#0f172a; border-color:#1f2937 } a{ color:#93c5fd } a:hover{ text-decoration:underline } button{ background: var(--primary); color:#fff } .btn-cta{ background: var(--primary); color:#fff } .faq-a{ color:#cbd5e1 } } diff --git a/lib/exporter/sections.edge-cases.test.ts b/lib/exporter/sections.edge-cases.test.ts new file mode 100644 index 0000000..7410fd6 --- /dev/null +++ b/lib/exporter/sections.edge-cases.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest' +import { exportPage } from '@/lib/exporter/html' +import { pageSchema, type Page } from '@/lib/schema/page' + +function makePage(overrides: Partial = {}): Page { + const base: Page = { + title: 'Edge', + locale: 'en', + theme: { primaryColor: '#2563eb', fontFamily: 'Inter' }, + sections: [ + { type: 'features', props: { items: ['One', ' ', ' ', 'Two'] } }, + { type: 'benefits', props: { items: ['A', ' ', ' ', 'B'] } }, + { type: 'faq', props: { items: [ + { q: ' ', a: ' ' }, + { q: 'Valid Q', a: ' ' }, + { q: 'Another', a: 'Answer' }, + ] } }, + { type: 'testimonials', props: { items: [ + { author: ' ', quote: ' ' }, + { author: 'Jane', quote: ' Great product ' }, + ] } }, + ] as Page['sections'], + form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } }, + analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' }, + seo: { title: 't', description: 'd' }, + } + return pageSchema.parse({ ...base, ...overrides }) +} + +describe('Exporter - sections edge cases', () => { + it('filters out whitespace-only items and keeps valid ones', () => { + const out = exportPage(makePage()) + const html = out.html + + // features: only One, Two + expect(html).toContain('
  • One
  • ') + expect(html).toContain('
  • Two
  • ') + expect(html).not.toMatch(/
  • <\/li>/) + + // benefits: only A, B + expect(html).toContain('
  • A
  • ') + expect(html).toContain('
  • B
  • ') + + // faq: drop fully empty, keep items even if answer empty when question is valid + expect(html).toContain('

    Valid Q

    ') + // testimonials: drop fully empty, keep trimmed quote + expect(html).toContain('
    “Great product”
    ') + }) + + it('adds word-break tokens to avoid overflow for long words', () => { + const out = exportPage(makePage()) + const css = out.css + expect(css).toMatch(/\.feature-item\{[\s\S]*(?:word-break:break-word|overflow-wrap:anywhere)/i) + expect(css).toMatch(/\.benefit-item\{[\s\S]*(?:word-break:break-word|overflow-wrap:anywhere)/i) + expect(css).toMatch(/\.testimonial blockquote\{[\s\S]*(?:word-break:break-word|overflow-wrap:anywhere)/i) + }) +}) -- 2.52.0