358 lines
14 KiB
TypeScript
358 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('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
|
|
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" width="1200" height="675" />
|
|
</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="/" />
|
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
<link rel="manifest" href="/site.webmanifest" />
|
|
<meta name="theme-color" content="${escapeHtml(page.theme.primaryColor)}" />
|
|
<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.googleapis.com">
|
|
<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() }
|
|
}
|