Phase7-PerfSEO: 폰트 preload 옵션 및 이미지 loading/decoding UI 연동 TDD/구현 완료
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportPage, type ExportOptions } from '@/lib/exporter/html'
|
||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||
|
||||
function makePage(fontFamily = 'Inter'): Page {
|
||||
return pageSchema.parse({
|
||||
title: 'Font Preload',
|
||||
locale: 'en',
|
||||
theme: { primaryColor: '#2563eb', fontFamily },
|
||||
sections: [ { type: 'hero', props: { heading: 'Hello', subheading: 'World', imageUrl: 'https://img/h.png' } } ],
|
||||
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||
seo: { title: 't', description: 'd' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('폰트 preload 옵션', () => {
|
||||
it('옵션이 true이면 Google Fonts stylesheet preload/link를 head에 포함한다', () => {
|
||||
const page = makePage('Inter')
|
||||
const opts: ExportOptions = { fontPreload: true }
|
||||
const out = exportPage(page, opts)
|
||||
expect(out.html).toContain('rel="preload"')
|
||||
expect(out.html).toContain('as="style"')
|
||||
expect(out.html).toContain('fonts.googleapis.com/css2')
|
||||
expect(out.html).toContain('family=Inter')
|
||||
// onload print hack 포함
|
||||
expect(out.html).toMatch(/rel=\"stylesheet\"[^>]+media=\"print\"[^>]+onload=\"this.media='all'\"/)
|
||||
})
|
||||
|
||||
it('옵션이 false이면 preload/link를 포함하지 않는다', () => {
|
||||
const page = makePage('Inter')
|
||||
const opts: ExportOptions = { fontPreload: false }
|
||||
const out = exportPage(page, opts)
|
||||
expect(out.html).not.toMatch(/fonts.googleapis.com\/css2/)
|
||||
})
|
||||
})
|
||||
+17
-2
@@ -2,7 +2,14 @@ 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; fontPreconnect?: boolean; robotsMeta?: string }
|
||||
export type ExportOptions = {
|
||||
assetManager?: AssetManager
|
||||
fontPreconnect?: boolean
|
||||
fontPreload?: boolean
|
||||
robotsMeta?: string
|
||||
imageLoading?: 'lazy' | 'eager'
|
||||
imageDecoding?: 'async' | 'sync'
|
||||
}
|
||||
|
||||
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 : []
|
||||
@@ -116,6 +123,8 @@ function renderHero(section: { props: { heading: string; subheading?: string; im
|
||||
const am = opts?.assetManager
|
||||
const imageUrlRaw: string = section.props.imageUrl
|
||||
const imageUrl = am ? am.rewriteUrl(imageUrlRaw) : imageUrlRaw
|
||||
const loading = opts?.imageLoading ?? 'lazy'
|
||||
const decoding = opts?.imageDecoding ?? 'async'
|
||||
return `
|
||||
<section class="hero" aria-labelledby="hero-heading">
|
||||
<div class="container">
|
||||
@@ -124,7 +133,7 @@ function renderHero(section: { props: { heading: string; subheading?: string; im
|
||||
${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" />
|
||||
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(heading)}" loading="${escapeHtml(loading)}" decoding="${escapeHtml(decoding)}" width="1200" height="675" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -252,12 +261,18 @@ function buildHead(page: Page, opts?: ExportOptions) {
|
||||
const custom = page.analytics?.customHeadHtml || ''
|
||||
const preconnect = opts?.fontPreconnect !== false
|
||||
const robotsMeta = (opts?.robotsMeta && opts.robotsMeta.trim().length > 0) ? `<meta name="robots" content="${escapeHtml(opts!.robotsMeta!)}" />` : ''
|
||||
const fontPreload = opts?.fontPreload === true
|
||||
const rawFamily = (page.theme?.fontFamily || '').trim()
|
||||
const fontHref = rawFamily.length > 0 ? `https://fonts.googleapis.com/css2?family=${encodeURIComponent(rawFamily).replace(/%20/g, '+')}&display=swap` : ''
|
||||
return `
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="canonical" href="/" />
|
||||
${preconnect ? '<link rel="preconnect" href="https://fonts.googleapis.com" />' : ''}
|
||||
${preconnect ? '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />' : ''}
|
||||
${fontPreload && fontHref ? `<link rel="preload" as="style" href="${fontHref}" />` : ''}
|
||||
${fontPreload && fontHref ? `<link rel="stylesheet" href="${fontHref}" media="print" onload="this.media='all'" />` : ''}
|
||||
${fontPreload && fontHref ? `<noscript><link rel="stylesheet" href="${fontHref}" /></noscript>` : ''}
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="theme-color" content="${escapeHtml(page.theme.primaryColor)}" />
|
||||
${robotsMeta}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportPage, type ExportOptions } from '@/lib/exporter/html'
|
||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||
|
||||
function makePage(): Page {
|
||||
return pageSchema.parse({
|
||||
title: 'Img Perf',
|
||||
locale: 'en',
|
||||
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
|
||||
sections: [ { type: 'hero', props: { heading: 'Hello', subheading: 'World', imageUrl: 'https://img/h.png' } } ],
|
||||
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||
seo: { title: 't', description: 'd' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('이미지 loading/decoding 토글', () => {
|
||||
it('기본은 loading="lazy", decoding="async"', () => {
|
||||
const page = makePage()
|
||||
const out = exportPage(page)
|
||||
expect(out.html).toContain('loading="lazy"')
|
||||
expect(out.html).toContain('decoding="async"')
|
||||
})
|
||||
|
||||
it('옵션으로 eager/sync로 바꿀 수 있다', () => {
|
||||
const page = makePage()
|
||||
const opts: ExportOptions = { imageLoading: 'eager', imageDecoding: 'sync' }
|
||||
const out = exportPage(page, opts)
|
||||
expect(out.html).toContain('loading="eager"')
|
||||
expect(out.html).toContain('decoding="sync"')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user