From 689edeaf244d7e19b395988ad818a02feeb986b9 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sat, 15 Nov 2025 15:18:23 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(perf-seo):=20exporter=EC=97=90=20robot?= =?UTF-8?q?s=20=EB=A9=94=ED=83=80=20=EC=98=B5=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80(TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/exporter/html.ts | 4 +++- lib/exporter/robots.meta.tag.test.ts | 29 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 lib/exporter/robots.meta.tag.test.ts diff --git a/lib/exporter/html.ts b/lib/exporter/html.ts index 14c1932..05b5956 100644 --- a/lib/exporter/html.ts +++ b/lib/exporter/html.ts @@ -2,7 +2,7 @@ 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 } +export type ExportOptions = { assetManager?: AssetManager; fontPreconnect?: boolean; robotsMeta?: string } 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 : [] @@ -251,6 +251,7 @@ 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) ? `` : '' return ` @@ -259,6 +260,7 @@ function buildHead(page: Page, opts?: ExportOptions) { ${preconnect ? '' : ''} + ${robotsMeta} ${escapeHtml(page.seo.title)} diff --git a/lib/exporter/robots.meta.tag.test.ts b/lib/exporter/robots.meta.tag.test.ts new file mode 100644 index 0000000..ca44dd3 --- /dev/null +++ b/lib/exporter/robots.meta.tag.test.ts @@ -0,0 +1,29 @@ +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: 'Meta Robots', + 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('meta robots', () => { + it('기본적으로 head에 robots 메타가 없다', () => { + const page = makePage() + const out = exportPage(page) + expect(out.html).not.toContain(' { + const page = makePage() + const opts: ExportOptions = { robotsMeta: 'noindex,nofollow' } + const out = exportPage(page, opts) + expect(out.html).toContain('') + }) +}) -- 2.52.0 From ed7f6189ebabd796d2fc14e4c87bf165d20036cf Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sun, 16 Nov 2025 04:10:52 +0900 Subject: [PATCH 2/4] =?UTF-8?q?Phase7-PerfSEO:=20=ED=8F=B0=ED=8A=B8=20prel?= =?UTF-8?q?oad=20=EC=98=B5=EC=85=98=20=EB=B0=8F=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20loading/decoding=20UI=20=EC=97=B0=EB=8F=99=20TDD/?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BuilderClientPage.imageOptions.test.tsx | 53 +++++++++++++++++++ components/BuilderClientPage.tsx | 53 ++++++++++++++++--- lib/exporter/font.preload.test.ts | 35 ++++++++++++ lib/exporter/html.ts | 19 ++++++- lib/exporter/image.loading.toggle.test.ts | 31 +++++++++++ 5 files changed, 183 insertions(+), 8 deletions(-) create mode 100644 components/BuilderClientPage.imageOptions.test.tsx create mode 100644 lib/exporter/font.preload.test.ts create mode 100644 lib/exporter/image.loading.toggle.test.ts 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"') + }) +}) -- 2.52.0 From 0eb056e6b551ee73aa71f07733a4aafad8d15b29 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sun, 16 Nov 2025 04:12:46 +0900 Subject: [PATCH 3/4] =?UTF-8?q?Phase7-PerfSEO:=20robots=20meta=20UI=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99(TDD)=20=EB=B0=8F=20Preview/Export=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BuilderClientPage.robotsMeta.test.tsx | 49 +++++++++++++++++++ components/BuilderClientPage.tsx | 24 ++++++--- 2 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 components/BuilderClientPage.robotsMeta.test.tsx 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 @@ -197,7 +198,7 @@ export default function BuilderClientPage() { w.__exportPreview = () => { try { // Export preview with current optimization options - return exportPage(pageSchema.parse(pageData), { assetManager: buildEffectiveAm(), fontPreconnect, fontPreload, imageLoading, imageDecoding }) + return exportPage(pageSchema.parse(pageData), { assetManager: buildEffectiveAm(), fontPreconnect, fontPreload, imageLoading, imageDecoding, robotsMeta: robotsMeta.trim() || undefined }) } catch { const fixed: Page = { ...pageData, @@ -207,12 +208,12 @@ export default function BuilderClientPage() { spamProtection: pageData.form?.spamProtection ?? { honeypotFieldName: '_hp', minSubmitSeconds: 2 }, }, } - return exportPage(pageSchema.parse(fixed), { assetManager: buildEffectiveAm(), fontPreconnect, fontPreload, imageLoading, imageDecoding }) + return exportPage(pageSchema.parse(fixed), { assetManager: buildEffectiveAm(), fontPreconnect, fontPreload, imageLoading, imageDecoding, robotsMeta: robotsMeta.trim() || undefined }) } } } } catch {} - }, [pageData, am, fontPreconnect, fontPreload, imageLoading, imageDecoding, assetPathStrategy]) + }, [pageData, am, fontPreconnect, fontPreload, imageLoading, imageDecoding, robotsMeta, assetPathStrategy]) const doExport = useCallback(async () => { let valid = pageData as Page @@ -240,8 +241,8 @@ export default function BuilderClientPage() { } return clone } - // Respect font and image optimization toggles when exporting - const exported = exportPage(valid, { assetManager: buildEffectiveAm(), fontPreconnect, fontPreload, imageLoading, imageDecoding }) + // 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 } } @@ -288,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, fontPreload, imageLoading, imageDecoding, assetPathStrategy]) + }, [pageData, am, manifestName, manifestShortName, manifestStartUrl, manifestDisplay, manifestThemeColor, robotsDisallow, sheetsMode, fontPreconnect, fontPreload, imageLoading, imageDecoding, robotsMeta, assetPathStrategy]) return (
@@ -487,6 +488,17 @@ export default function BuilderClientPage() { placeholder="/private\n/tmp" /> +