Merge pull request 'auto: PR for feat/export-bundle-phase7-perf-seo' (#64) from feat/export-bundle-phase7-perf-seo into main
CI / test (push) Successful in 54s
CI / test (push) Successful in 54s
This commit was merged in pull request #64.
This commit is contained in:
@@ -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)
|
- Name, Short Name, Start URL, Display (dropdown), Theme Color (HEX only)
|
||||||
- Robots
|
- Robots
|
||||||
- Disallow lines (textarea). Each line becomes a `Disallow:` rule.
|
- Disallow lines (textarea). Each line becomes a `Disallow:` rule.
|
||||||
|
- Robots Meta: `<meta name="robots" content="...">` value (e.g. `noindex, nofollow`). Empty disables the tag.
|
||||||
- Google Sheets Template Mode
|
- Google Sheets Template Mode
|
||||||
- Auto: include when `form.actionUrl` is empty
|
- Auto: include when `form.actionUrl` is empty
|
||||||
- Include: always include
|
- Include: always include
|
||||||
- Exclude: never 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 `<img loading>`
|
||||||
|
- Decoding: `async` (default) or `sync` for `<img decoding>`
|
||||||
|
|
||||||
### site.webmanifest and robots.txt
|
### site.webmanifest and robots.txt
|
||||||
|
|
||||||
- `site.webmanifest` is derived from page data; Export Options can override fields.
|
- `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.
|
- `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 `<head>`.
|
||||||
|
|
||||||
### Asset paths and grouping
|
### Asset paths and grouping
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<NextIntlClientProvider locale="en" messages={{}}>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
// 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(
|
||||||
|
<NextIntlClientProvider locale="en" messages={{}}>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
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"')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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(
|
||||||
|
<NextIntlClientProvider locale="en" messages={{}}>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
// 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('<meta name="robots"')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('UI 입력값이 head robots meta에 반영된다', async () => {
|
||||||
|
render(
|
||||||
|
<NextIntlClientProvider locale="en" messages={{}}>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
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('<meta name="robots" content="noindex, nofollow"')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -57,10 +57,14 @@ export default function BuilderClientPage() {
|
|||||||
const [manifestDisplay, setManifestDisplay] = useState<string>('')
|
const [manifestDisplay, setManifestDisplay] = useState<string>('')
|
||||||
const [manifestThemeColor, setManifestThemeColor] = useState<string>('')
|
const [manifestThemeColor, setManifestThemeColor] = useState<string>('')
|
||||||
const [robotsDisallow, setRobotsDisallow] = useState<string>('')
|
const [robotsDisallow, setRobotsDisallow] = useState<string>('')
|
||||||
|
const [robotsMeta, setRobotsMeta] = useState<string>('')
|
||||||
const [sheetsMode, setSheetsMode] = useState<'auto' | 'include' | 'exclude'>('auto')
|
const [sheetsMode, setSheetsMode] = useState<'auto' | 'include' | 'exclude'>('auto')
|
||||||
const [assetPathStrategy, setAssetPathStrategy] = useState<'flat' | 'grouped'>('flat')
|
const [assetPathStrategy, setAssetPathStrategy] = useState<'flat' | 'grouped'>('flat')
|
||||||
// Font optimization: allow toggling Google Fonts preconnect links in exported HTML
|
// Font optimization: allow toggling Google Fonts preconnect links in exported HTML
|
||||||
const [fontPreconnect, setFontPreconnect] = useState<boolean>(true)
|
const [fontPreconnect, setFontPreconnect] = useState<boolean>(true)
|
||||||
|
const [fontPreload, setFontPreload] = useState<boolean>(false)
|
||||||
|
const [imageLoading, setImageLoading] = useState<'lazy' | 'eager'>('lazy')
|
||||||
|
const [imageDecoding, setImageDecoding] = useState<'async' | 'sync'>('async')
|
||||||
const [viewport, setViewport] = useState<'desktop' | 'mobile'>(() => {
|
const [viewport, setViewport] = useState<'desktop' | 'mobile'>(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
try {
|
try {
|
||||||
@@ -194,7 +198,7 @@ export default function BuilderClientPage() {
|
|||||||
w.__exportPreview = () => {
|
w.__exportPreview = () => {
|
||||||
try {
|
try {
|
||||||
// Export preview with current optimization options
|
// 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 {
|
} catch {
|
||||||
const fixed: Page = {
|
const fixed: Page = {
|
||||||
...pageData,
|
...pageData,
|
||||||
@@ -204,12 +208,12 @@ export default function BuilderClientPage() {
|
|||||||
spamProtection: pageData.form?.spamProtection ?? { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
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 {}
|
} catch {}
|
||||||
}, [pageData, am, fontPreconnect, assetPathStrategy])
|
}, [pageData, am, fontPreconnect, fontPreload, imageLoading, imageDecoding, robotsMeta, assetPathStrategy])
|
||||||
|
|
||||||
const doExport = useCallback(async () => {
|
const doExport = useCallback(async () => {
|
||||||
let valid = pageData as Page
|
let valid = pageData as Page
|
||||||
@@ -237,8 +241,8 @@ export default function BuilderClientPage() {
|
|||||||
}
|
}
|
||||||
return clone
|
return clone
|
||||||
}
|
}
|
||||||
// Respect font optimization toggle when exporting
|
// Respect font, image, robots meta optimization toggles when exporting
|
||||||
const exported = exportPage(valid, { assetManager: buildEffectiveAm(), fontPreconnect })
|
const exported = exportPage(valid, { assetManager: buildEffectiveAm(), fontPreconnect, fontPreload, imageLoading, imageDecoding, robotsMeta: robotsMeta.trim() || undefined })
|
||||||
try {
|
try {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const w = window as unknown as { __captureExport?: boolean; __lastExport?: { html: string; css: string; js: string } }
|
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 })
|
const zipBytes = await buildZip({ html: exported.html, css: exported.css, js: exported.js, assets, extras })
|
||||||
download('landing.zip', zipBytes)
|
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 (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
@@ -375,6 +379,44 @@ export default function BuilderClientPage() {
|
|||||||
/>
|
/>
|
||||||
<span className="text-sm">Enable font preconnect (Google Fonts)</span>
|
<span className="text-sm">Enable font preconnect (Google Fonts)</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-2" htmlFor="ins-font-preload">
|
||||||
|
<input
|
||||||
|
id="ins-font-preload"
|
||||||
|
type="checkbox"
|
||||||
|
aria-label="Font Preload"
|
||||||
|
checked={fontPreload}
|
||||||
|
onChange={(e) => setFontPreload(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Enable font preload (Google Fonts)</span>
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<label className="block" htmlFor="ins-image-loading">
|
||||||
|
<span className="sr-only">Image Loading</span>
|
||||||
|
<select
|
||||||
|
id="ins-image-loading"
|
||||||
|
aria-label="Image Loading"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
value={imageLoading}
|
||||||
|
onChange={(e) => setImageLoading(e.target.value as 'lazy' | 'eager')}
|
||||||
|
>
|
||||||
|
<option value="lazy">Image Loading: lazy</option>
|
||||||
|
<option value="eager">Image Loading: eager</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block" htmlFor="ins-image-decoding">
|
||||||
|
<span className="sr-only">Image Decoding</span>
|
||||||
|
<select
|
||||||
|
id="ins-image-decoding"
|
||||||
|
aria-label="Image Decoding"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
value={imageDecoding}
|
||||||
|
onChange={(e) => setImageDecoding(e.target.value as 'async' | 'sync')}
|
||||||
|
>
|
||||||
|
<option value="async">Image Decoding: async</option>
|
||||||
|
<option value="sync">Image Decoding: sync</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<label className="block" htmlFor="ins-manifest-name">
|
<label className="block" htmlFor="ins-manifest-name">
|
||||||
<span className="sr-only">Manifest Name</span>
|
<span className="sr-only">Manifest Name</span>
|
||||||
<input
|
<input
|
||||||
@@ -446,6 +488,17 @@ export default function BuilderClientPage() {
|
|||||||
placeholder="/private\n/tmp"
|
placeholder="/private\n/tmp"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="block" htmlFor="ins-robots-meta">
|
||||||
|
<span className="sr-only">Robots Meta</span>
|
||||||
|
<input
|
||||||
|
id="ins-robots-meta"
|
||||||
|
aria-label="Robots Meta"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
value={robotsMeta}
|
||||||
|
onChange={(e) => setRobotsMeta(e.target.value)}
|
||||||
|
placeholder="noindex, nofollow"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<label className="block" htmlFor="ins-sheets-mode">
|
<label className="block" htmlFor="ins-sheets-mode">
|
||||||
<span className="sr-only">Google Sheets Template Mode</span>
|
<span className="sr-only">Google Sheets Template Mode</span>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -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/)
|
||||||
|
})
|
||||||
|
})
|
||||||
+19
-2
@@ -2,7 +2,14 @@ import type { Page, FormField } from '@/lib/schema/page'
|
|||||||
import type { AssetManager } from '@/lib/assets/assetManager'
|
import type { AssetManager } from '@/lib/assets/assetManager'
|
||||||
|
|
||||||
export type Exported = { html: string; css: string; js: string }
|
export type Exported = { html: string; css: string; js: string }
|
||||||
export type ExportOptions = { assetManager?: AssetManager; fontPreconnect?: boolean }
|
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 }> } }) {
|
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 : []
|
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 am = opts?.assetManager
|
||||||
const imageUrlRaw: string = section.props.imageUrl
|
const imageUrlRaw: string = section.props.imageUrl
|
||||||
const imageUrl = am ? am.rewriteUrl(imageUrlRaw) : imageUrlRaw
|
const imageUrl = am ? am.rewriteUrl(imageUrlRaw) : imageUrlRaw
|
||||||
|
const loading = opts?.imageLoading ?? 'lazy'
|
||||||
|
const decoding = opts?.imageDecoding ?? 'async'
|
||||||
return `
|
return `
|
||||||
<section class="hero" aria-labelledby="hero-heading">
|
<section class="hero" aria-labelledby="hero-heading">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -124,7 +133,7 @@ function renderHero(section: { props: { heading: string; subheading?: string; im
|
|||||||
${subheading.length > 0 ? `<p>${escapeHtml(subheading)}</p>` : ''}
|
${subheading.length > 0 ? `<p>${escapeHtml(subheading)}</p>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="media">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -251,14 +260,22 @@ function buildHead(page: Page, opts?: ExportOptions) {
|
|||||||
}
|
}
|
||||||
const custom = page.analytics?.customHeadHtml || ''
|
const custom = page.analytics?.customHeadHtml || ''
|
||||||
const preconnect = opts?.fontPreconnect !== false
|
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 `
|
return `
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="canonical" href="/" />
|
<link rel="canonical" href="/" />
|
||||||
${preconnect ? '<link rel="preconnect" href="https://fonts.googleapis.com" />' : ''}
|
${preconnect ? '<link rel="preconnect" href="https://fonts.googleapis.com" />' : ''}
|
||||||
${preconnect ? '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />' : ''}
|
${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" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
<meta name="theme-color" content="${escapeHtml(page.theme.primaryColor)}" />
|
<meta name="theme-color" content="${escapeHtml(page.theme.primaryColor)}" />
|
||||||
|
${robotsMeta}
|
||||||
<title>${escapeHtml(page.seo.title)}</title>
|
<title>${escapeHtml(page.seo.title)}</title>
|
||||||
<meta name="description" content="${escapeHtml(page.seo.description)}" />
|
<meta name="description" content="${escapeHtml(page.seo.description)}" />
|
||||||
<meta property="og:title" content="${escapeHtml(page.seo.title)}" />
|
<meta property="og:title" content="${escapeHtml(page.seo.title)}" />
|
||||||
|
|||||||
@@ -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"')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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('<meta name="robots"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('옵션으로 robots 메타를 삽입할 수 있다', () => {
|
||||||
|
const page = makePage()
|
||||||
|
const opts: ExportOptions = { robotsMeta: 'noindex,nofollow' }
|
||||||
|
const out = exportPage(page, opts)
|
||||||
|
expect(out.html).toContain('<meta name="robots" content="noindex,nofollow" />')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user