Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df41aac2b1 | |||
| a620ff970a | |||
| 524abda49c | |||
| fe62656d37 | |||
| 8ad3a9e677 | |||
| e9a87773ea |
@@ -39,3 +39,4 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
MAIN_PLAN.md
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportPage } from '@/lib/exporter/html'
|
||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||
|
||||
function makePage(): Page {
|
||||
return pageSchema.parse({
|
||||
title: 'Font Opt',
|
||||
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('Exporter - 폰트 최적화', () => {
|
||||
it('head에 폰트 preconnect 링크가 포함된다(googleapis/gstatic)', () => {
|
||||
const out = exportPage(makePage())
|
||||
const html = out.html
|
||||
expect(html).toMatch(/<link rel="preconnect" href="https:\/\/fonts\.googleapis\.com">/)
|
||||
expect(html).toMatch(/<link rel="preconnect" href="https:\/\/fonts\.gstatic\.com" crossorigin>/)
|
||||
})
|
||||
|
||||
it('CSS에 system-ui 폴백이 포함된다', () => {
|
||||
const out = exportPage(makePage())
|
||||
const css = out.css
|
||||
expect(css).toMatch(/font-family:[^;]*system-ui/i)
|
||||
})
|
||||
})
|
||||
@@ -336,6 +336,7 @@ export function exportPage(page: Page, opts?: ExportOptions): Exported {
|
||||
<html lang="${escapeHtml(page.locale)}">
|
||||
<head>
|
||||
${buildHead(page)}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -5,6 +5,8 @@ export type BuildZipInput = {
|
||||
css: string
|
||||
js: string
|
||||
assets?: Record<string, Uint8Array>
|
||||
// root-level extra files like favicon.ico, site.webmanifest, robots.txt
|
||||
extras?: Record<string, Uint8Array>
|
||||
}
|
||||
|
||||
export async function buildZip(input: BuildZipInput): Promise<Uint8Array> {
|
||||
@@ -19,6 +21,22 @@ export async function buildZip(input: BuildZipInput): Promise<Uint8Array> {
|
||||
zip.file(path, buf as unknown as Buffer)
|
||||
}
|
||||
}
|
||||
if (input.extras) {
|
||||
// validate manifest if present
|
||||
if (Object.prototype.hasOwnProperty.call(input.extras, 'site.webmanifest')) {
|
||||
try {
|
||||
const manifestBytes = input.extras['site.webmanifest']
|
||||
const manifestStr = Buffer.from(manifestBytes).toString('utf8')
|
||||
JSON.parse(manifestStr)
|
||||
} catch {
|
||||
throw new Error('Invalid site.webmanifest JSON')
|
||||
}
|
||||
}
|
||||
for (const [path, bytes] of Object.entries(input.extras)) {
|
||||
const buf = Buffer.from(bytes)
|
||||
zip.file(path, buf as unknown as Buffer)
|
||||
}
|
||||
}
|
||||
const content = await zip.generateAsync({ type: 'uint8array' })
|
||||
return content
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import JSZip from 'jszip'
|
||||
import { buildZip } from '@/lib/exporter/zip'
|
||||
import type { BuildZipInput } from '@/lib/exporter/zip'
|
||||
|
||||
const TE = new TextEncoder()
|
||||
|
||||
describe('Export ZIP 검증 강화 4차: 루트 파일(favicon/manifest/robots)', () => {
|
||||
it('루트 파일들을 포함한다: favicon.ico / site.webmanifest / robots.txt', async () => {
|
||||
const manifest = {
|
||||
name: 'Landing Builder',
|
||||
short_name: 'LB',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
icons: [{ src: '/favicon.png', sizes: '512x512', type: 'image/png' }],
|
||||
}
|
||||
const input: BuildZipInput = {
|
||||
html: '<!DOCTYPE html><html><head><title>a</title></head><body>ok</body></html>',
|
||||
css: 'body{color:#111}',
|
||||
js: 'console.log("ok")',
|
||||
assets: {},
|
||||
extras: {
|
||||
'favicon.ico': new Uint8Array([0, 1, 2, 3]),
|
||||
'site.webmanifest': TE.encode(JSON.stringify(manifest)),
|
||||
'robots.txt': TE.encode('User-agent: *\nDisallow:'),
|
||||
},
|
||||
}
|
||||
const zipBytes = await buildZip(input)
|
||||
|
||||
const zip = await JSZip.loadAsync(zipBytes)
|
||||
const names = Object.keys(zip.files)
|
||||
expect(names).toContain('favicon.ico')
|
||||
expect(names).toContain('site.webmanifest')
|
||||
expect(names).toContain('robots.txt')
|
||||
|
||||
const robots = await zip.files['robots.txt'].async('string')
|
||||
expect(robots).toMatch(/User-agent: \*/)
|
||||
|
||||
const parsed = JSON.parse(await zip.files['site.webmanifest'].async('string'))
|
||||
expect(parsed.name).toBe('Landing Builder')
|
||||
expect(parsed.icons[0].type).toBe('image/png')
|
||||
})
|
||||
|
||||
it('잘못된 manifest JSON이면 실패한다', async () => {
|
||||
const badInput: BuildZipInput = {
|
||||
html: '<!DOCTYPE html><html><head><title>a</title></head><body>ok</body></html>',
|
||||
css: 'body{color:#111}',
|
||||
js: 'console.log("ok")',
|
||||
extras: { 'site.webmanifest': TE.encode('{ invalid json') },
|
||||
}
|
||||
await expect(buildZip(badInput)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user