diff --git a/MAIN_PLAN.md b/MAIN_PLAN.md index 69cf910..0807e05 100644 --- a/MAIN_PLAN.md +++ b/MAIN_PLAN.md @@ -369,9 +369,14 @@ - `assets.index.json` 그룹 정렬/경로 전략(grouped/flat) 커버리지 확장 - `referencedAt` 추적(HTML 내 `rewriteUrl('local:...')`) 매트릭스 테스트 - `verifyIntegrity` 유틸 구현 및 TDD(정상/변조/누락/번들해시 불일치) 통과 +- `assets.integrity.index.json`에 `size`(byte) 추가, `verifyIntegrity`에서 크기 불일치 검증 +- `verifyIntegrity` 결과에 `summary`(total/missing/integrityMismatches/sizeMismatches/bundleHashMismatch) 추가 - Export Options(폰트 프리커넥트/프리로드, 이미지 로딩/디코딩, robots 메타) UI/문서화 - 브랜치 푸쉬 완료: `feat/export-bundle-integrity` +### 하드닝 +- ZIP 경로 가드: `../`, 절대경로(`/`), 역슬래시(`\`) 차단 (테스트 포함) + ### TDD 계획 및 상태 - 테스트 선작성 → 최소 구현 → 리팩터/문서화 - 현재 테스트: 110 files / 173 tests 모두 통과 @@ -384,7 +389,7 @@ ### 다음 작업 1) PR 생성(템플릿/라벨): `feat/export-bundle-integrity` → `main` -2) CI 무결성 검증 스텝 추가: `verifyIntegrity`로 빌드 산출물 검사 +2) CI 무결성 검증 스텝 추가: `verifyIntegrity`로 빌드 산출물 검사(요약 리포트 로깅) 3) PR 라벨 자동화 및 조건부 머지(Gitea Actions/웹훅) 4) README에 사용 가이드 보완(verifyIntegrity 활용 예시) diff --git a/README.md b/README.md index ce13530..d7486e6 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,20 @@ Usage: - Verify file size matches `entries[].size`. - Verify bundle integrity by recomputing `bundleHash` from sorted entries; useful for quick tamper detection. +Example (Node): + +```ts +import { verifyIntegrity } from '@/lib/exporter/verify' + +// extras must include 'assets.integrity.index.json', assets is a map of zipPath -> bytes +const result = verifyIntegrity(extras, assets) +if (!result.ok) { + console.error('Integrity check failed:', result.errors) +} +console.log('Summary:', result.summary) +// { total, missing, integrityMismatches, sizeMismatches, bundleHashMismatch } +``` + ### Google Sheets template (optional) - When included, ZIP contains: diff --git a/lib/exporter/html.ts b/lib/exporter/html.ts index 69409c1..ae52a8a 100644 --- a/lib/exporter/html.ts +++ b/lib/exporter/html.ts @@ -9,6 +9,9 @@ export type ExportOptions = { robotsMeta?: string imageLoading?: 'lazy' | 'eager' imageDecoding?: 'async' | 'sync' + imageFetchPriority?: 'high' | 'low' + imageSrcset?: string + imageSizes?: string } function renderFaq(section: { props: { items: Array<{ q: string; a: string }> } }) { @@ -125,6 +128,9 @@ function renderHero(section: { props: { heading: string; subheading?: string; im const imageUrl = am ? am.rewriteUrl(imageUrlRaw) : imageUrlRaw const loading = opts?.imageLoading ?? 'lazy' const decoding = opts?.imageDecoding ?? 'async' + const fetchpriority = opts?.imageFetchPriority ? ` fetchpriority="${escapeHtml(opts.imageFetchPriority)}"` : '' + const srcset = opts?.imageSrcset ? ` srcset="${escapeHtml(opts.imageSrcset)}"` : '' + const sizes = opts?.imageSizes ? ` sizes="${escapeHtml(opts.imageSizes)}"` : '' return `
@@ -133,7 +139,7 @@ function renderHero(section: { props: { heading: string; subheading?: string; im ${subheading.length > 0 ? `

${escapeHtml(subheading)}

` : ''}
- ${escapeHtml(heading)} + ${escapeHtml(heading)}
diff --git a/lib/exporter/image.responsive.options.test.ts b/lib/exporter/image.responsive.options.test.ts new file mode 100644 index 0000000..a845df3 --- /dev/null +++ b/lib/exporter/image.responsive.options.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest' +import { exportPage } from '@/lib/exporter/html' +import type { ExportOptions } from '@/lib/exporter/html' +import type { Page } from '@/lib/schema/page' + +const basePage: Page = { + title: 'Img Test', + locale: 'en', + theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' }, + sections: [ + { type: 'hero', props: { heading: 'Hero', subheading: '', imageUrl: 'https://example.com/hero.jpg' } }, + ], + form: { + actionUrl: '', + fields: [], + spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 }, + }, + analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' }, + seo: { title: 'T', description: 'D' }, +} + +describe('image responsive/export options', () => { + it('applies fetchpriority when provided', () => { + const options: ExportOptions = { imageLoading: 'eager', imageDecoding: 'async', imageFetchPriority: 'high' } + const { html } = exportPage(basePage, options) + expect(html).toMatch(/]*fetchpriority="high"/) + }) + + it('applies srcset and sizes when provided', () => { + const options: ExportOptions = { + imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x', + imageSizes: '(max-width: 640px) 100vw, 600px', + } + const { html } = exportPage(basePage, options) + expect(html).toMatch(/]*srcset="https:\/\/example.com\/hero.jpg 1x, https:\/\/example.com\/hero@2x.jpg 2x"/) + expect(html).toMatch(/]*sizes="\(max-width: 640px\) 100vw, 600px"/) + }) +})