test(ci): add exporter heading levels and a11y typography smoke; update MAIN_PLAN
CI / test (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
This commit is contained in:
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
|
||||
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() }
|
||||
}
|
||||
Reference in New Issue
Block a user