diff --git a/components/BuilderClientPage.imageOptions.test.tsx b/components/BuilderClientPage.imageOptions.test.tsx new file mode 100644 index 0000000..3784257 --- /dev/null +++ b/components/BuilderClientPage.imageOptions.test.tsx @@ -0,0 +1,53 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { NextIntlClientProvider } from 'next-intl' +import BuilderClientPage from '@/components/BuilderClientPage' + +declare global { + interface Window { __exportPreview?: () => { html: string } } +} + +describe('BuilderClientPage 이미지 옵션 UI 연동', () => { + beforeEach(() => { + // JSDOM 환경 초기화용 + window.__exportPreview = undefined + }) + + it('기본값으로 img에 loading="lazy" decoding="async"가 포함된다', async () => { + render( + + + + ) + // Ensure a hero section with image is present + const addHeroBtn = screen.getByTestId('btn-add-hero') + addHeroBtn.click() + expect(typeof window.__exportPreview).toBe('function') + await waitFor(() => { + const out = window.__exportPreview!() + expect(out.html).toContain('loading="lazy"') + expect(out.html).toContain('decoding="async"') + }) + }) + + it('UI에서 eager/sync로 변경 시 preview HTML에 반영된다', async () => { + render( + + + + ) + const addHeroBtn = screen.getByTestId('btn-add-hero') + addHeroBtn.click() + const loadingSelect = screen.getByLabelText('Image Loading') as HTMLSelectElement + const decodingSelect = screen.getByLabelText('Image Decoding') as HTMLSelectElement + + fireEvent.change(loadingSelect, { target: { value: 'eager' } }) + fireEvent.change(decodingSelect, { target: { value: 'sync' } }) + + await waitFor(() => { + const out = window.__exportPreview!() + expect(out.html).toContain('loading="eager"') + expect(out.html).toContain('decoding="sync"') + }) + }) +}) diff --git a/components/BuilderClientPage.tsx b/components/BuilderClientPage.tsx index 7d3bfb8..6e36ca7 100644 --- a/components/BuilderClientPage.tsx +++ b/components/BuilderClientPage.tsx @@ -61,6 +61,9 @@ export default function BuilderClientPage() { const [assetPathStrategy, setAssetPathStrategy] = useState<'flat' | 'grouped'>('flat') // Font optimization: allow toggling Google Fonts preconnect links in exported HTML const [fontPreconnect, setFontPreconnect] = useState(true) + const [fontPreload, setFontPreload] = useState(false) + const [imageLoading, setImageLoading] = useState<'lazy' | 'eager'>('lazy') + const [imageDecoding, setImageDecoding] = useState<'async' | 'sync'>('async') const [viewport, setViewport] = useState<'desktop' | 'mobile'>(() => { if (typeof window !== 'undefined') { try { @@ -194,7 +197,7 @@ export default function BuilderClientPage() { w.__exportPreview = () => { try { // Export preview with current optimization options - return exportPage(pageSchema.parse(pageData), { assetManager: buildEffectiveAm(), fontPreconnect }) + return exportPage(pageSchema.parse(pageData), { assetManager: buildEffectiveAm(), fontPreconnect, fontPreload, imageLoading, imageDecoding }) } catch { const fixed: Page = { ...pageData, @@ -204,12 +207,12 @@ export default function BuilderClientPage() { spamProtection: pageData.form?.spamProtection ?? { honeypotFieldName: '_hp', minSubmitSeconds: 2 }, }, } - return exportPage(pageSchema.parse(fixed), { assetManager: buildEffectiveAm(), fontPreconnect }) + return exportPage(pageSchema.parse(fixed), { assetManager: buildEffectiveAm(), fontPreconnect, fontPreload, imageLoading, imageDecoding }) } } } } catch {} - }, [pageData, am, fontPreconnect, assetPathStrategy]) + }, [pageData, am, fontPreconnect, fontPreload, imageLoading, imageDecoding, assetPathStrategy]) const doExport = useCallback(async () => { let valid = pageData as Page @@ -237,8 +240,8 @@ export default function BuilderClientPage() { } return clone } - // Respect font optimization toggle when exporting - const exported = exportPage(valid, { assetManager: buildEffectiveAm(), fontPreconnect }) + // Respect font and image optimization toggles when exporting + const exported = exportPage(valid, { assetManager: buildEffectiveAm(), fontPreconnect, fontPreload, imageLoading, imageDecoding }) try { if (typeof window !== 'undefined') { const w = window as unknown as { __captureExport?: boolean; __lastExport?: { html: string; css: string; js: string } } @@ -285,7 +288,7 @@ export default function BuilderClientPage() { } const zipBytes = await buildZip({ html: exported.html, css: exported.css, js: exported.js, assets, extras }) download('landing.zip', zipBytes) - }, [pageData, am, manifestName, manifestShortName, manifestStartUrl, manifestDisplay, manifestThemeColor, robotsDisallow, sheetsMode, fontPreconnect, assetPathStrategy]) + }, [pageData, am, manifestName, manifestShortName, manifestStartUrl, manifestDisplay, manifestThemeColor, robotsDisallow, sheetsMode, fontPreconnect, fontPreload, imageLoading, imageDecoding, assetPathStrategy]) return (
@@ -375,6 +378,44 @@ export default function BuilderClientPage() { /> Enable font preconnect (Google Fonts) + +
+ + +
@@ -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) ? `` : '' + 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 ` ${preconnect ? '' : ''} ${preconnect ? '' : ''} + ${fontPreload && fontHref ? `` : ''} + ${fontPreload && fontHref ? `` : ''} + ${fontPreload && fontHref ? `` : ''} ${robotsMeta} diff --git a/lib/exporter/image.loading.toggle.test.ts b/lib/exporter/image.loading.toggle.test.ts new file mode 100644 index 0000000..022b892 --- /dev/null +++ b/lib/exporter/image.loading.toggle.test.ts @@ -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"') + }) +})