From 9473b84b81b063d3a0de963f62c8e467d2c0e410 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sat, 15 Nov 2025 00:11:05 +0900 Subject: [PATCH] =?UTF-8?q?feat(zip):=20=EB=A3=A8=ED=8A=B8=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC(extras)=20=ED=8F=AC=ED=95=A8=20=EB=B0=8F=20site.webma?= =?UTF-8?q?nifest=20JSON=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80\n\n-=20?= =?UTF-8?q?favicon.ico/site.webmanifest/robots.txt=20ZIP=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8=20=EC=A7=80=EC=9B=90\n-=20=EC=9E=98=EB=AA=BB=EB=90=9C?= =?UTF-8?q?=20manifest=20JSON=20=EC=8B=9C=20=EC=97=90=EB=9F=AC\n-=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8(phase4)=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=ED=86=B5=EA=B3=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/exporter/zip.ts | 18 ++++++++ lib/exporter/zip.validation.phase4.test.ts | 53 ++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 lib/exporter/zip.validation.phase4.test.ts 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() + }) +})