diff --git a/lib/exporter/zip.ts b/lib/exporter/zip.ts index 6a0ded6..09b7382 100644 --- a/lib/exporter/zip.ts +++ b/lib/exporter/zip.ts @@ -5,6 +5,8 @@ export type BuildZipInput = { css: string js: string assets?: Record + // root-level extra files like favicon.ico, site.webmanifest, robots.txt + extras?: Record } export async function buildZip(input: BuildZipInput): Promise { @@ -19,6 +21,22 @@ export async function buildZip(input: BuildZipInput): Promise { 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 } diff --git a/lib/exporter/zip.validation.phase4.test.ts b/lib/exporter/zip.validation.phase4.test.ts new file mode 100644 index 0000000..15d26f5 --- /dev/null +++ b/lib/exporter/zip.validation.phase4.test.ts @@ -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: 'aok', + 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: 'aok', + css: 'body{color:#111}', + js: 'console.log("ok")', + extras: { 'site.webmanifest': TE.encode('{ invalid json') }, + } + await expect(buildZip(badInput)).rejects.toThrow() + }) +})