Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ad3a9e677 | |||
| 678b081364 | |||
| 46a9c5c83d | |||
| 6c86e1cf85 | |||
| aaea43f2cc | |||
| bc29faf60c | |||
| 26a3b43d28 | |||
| 6ff27295e6 |
@@ -69,7 +69,7 @@ jobs:
|
||||
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/labels" \
|
||||
-d '{"labels":['"${LID}"']}' || true
|
||||
else
|
||||
echo "No existing 'automerge' found; skip labeling"
|
||||
echo "No existing 'automerge' label found; skipping label add"
|
||||
fi
|
||||
else
|
||||
echo "PR number could not be determined; skipping label add"
|
||||
|
||||
@@ -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 { exportPage } from '@/lib/exporter/html'
|
||||
import { buildZip } from '@/lib/exporter/zip'
|
||||
import { AssetManager } from '@/lib/assets/assetManager'
|
||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||
|
||||
function makePageWithLocalAsset(): { page: Page; am: AssetManager } {
|
||||
const am = new AssetManager({ maxBytes: 1024 * 1024, allowedExts: ['png','svg','jpg','jpeg','webp'] })
|
||||
// simulate adding a local image and referencing it by original name via local: prefix
|
||||
const bytes = new Uint8Array([7,8,9])
|
||||
// add as "hero.png"
|
||||
am.add({ name: 'hero.png', type: 'image/png', bytes })
|
||||
|
||||
const base: Page = pageSchema.parse({
|
||||
title: 'Integrated ZIP',
|
||||
locale: 'en',
|
||||
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
|
||||
sections: [
|
||||
{ type: 'hero', props: { heading: 'Hello', subheading: 'World', imageUrl: 'local:hero.png' } },
|
||||
{ type: 'cta', props: { text: 'Go', href: '#' } },
|
||||
],
|
||||
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||
analytics: { ga4MeasurementId: 'G-TEST', metaPixelId: '', customHeadHtml: '' },
|
||||
seo: { title: 't', description: 'd' },
|
||||
})
|
||||
return { page: base, am }
|
||||
}
|
||||
|
||||
describe('Export ZIP 통합 검증', () => {
|
||||
it('exportPage 출력 + buildZip 결과에서 assets 경로/메타가 보존된다', async () => {
|
||||
const { page, am } = makePageWithLocalAsset()
|
||||
const out = exportPage(page, { assetManager: am })
|
||||
const assets = am.toZipStructure()
|
||||
const zipBytes = await buildZip({ html: out.html, css: out.css, js: out.js, assets })
|
||||
|
||||
const zip = await JSZip.loadAsync(zipBytes)
|
||||
const names = Object.keys(zip.files)
|
||||
// assets 파일이 존재해야 함
|
||||
const assetKey = Object.keys(assets)[0]
|
||||
expect(names).toContain(assetKey)
|
||||
|
||||
// index.html에 canonical/meta/ga4 스니펫 보존
|
||||
const html = await zip.files['index.html'].async('string')
|
||||
expect(html).toMatch(/<link rel="canonical" href="\//)
|
||||
expect(html).toMatch(/<meta name="description" content="d"/)
|
||||
expect(html).toMatch(/gtag\('config', 'G-TEST'\)/)
|
||||
|
||||
// hero 이미지가 재작성된 경로를 사용한다
|
||||
const match = html.match(/<img[^>]*src="([^"]+)"/)
|
||||
expect(match && match[1]).toBe(assetKey)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import JSZip from 'jszip'
|
||||
import { buildZip } from '@/lib/exporter/zip'
|
||||
|
||||
describe('Export ZIP 메타 보존 검증', () => {
|
||||
it('index.html의 SEO/OG/Analytics 메타가 그대로 보존된다', async () => {
|
||||
const html = `<!DOCTYPE html><html><head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="canonical" href="/" />
|
||||
<meta name="description" content="desc" />
|
||||
<meta property="og:title" content="OT" />
|
||||
<meta property="og:description" content="OD" />
|
||||
<script>window.dataLayer=[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());</script>
|
||||
</head><body>ok</body></html>`
|
||||
const css = 'body{color:#222}'
|
||||
const js = 'console.log("ok")'
|
||||
|
||||
const zipBytes = await buildZip({ html, css, js, assets: {} })
|
||||
const zip = await JSZip.loadAsync(zipBytes)
|
||||
const htmlContent = await zip.files['index.html'].async('string')
|
||||
|
||||
expect(htmlContent).toMatch(/<link rel="canonical" href="\//)
|
||||
expect(htmlContent).toMatch(/<meta name="description" content="desc"/)
|
||||
expect(htmlContent).toMatch(/<meta property="og:title" content="OT"/)
|
||||
expect(htmlContent).toMatch(/<meta property="og:description" content="OD"/)
|
||||
expect(htmlContent).toMatch(/window\.dataLayer=\[\];/)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import JSZip from 'jszip'
|
||||
import { buildZip } from '@/lib/exporter/zip'
|
||||
|
||||
const TE = new TextEncoder()
|
||||
|
||||
describe('Export ZIP 검증 강화', () => {
|
||||
it('assets가 비어있을 때 index.html/styles.css/app.js만 포함한다', async () => {
|
||||
const zipBytes = await buildZip({
|
||||
html: '<!DOCTYPE html><html><head><title>a</title></head><body>ok</body></html>',
|
||||
css: 'body{color:#111}',
|
||||
js: 'console.log("ok")',
|
||||
assets: {},
|
||||
})
|
||||
const zip = await JSZip.loadAsync(zipBytes)
|
||||
const names = Object.keys(zip.files).sort()
|
||||
expect(names).toEqual(['app.js','index.html','styles.css'])
|
||||
})
|
||||
|
||||
it('assets가 있으면 모든 파일을 그대로 포함한다(서브폴더 포함)', async () => {
|
||||
const assets = {
|
||||
'assets/img/logo.png': new Uint8Array([0,1,2,3]),
|
||||
'assets/svg/icon.svg': TE.encode('<svg></svg>'),
|
||||
'assets/fonts/inter.woff2': new Uint8Array([4,5,6]),
|
||||
}
|
||||
const zipBytes = await buildZip({
|
||||
html: '<!DOCTYPE html><html><head><title>a</title></head><body>ok</body></html>',
|
||||
css: 'body{color:#111}',
|
||||
js: 'console.log("ok")',
|
||||
assets,
|
||||
})
|
||||
const zip = await JSZip.loadAsync(zipBytes)
|
||||
const names = Object.keys(zip.files)
|
||||
expect(names).toContain('index.html')
|
||||
expect(names).toContain('styles.css')
|
||||
expect(names).toContain('app.js')
|
||||
expect(names).toContain('assets/img/logo.png')
|
||||
expect(names).toContain('assets/svg/icon.svg')
|
||||
expect(names).toContain('assets/fonts/inter.woff2')
|
||||
|
||||
const logo = await zip.files['assets/img/logo.png'].async('uint8array')
|
||||
expect(Array.from(logo)).toEqual([0,1,2,3])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user