Compare commits

...

9 Commits

Author SHA1 Message Date
jaybe 7bf16bfeee Phase7-PerfSEO: Export Options 문서 업데이트 (robots meta, font preload, image 옵션)
Auto PR / open-pr (push) Successful in 15s
Auto Label PR / add-automerge-label (pull_request) Successful in 3s
CI / test (pull_request) Successful in 1m15s
2025-11-16 10:55:42 +09:00
jaybe 0eb056e6b5 Phase7-PerfSEO: robots meta UI 연동(TDD) 및 Preview/Export 반영 2025-11-16 04:12:46 +09:00
jaybe ed7f6189eb Phase7-PerfSEO: 폰트 preload 옵션 및 이미지 loading/decoding UI 연동 TDD/구현 완료 2025-11-16 04:10:52 +09:00
jaybe 689edeaf24 feat(perf-seo): exporter에 robots 메타 옵션 추가(TDD) 2025-11-15 15:18:23 +09:00
jaybe 13439a384f test(assets): grouped 경로 전략 검증 테스트 추가
Auto PR / open-pr (push) Successful in 21s
Auto Label PR / add-automerge-label (pull_request) Successful in 8s
CI / test (pull_request) Successful in 1m12s
2025-11-15 15:14:19 +09:00
jaybe a5b0f07f2a fix(ci): 누락 파일 추가 및 AssetManager 경로 전략 변경사항 포함
Auto PR / open-pr (push) Failing after 11s
Auto Label PR / add-automerge-label (pull_request) Successful in 7s
CI / test (pull_request) Successful in 1m17s
2025-11-15 15:01:17 +09:00
jaybe 7e7eb1be15 feat(phase6): 자산 경로 구조 옵션(UI/미리보기/Export) + 폰트 preconnect 토글 + extras 자동 보강(robots/manifest) + 문서화
Auto PR / open-pr (push) Failing after 13s
Auto Label PR / add-automerge-label (pull_request) Successful in 7s
CI / test (pull_request) Failing after 35s
2025-11-15 14:28:05 +09:00
jaybe 2d6afaf665 feat(font): Export Options에 폰트 preconnect 토글 추가 및 exporter 옵션 연동
Auto PR / open-pr (push) Failing after 14s
Auto Label PR / add-automerge-label (pull_request) Successful in 8s
CI / test (pull_request) Failing after 36s
2025-11-15 14:22:41 +09:00
jaybe 31eaa9343a feat(export-options): Google Sheets 템플릿 모드 옵션(UI+extras) 추가
Auto PR / open-pr (push) Successful in 22s
Auto Label PR / add-automerge-label (pull_request) Successful in 8s
CI / test (pull_request) Failing after 33s
2025-11-15 13:57:42 +09:00
15 changed files with 605 additions and 21 deletions
+66
View File
@@ -34,3 +34,69 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
## Export Bundle (Landing ZIP)
The Builder can export a production-ready ZIP that contains:
- index.html, styles.css, app.js
- assets/ hashed assets (optionally grouped by type)
- site.webmanifest, robots.txt
- Optional: sheets/ (Google Apps Script template)
- Metadata files: assets.json, assets.index.json
### Export Options UI
- Manifest
- Name, Short Name, Start URL, Display (dropdown), Theme Color (HEX only)
- Robots
- 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
- 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 `<img loading>`
- Decoding: `async` (default) or `sync` for `<img decoding>`
### 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 `<head>`.
### Asset paths and grouping
- AssetManager writes files to `assets/` with stable hash-based filenames.
- Path strategy can be configured:
- `flat` (default): `assets/<hash>.<ext>`
- `grouped`: `assets/images/...`, `assets/fonts/...`, `assets/other/...`
### assets.json
Structured metadata for all assets. Example fields per entry:
- originalName, mimeType, size
- hash8, ext, zipPath
- integrity: `sha256-<base64>` (placeholder, base64 of bytes)
- group: `images|fonts|other`
- referencedAt: array of `local:<originalName>` when referenced by the page
### assets.index.json
Simple index to quickly find assets by group:
- images: string[] of zip paths
- fonts: string[] of zip paths
- other: string[] of zip paths
### Google Sheets template (optional)
- When included, ZIP contains:
- `sheets/Code.gs`: minimal Apps Script handler (Web App)
- `sheets/README.txt`: setup instructions
- Inclusion is controlled by `sheetsMode` (Auto/Include/Exclude) and `form.actionUrl` in Auto mode.
@@ -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"')
})
})
})
+140 -8
View File
@@ -8,6 +8,7 @@ import { AssetManager } from '@/lib/assets/assetManager'
import { buildZip } from '@/lib/exporter/zip' import { buildZip } from '@/lib/exporter/zip'
import { buildExtrasForPage } from '@/lib/exporter/extras' import { buildExtrasForPage } from '@/lib/exporter/extras'
import { buildAssetsMetadata } from '@/lib/exporter/assets.meta' import { buildAssetsMetadata } from '@/lib/exporter/assets.meta'
import { buildAssetsIndex } from '@/lib/exporter/assets.index'
import type { Section } from '@/lib/state/store' import type { Section } from '@/lib/state/store'
import { createFormStore, type FormState } from '@/lib/state/formStore' import { createFormStore, type FormState } from '@/lib/state/formStore'
import FormBuilderPanel from '@/components/FormBuilderPanel' import FormBuilderPanel from '@/components/FormBuilderPanel'
@@ -56,6 +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 [assetPathStrategy, setAssetPathStrategy] = useState<'flat' | 'grouped'>('flat')
// Font optimization: allow toggling Google Fonts preconnect links in exported HTML
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 {
@@ -174,9 +183,22 @@ export default function BuilderClientPage() {
try { try {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const w = window as unknown as { __exportPreview?: () => { html: string; css: string; js: string } } const w = window as unknown as { __exportPreview?: () => { html: string; css: string; js: string } }
// Build an effective AssetManager based on selected path strategy
const buildEffectiveAm = (): AssetManager | undefined => {
const base = am ?? undefined
if (!base) return undefined
if (assetPathStrategy === 'flat') return base
const clone = new AssetManager({ maxBytes: 10 * 1024 * 1024, allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'], pathStrategy: assetPathStrategy })
for (const ref of base.list()) {
// re-add to apply new path strategy and recreate byOriginal mapping
clone.add({ name: ref.originalName, type: ref.mimeType, bytes: ref.bytes })
}
return clone
}
w.__exportPreview = () => { w.__exportPreview = () => {
try { try {
return exportPage(pageSchema.parse(pageData), { assetManager: am ?? undefined }) // Export preview with current optimization options
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,
@@ -186,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: am ?? undefined }) return exportPage(pageSchema.parse(fixed), { assetManager: buildEffectiveAm(), fontPreconnect, fontPreload, imageLoading, imageDecoding, robotsMeta: robotsMeta.trim() || undefined })
} }
} }
} }
} catch {} } catch {}
}, [pageData, am]) }, [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
@@ -208,7 +230,19 @@ export default function BuilderClientPage() {
} as Page } as Page
valid = pageSchema.parse(fixed) valid = pageSchema.parse(fixed)
} }
const exported = exportPage(valid, { assetManager: am ?? undefined }) // Build an effective AssetManager based on selected path strategy
const buildEffectiveAm = (): AssetManager | undefined => {
const base = am ?? undefined
if (!base) return undefined
if (assetPathStrategy === 'flat') return base
const clone = new AssetManager({ maxBytes: 10 * 1024 * 1024, allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'], pathStrategy: assetPathStrategy })
for (const ref of base.list()) {
clone.add({ name: ref.originalName, type: ref.mimeType, bytes: ref.bytes })
}
return clone
}
// Respect font, image, robots meta optimization toggles when exporting
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 } }
@@ -217,7 +251,17 @@ export default function BuilderClientPage() {
} }
} }
} catch {} } catch {}
const assets = am ? am.toZipStructure() : {} const effectiveAm = (() => {
const base = am ?? undefined
if (!base) return undefined
if (assetPathStrategy === 'flat') return base
const clone = new AssetManager({ maxBytes: 10 * 1024 * 1024, allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'], pathStrategy: assetPathStrategy })
for (const ref of base.list()) {
clone.add({ name: ref.originalName, type: ref.mimeType, bytes: ref.bytes })
}
return clone
})()
const assets = effectiveAm ? effectiveAm.toZipStructure() : {}
// Build overrides from UI options // Build overrides from UI options
const manifestOverrideBase: Record<string, string> = {} const manifestOverrideBase: Record<string, string> = {}
if (manifestName && manifestName.trim().length > 0) manifestOverrideBase.name = manifestName.trim() if (manifestName && manifestName.trim().length > 0) manifestOverrideBase.name = manifestName.trim()
@@ -238,14 +282,14 @@ export default function BuilderClientPage() {
const disallowLines = lines.map((l) => (l.startsWith('Disallow:') ? l : `Disallow: ${l}`)) const disallowLines = lines.map((l) => (l.startsWith('Disallow:') ? l : `Disallow: ${l}`))
return `User-agent: *\n${disallowLines.join('\n')}` return `User-agent: *\n${disallowLines.join('\n')}`
})() })()
const baseExtras = buildExtrasForPage(valid, { robotsText: robotsTextOverride, manifest: manifestOverride }) const baseExtras = buildExtrasForPage(valid, { robotsText: robotsTextOverride, manifest: manifestOverride, sheetsMode })
const extras: Record<string, Uint8Array> = { const extras: Record<string, Uint8Array> = {
...baseExtras, ...baseExtras,
...(am ? { 'assets.json': buildAssetsMetadata(am) } : {}), ...(am ? { 'assets.json': buildAssetsMetadata(am), 'assets.index.json': buildAssetsIndex(am) } : {}),
} }
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]) }, [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">
@@ -310,6 +354,69 @@ export default function BuilderClientPage() {
</div> </div>
<div className="border rounded p-3 space-y-2"> <div className="border rounded p-3 space-y-2">
<h2 className="text-sm font-semibold mb-1">Export Options</h2> <h2 className="text-sm font-semibold mb-1">Export Options</h2>
{/* Asset path strategy: flat vs grouped */}
<label className="block" htmlFor="ins-asset-path-strategy">
<span className="sr-only">Asset Path Strategy</span>
<select
id="ins-asset-path-strategy"
aria-label="Asset Path Strategy"
className="border rounded px-3 py-2 w-full"
value={assetPathStrategy}
onChange={(e) => setAssetPathStrategy(e.target.value as 'flat' | 'grouped')}
>
<option value="flat">Assets Path: Flat</option>
<option value="grouped">Assets Path: Grouped (images/fonts/other)</option>
</select>
</label>
{/* Font optimization toggle for Google Fonts preconnect */}
<label className="flex items-center gap-2" htmlFor="ins-font-preconnect">
<input
id="ins-font-preconnect"
type="checkbox"
aria-label="Font Preconnect"
checked={fontPreconnect}
onChange={(e) => setFontPreconnect(e.target.checked)}
/>
<span className="text-sm">Enable font preconnect (Google Fonts)</span>
</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
@@ -381,6 +488,31 @@ 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">
<span className="sr-only">Google Sheets Template Mode</span>
<select
id="ins-sheets-mode"
aria-label="Google Sheets Template Mode"
className="border rounded px-3 py-2 w-full"
value={sheetsMode}
onChange={(e) => setSheetsMode(e.target.value as 'auto'|'include'|'exclude')}
>
<option value="auto">Sheets Template: Auto</option>
<option value="include">Sheets Template: Include</option>
<option value="exclude">Sheets Template: Exclude</option>
</select>
</label>
</div> </div>
</div> </div>
} }
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest'
import { AssetManager } from '@/lib/assets/assetManager'
function u8(n: number): Uint8Array { const a = new Uint8Array(n); for (let i=0;i<n;i++) a[i]=i&0xff; return a }
describe('AssetManager pathStrategy grouped', () => {
it('writes assets under assets/images and assets/fonts when grouped', async () => {
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','woff2'], pathStrategy: 'grouped' })
const img = await am.add({ name: 'hero.png', type: 'image/png', bytes: u8(10) })
const font = await am.add({ name: 'main.woff2', type: 'font/woff2', bytes: u8(12) })
expect(img.path).toMatch(/^assets\/images\/[0-9a-f]{8}\.png$/)
expect(font.path).toMatch(/^assets\/fonts\/[0-9a-f]{8}\.woff2$/)
const zip = am.toZipStructure()
const keys = Object.keys(zip)
expect(keys.some(k => /^assets\/images\//.test(k))).toBe(true)
expect(keys.some(k => /^assets\/fonts\//.test(k))).toBe(true)
})
it('rewriteUrl marks referenced originals and returns grouped path', async () => {
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png'], pathStrategy: 'grouped' })
const img = await am.add({ name: 'hero.png', type: 'image/png', bytes: u8(10) })
const rewritten = am.rewriteUrl('local:hero.png')
expect(rewritten).toBe(img.path)
expect(am.referencedList()).toContain('hero.png')
})
})
+14 -1
View File
@@ -16,6 +16,7 @@ export type AssetRef = {
export type AssetManagerOptions = { export type AssetManagerOptions = {
maxBytes: number maxBytes: number
allowedExts: string[] // e.g., ['png','jpg','jpeg','webp','svg'] allowedExts: string[] // e.g., ['png','jpg','jpeg','webp','svg']
pathStrategy?: 'flat' | 'grouped'
} }
function extOf(name: string): string { function extOf(name: string): string {
@@ -36,6 +37,7 @@ function hash8(bytes: Uint8Array): string {
export class AssetManager { export class AssetManager {
private readonly maxBytes: number private readonly maxBytes: number
private readonly allowed: Set<string> private readonly allowed: Set<string>
private readonly pathStrategy: 'flat' | 'grouped'
private byHash = new Map<string, AssetRef>() private byHash = new Map<string, AssetRef>()
private byOriginal = new Map<string, string>() // originalName -> path private byOriginal = new Map<string, string>() // originalName -> path
private referenced = new Set<string>() // originalName referenced via rewrite private referenced = new Set<string>() // originalName referenced via rewrite
@@ -43,6 +45,7 @@ export class AssetManager {
constructor(opts: AssetManagerOptions) { constructor(opts: AssetManagerOptions) {
this.maxBytes = opts.maxBytes this.maxBytes = opts.maxBytes
this.allowed = new Set(opts.allowedExts.map((e) => e.toLowerCase())) this.allowed = new Set(opts.allowedExts.map((e) => e.toLowerCase()))
this.pathStrategy = opts.pathStrategy ?? 'flat'
} }
async add(file: AssetInput): Promise<AssetRef> { async add(file: AssetInput): Promise<AssetRef> {
@@ -61,7 +64,7 @@ export class AssetManager {
return existing return existing
} }
const path = `assets/${h}.${ext}` const path = this.makePath(h, ext, file.type)
const ref: AssetRef = { const ref: AssetRef = {
originalName: file.name, originalName: file.name,
mimeType: file.type, mimeType: file.type,
@@ -101,4 +104,14 @@ export class AssetManager {
referencedList(): string[] { referencedList(): string[] {
return Array.from(this.referenced) return Array.from(this.referenced)
} }
private makePath(hash8: string, ext: string, mime: string): string {
if (this.pathStrategy === 'grouped') {
let group = 'other'
if (mime.startsWith('image/')) group = 'images'
else if (mime.startsWith('font/')) group = 'fonts'
return `assets/${group}/${hash8}.${ext}`
}
return `assets/${hash8}.${ext}`
}
} }
+22
View File
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest'
import { AssetManager } from '@/lib/assets/assetManager'
import { buildAssetsIndex } from '@/lib/exporter/assets.index'
function u8(n: number): Uint8Array { const a = new Uint8Array(n); for (let i=0;i<n;i++) a[i]=i&0xff; return a }
describe('Assets index builder', () => {
it('groups assets by mime into images/fonts/other and lists zipPath', async () => {
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','woff2','txt'] })
await am.add({ name: 'a.png', type: 'image/png', bytes: u8(4) })
await am.add({ name: 'b.woff2', type: 'font/woff2', bytes: u8(6) })
await am.add({ name: 'c.txt', type: 'text/plain', bytes: u8(2) })
const bytes = buildAssetsIndex(am)
const obj = JSON.parse(new TextDecoder().decode(bytes)) as { images: string[]; fonts: string[]; other: string[] }
expect(obj.images.length).toBe(1)
expect(obj.fonts.length).toBe(1)
expect(obj.other.length).toBe(1)
expect(obj.images[0]).toMatch(/^assets\/[0-9a-f]{8}\.png$/)
expect(obj.fonts[0]).toMatch(/^assets\/[0-9a-f]{8}\.woff2$/)
expect(obj.other[0]).toMatch(/^assets\/[0-9a-f]{8}\.txt$/)
})
})
+21
View File
@@ -0,0 +1,21 @@
import type { AssetManager } from '@/lib/assets/assetManager'
export type AssetsIndex = {
images: string[]
fonts: string[]
other: string[]
}
export function buildAssetsIndex(am: AssetManager): Uint8Array {
const images: string[] = []
const fonts: string[] = []
const other: string[] = []
for (const ref of am.list()) {
if (ref.mimeType.startsWith('image/')) images.push(ref.path)
else if (ref.mimeType.startsWith('font/')) fonts.push(ref.path)
else other.push(ref.path)
}
const obj: AssetsIndex = { images, fonts, other }
const json = JSON.stringify(obj, null, 2)
return new TextEncoder().encode(json)
}
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { exportPage } from '@/lib/exporter/html'
import { pageSchema, type Page } from '@/lib/schema/page'
import { AssetManager } from '@/lib/assets/assetManager'
function makePage(): Page {
return pageSchema.parse({
title: 'Path Strategy',
locale: 'en',
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
sections: [ { type: 'hero', props: { heading: 'Hello', subheading: 'World', imageUrl: 'local:hero.png' } } ],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 't', description: 'd' },
})
}
describe('자산 경로 구조 옵션', () => {
it('grouped 전략일 때 이미지가 assets/images/ 경로로 렌더된다', async () => {
const page = makePage()
const am = new AssetManager({ maxBytes: 1024 * 1024, allowedExts: ['png'], pathStrategy: 'grouped' })
// add hero image asset
const bytes = new Uint8Array([1,2,3,4,5])
await am.add({ name: 'hero.png', type: 'image/png', bytes })
const out = exportPage(page, { assetManager: am })
expect(out.html).toMatch(/assets\/images\/[a-f0-9]{8}\.png/i)
})
})
+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest'
import { buildExtrasForPage } from '@/lib/exporter/extras'
import { pageSchema, type Page } from '@/lib/schema/page'
function makePage(): Page {
return pageSchema.parse({
title: 'My Product',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [ { type: 'hero', props: { heading: 'Hello', subheading: '', imageUrl: 'https://example.com/hero.png' } } ],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 't', description: 'd' },
})
}
function toStr(bytes: Uint8Array) {
return new TextDecoder().decode(bytes)
}
describe('extras 자동 보강', () => {
it('manifest: 잘못된 theme_color는 무시하고 기본(primaryColor) 사용', () => {
const page = makePage()
const out = buildExtrasForPage(page, { manifest: { theme_color: 'not-a-hex' } })
const manifest = JSON.parse(toStr(out['site.webmanifest']))
expect(manifest.theme_color).toBe('#0ea5e9')
})
it('manifest: short_name은 12자 이내로 자동 절단', () => {
const page = makePage()
const out = buildExtrasForPage(page, { manifest: { name: 'Super Long Product Name' } })
const manifest = JSON.parse(toStr(out['site.webmanifest']))
expect(manifest.short_name.length).toBeLessThanOrEqual(12)
})
it('robots: 주어진 plain 경로 목록을 자동으로 User-agent 헤더와 Disallow 라인으로 보강', () => {
const page = makePage()
const robotsPlain = "/admin\n/private"
const out = buildExtrasForPage(page, { robotsText: robotsPlain })
const robots = toStr(out['robots.txt'])
expect(robots).toMatch(/^User-agent: \*/)
expect(robots).toMatch(/Disallow: \/admin/)
expect(robots).toMatch(/Disallow: \/private/)
})
})
+24 -5
View File
@@ -9,6 +9,7 @@ export type ExtrasOverrides = {
display: string display: string
theme_color: string theme_color: string
}> }>
sheetsMode?: 'auto' | 'include' | 'exclude'
} }
function toBytes(s: string): Uint8Array { function toBytes(s: string): Uint8Array {
@@ -16,7 +17,14 @@ function toBytes(s: string): Uint8Array {
} }
function buildRobots(base?: string): string { function buildRobots(base?: string): string {
if (base && base.trim().length > 0) return base if (base && base.trim().length > 0) {
const s = base.trim()
if (/^User-agent:/i.test(s)) return s
const lines = s.split(/\n+/).map((l) => l.trim()).filter((l) => l.length > 0)
if (lines.length === 0) return 'User-agent: *\n'
const disallowLines = lines.map((l) => (l.startsWith('Disallow:') ? l : `Disallow: ${l}`))
return `User-agent: *\n${disallowLines.join('\n')}`
}
return 'User-agent: *\n' return 'User-agent: *\n'
} }
@@ -36,16 +44,27 @@ export function buildExtrasForPage(page: Page, overrides?: ExtrasOverrides): Rec
const robotsText = buildRobots(overrides?.robotsText) const robotsText = buildRobots(overrides?.robotsText)
out['robots.txt'] = toBytes(robotsText) out['robots.txt'] = toBytes(robotsText)
// site.webmanifest // site.webmanifest (auto-enrich and safe-merge)
const manifestBase = deriveManifest(page) const manifestBase = deriveManifest(page)
const manifestMerged = { ...manifestBase, ...(overrides?.manifest || {}) } const ov = overrides?.manifest || {}
const name = ov.name && ov.name.trim().length > 0 ? ov.name : manifestBase.name
const short_name = ov.short_name && ov.short_name.trim().length > 0
? ov.short_name
: (name.length > 12 ? name.slice(0, 12) : name)
const start_url = ov.start_url && ov.start_url.trim().length > 0 ? ov.start_url : manifestBase.start_url
const display = ov.display && ov.display.trim().length > 0 ? ov.display : manifestBase.display
const hexOk = ov.theme_color ? /^#([0-9a-fA-F]{3}){1,2}$/.test(ov.theme_color) : false
const theme_color = hexOk ? (ov.theme_color as string) : manifestBase.theme_color
const manifestMerged = { name, short_name, start_url, display, theme_color }
const manifestJson = JSON.stringify(manifestMerged, null, 2) const manifestJson = JSON.stringify(manifestMerged, null, 2)
out['site.webmanifest'] = toBytes(manifestJson) out['site.webmanifest'] = toBytes(manifestJson)
// Google Sheets 템플릿: actionUrl 미지정 시 포함 // Google Sheets 템플릿: 기본 auto. overrides.sheetsMode로 강제 포함/제외 가능
const mode = overrides?.sheetsMode ?? 'auto'
const action = page.form?.actionUrl?.trim() const action = page.form?.actionUrl?.trim()
const hasAction = !!(action && action.length > 0) const hasAction = !!(action && action.length > 0)
if (!hasAction) { const shouldInclude = mode === 'include' || (mode === 'auto' && !hasAction)
if (shouldInclude) {
const code = `/** const code = `/**
* Google Apps Script Web App for form submissions * Google Apps Script Web App for form submissions
* - Deploy as Web App and set URL as form action if needed. * - Deploy as Web App and set URL as form action if needed.
+35
View File
@@ -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/)
})
})
+24 -7
View File
@@ -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 } 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>
@@ -222,7 +231,7 @@ function renderForm(page: Page) {
` `
} }
function buildHead(page: Page) { function buildHead(page: Page, opts?: ExportOptions) {
const ogImage = page.seo.ogImage const ogImage = page.seo.ogImage
const analyticsParts: string[] = [] const analyticsParts: string[] = []
const ga = page.analytics?.ga4MeasurementId const ga = page.analytics?.ga4MeasurementId
@@ -250,13 +259,23 @@ function buildHead(page: Page) {
</script>`) </script>`)
} }
const custom = page.analytics?.customHeadHtml || '' 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 ` 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="/" />
<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 />' : ''}
${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)}" />
@@ -338,9 +357,7 @@ export function exportPage(page: Page, opts?: ExportOptions): Exported {
const html = `<!DOCTYPE html> const html = `<!DOCTYPE html>
<html lang="${escapeHtml(page.locale)}"> <html lang="${escapeHtml(page.locale)}">
<head> <head>
${buildHead(page)} ${buildHead(page, opts)}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
</head> </head>
<body> <body>
<a href="#main" class="skip-link">Skip to content</a> <a href="#main" class="skip-link">Skip to content</a>
+31
View File
@@ -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"')
})
})
+29
View File
@@ -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" />')
})
})