Compare commits

..

6 Commits

Author SHA1 Message Date
jaybe df41aac2b1 feat(exporter): 폰트 최적화 - preconnect(googleapis/gstatic) 및 system-ui 폴백 테스트 추가
Auto PR / open-pr (push) Successful in 20s
Auto Label PR / add-automerge-label (pull_request) Successful in 7s
CI / test (pull_request) Successful in 56s
2025-11-15 00:41:48 +09:00
jaybe a620ff970a Merge branch 'main' of https://gitea.jaybe.dev/jaybe/landing-builder
CI / test (push) Successful in 54s
2025-11-15 00:23:32 +09:00
jaybe 524abda49c ignore 수정 2025-11-15 00:18:12 +09:00
jaybe fe62656d37 Merge pull request 'auto: PR for feat/zip-validation-phase4' (#25) from feat/zip-validation-phase4 into main 2025-11-14 15:12:40 +00:00
jaybe 8ad3a9e677 feat(zip): 루트 파일(extras) 포함 및 site.webmanifest JSON 검증 추가\n\n- favicon.ico/site.webmanifest/robots.txt ZIP 포함 지원\n- 잘못된 manifest JSON 시 에러\n- 테스트(phase4) 추가 및 통과
Auto PR / open-pr (push) Successful in 20s
Auto Label PR / add-automerge-label (pull_request) Successful in 6s
CI / test (pull_request) Successful in 55s
2025-11-15 00:11:05 +09:00
jaybe e9a87773ea Merge pull request 'auto: PR for feat/export-zip-validation' (#22) from feat/export-zip-validation into main 2025-11-14 14:53:48 +00:00
5 changed files with 102 additions and 0 deletions
+1
View File
@@ -39,3 +39,4 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
MAIN_PLAN.md
+29
View File
@@ -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)
})
})
+1
View File
@@ -336,6 +336,7 @@ export function exportPage(page: Page, opts?: ExportOptions): Exported {
<html lang="${escapeHtml(page.locale)}"> <html lang="${escapeHtml(page.locale)}">
<head> <head>
${buildHead(page)} ${buildHead(page)}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
</head> </head>
<body> <body>
+18
View File
@@ -5,6 +5,8 @@ export type BuildZipInput = {
css: string css: string
js: string js: string
assets?: Record<string, Uint8Array> 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> { 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) 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' }) const content = await zip.generateAsync({ type: 'uint8array' })
return content 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()
})
})