Files
landing-builder/lib/exporter/html.ts
T

354 lines
14 KiB
TypeScript

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 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 `
<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 : [])
.map((s) => String(s ?? '').trim())
.filter((s) => s.length > 0)
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 renderBenefits(section: { props: { items: string[] } }) {
const items: string[] = (Array.isArray(section.props?.items) ? section.props.items : [])
.map((s) => String(s ?? '').trim())
.filter((s) => s.length > 0)
return `
<section class="benefits" aria-labelledby="benefits-heading">
<div class="container">
<h2 id="benefits-heading">Benefits</h2>
<ul class="benefits-list">
${items.map((it) => `<li class="benefit-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
: [])
.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 `
<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>
${it.author.length > 0 ? `<figcaption>— ${escapeHtml(it.author)}</figcaption>` : ''}
</figure>`
)
.join('')}
</div>
</section>
`
}
function renderCta(section: { props: { text: string; href?: string } }) {
const href = section.props.href
const text = (section.props.text || '').trim()
if (text.length === 0) return ''
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 } = section.props
const subheadingRaw = section.props.subheading
const subheading = subheadingRaw ? subheadingRaw.trim() : ''
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.length > 0 ? `<p>${escapeHtml(subheading)}</p>` : ''}
</div>
<div class="media">
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(heading)}" loading="lazy" decoding="async" />
</div>
</div>
</section>
`
}
function renderField(field: FormField) {
const id = `field-${escapeHtml(field.name)}`
const helpId = `field-${escapeHtml(field.name)}-help`
const hasHelp = !!field.help
const helpHtml = hasHelp ? `<div id="${helpId}" class="form-help">${escapeHtml(field.help!)}</div>` : ''
const pat = 'pattern' in field && field.pattern ? ` pattern="${escapeHtml(field.pattern)}"` : ''
const max = 'maxLength' in field && field.maxLength ? ` maxlength="${field.maxLength}"` : ''
const describedBy = hasHelp ? ` aria-describedby="${helpId}"` : ''
const common = `id="${id}" name="${escapeHtml(field.name)}" aria-label="${escapeHtml(field.label)}" ${field.required ? 'required' : ''}${pat}${max}${describedBy}`
switch (field.type) {
case 'text':
case 'email':
case 'tel':
case 'date':
return `
<div class="form-control">
<label for="${id}">${escapeHtml(field.label)}</label>
<input type="${field.type}" ${common} />
${helpHtml}
</div>
`
case 'textarea':
return `
<div class="form-control">
<label for="${id}">${escapeHtml(field.label)}</label>
<textarea ${common}></textarea>
${helpHtml}
</div>
`
case 'checkbox':
return `
<div class="form-control">
<label for="${id}">${escapeHtml(field.label)}</label>
<input type="checkbox" ${common} />
${helpHtml}
</div>
`
case 'radio': {
const { options } = field as Extract<FormField, { type: 'radio' }>
const fsDescribed = hasHelp ? ` aria-describedby="${helpId}"` : ''
return `
<fieldset${fsDescribed}>
<legend>${escapeHtml(field.label)}</legend>
${options
.map(
(opt: string, i: number) => {
const optId = `${id}-opt-${i}`
return `<div><input id="${optId}" type="radio" name="${escapeHtml(field.name)}" aria-label="${escapeHtml(field.label)}" value="${escapeHtml(opt)}" ${field.required ? 'required' : ''}${pat}${max} /><label for="${optId}">${escapeHtml(opt)}</label></div>`
}
)
.join('')}
${helpHtml}
</fieldset>
`
}
case 'select': {
const { options } = field as Extract<FormField, { type: 'select' }>
return `
<div class="form-control">
<label for="${id}">${escapeHtml(field.label)}</label>
<select ${common}>
${options
.map((opt: string) => `<option value="${escapeHtml(opt)}">${escapeHtml(opt)}</option>`)
.join('')}
</select>
${helpHtml}
</div>
`
}
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" />
<link rel="canonical" href="/" />
<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; overflow-wrap:anywhere }
.faq-a{ color:#334155; overflow-wrap:anywhere }
.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; 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; 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 } }
@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()); }
// Ensure skip-link moves focus into main for a11y
var skip = document.querySelector('a.skip-link');
var main = document.getElementById('main');
if (skip && main) {
skip.addEventListener('click', function(){
if (!main.hasAttribute('tabindex')) {
main.setAttribute('tabindex', '-1');
}
main.focus();
});
}
})();
`
}
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 === 'benefits') return renderBenefits(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() }
}