diff --git a/README.md b/README.md index 2efff98..cb3dd5b 100644 --- a/README.md +++ b/README.md @@ -51,15 +51,23 @@ The Builder can export a production-ready ZIP that contains: - Name, Short Name, Start URL, Display (dropdown), Theme Color (HEX only) - Robots - Disallow lines (textarea). Each line becomes a `Disallow:` rule. + - Robots Meta: `` value (e.g. `noindex, nofollow`). Empty disables the tag. - Google Sheets Template Mode - Auto: include when `form.actionUrl` is empty - Include: always include - Exclude: never include +- Font Optimization + - Preconnect: toggle inclusion of Google Fonts preconnect links. + - Preload: preload & load Google Fonts stylesheet with `media="print" onload` pattern. +- Image Options + - Loading: `lazy` (default) or `eager` for `` + - Decoding: `async` (default) or `sync` for `` ### site.webmanifest and robots.txt - `site.webmanifest` is derived from page data; Export Options can override fields. - `robots.txt` defaults to a permissive header and adds `Disallow` lines from UI. +- `meta name="robots"` can be injected via Export Options and is written in ``. ### Asset paths and grouping 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.robotsMeta.test.tsx b/components/BuilderClientPage.robotsMeta.test.tsx new file mode 100644 index 0000000..e851703 --- /dev/null +++ b/components/BuilderClientPage.robotsMeta.test.tsx @@ -0,0 +1,49 @@ +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 robots meta UI 연동', () => { + beforeEach(() => { + window.__exportPreview = undefined + }) + + it('기본값에서는 robots meta가 존재하지 않는다', async () => { + render( + + + + ) + // Hero 추가(렌더 내용 명확화) + const addHeroBtn = screen.getByTestId('btn-add-hero') + addHeroBtn.click() + + expect(typeof window.__exportPreview).toBe('function') + await waitFor(() => { + const out = window.__exportPreview!() + expect(out.html).not.toContain(' { + render( + + + + ) + const addHeroBtn = screen.getByTestId('btn-add-hero') + addHeroBtn.click() + + const robotsInput = screen.getByLabelText('Robots Meta') as HTMLInputElement + fireEvent.change(robotsInput, { target: { value: 'noindex, nofollow' } }) + + await waitFor(() => { + const out = window.__exportPreview!() + expect(out.html).toContain('('') const [manifestThemeColor, setManifestThemeColor] = useState('') const [robotsDisallow, setRobotsDisallow] = useState('') + const [robotsMeta, setRobotsMeta] = useState('') const [sheetsMode, setSheetsMode] = useState<'auto' | 'include' | 'exclude'>('auto') 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 +198,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, robotsMeta: robotsMeta.trim() || undefined }) } catch { const fixed: Page = { ...pageData, @@ -204,12 +208,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, robotsMeta: robotsMeta.trim() || undefined }) } } } } catch {} - }, [pageData, am, fontPreconnect, assetPathStrategy]) + }, [pageData, am, fontPreconnect, fontPreload, imageLoading, imageDecoding, robotsMeta, assetPathStrategy]) const doExport = useCallback(async () => { let valid = pageData as Page @@ -237,8 +241,8 @@ export default function BuilderClientPage() { } return clone } - // Respect font optimization toggle when exporting - const exported = exportPage(valid, { assetManager: buildEffectiveAm(), fontPreconnect }) + // Respect font, image, robots meta optimization toggles when exporting + const exported = exportPage(valid, { assetManager: buildEffectiveAm(), fontPreconnect, fontPreload, imageLoading, imageDecoding, robotsMeta: robotsMeta.trim() || undefined }) try { if (typeof window !== 'undefined') { const w = window as unknown as { __captureExport?: boolean; __lastExport?: { html: string; css: string; js: string } } @@ -285,7 +289,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, robotsMeta, assetPathStrategy]) return (
@@ -375,6 +379,44 @@ export default function BuilderClientPage() { /> Enable font preconnect (Google Fonts) + +
+ + +
+