From ba2ba588f7eba0cd560004832245c29291ed8910 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sun, 16 Nov 2025 20:03:33 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94=202=C2=B73=EC=B0=A8(imageAutoSizes/imageSize?= =?UTF-8?q?sPreset)=20=EB=B0=8F=20=EB=AC=B8=EC=84=9C/=ED=94=8C=EB=9E=9C=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20(TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExportOptions.imageAutoSizes 추가 및 기본 sizes 자동 적용 - ExportOptions.imageSizesPreset 추가(mobile-first/desktop-fixed) - 우선순위: imageSizes > preset > auto > none - 테스트 케이스 확장(image.responsive.options.test.ts) - README/MAIN_PLAN 업데이트 --- MAIN_PLAN.md | 12 ++++- README.md | 7 +++ lib/exporter/html.ts | 12 ++++- lib/exporter/image.responsive.options.test.ts | 49 +++++++++++++++++++ 4 files changed, 78 insertions(+), 2 deletions(-) diff --git a/MAIN_PLAN.md b/MAIN_PLAN.md index 0807e05..28c481b 100644 --- a/MAIN_PLAN.md +++ b/MAIN_PLAN.md @@ -95,7 +95,15 @@ - Exporter CSS 토큰 회귀 확대(:root --primary, body font, skip-link base/focus, focus outline, reduced-motion) - 컴포넌트 i18n 회귀 스냅샷(en/ko 주요 라벨/버튼 키 존재) - 섹션 a11y 자동 검증 템플릿(testUtils/sectionA11y) 도입 및 템플릿 테스트 추가 - - 신규 섹션 추가: Benefits + - 이미지 최적화 옵션 1차(TDD): hero 이미지에 `fetchpriority`/`srcset`/`sizes` 조건부 렌더링 + - 테스트: `lib/exporter/image.responsive.options.test.ts` (속성 존재 검증) + - 구현: `lib/exporter/html.ts`(`ExportOptions`에 옵션 추가 및 `` 렌더 반영) + - 브랜치: `feat/image-optimization` (로컬 커밋, 푸쉬 보류) + - 이미지 최적화 옵션 2차(TDD): `srcset`만 제공 시 기본 `sizes` 자동 적용(`imageAutoSizes`) + - 기본값: `(max-width: 640px) 100vw, 600px` + - 테스트: `image.responsive.options.test.ts` (auto on/off 케이스) + - 구현: `ExportOptions.imageAutoSizes` 추가 및 `renderHero` 적용 + - 신규 섹션 추가: Benefits - 스키마 확장(`benefits`): props.items:string[] - Exporter 렌더 추가: `
`, `

`, 리스트 렌더 - 테스트: sections.benefits.test.ts (h2/aria-labelledby/리스트 아이템 검증) @@ -373,6 +381,7 @@ - `verifyIntegrity` 결과에 `summary`(total/missing/integrityMismatches/sizeMismatches/bundleHashMismatch) 추가 - Export Options(폰트 프리커넥트/프리로드, 이미지 로딩/디코딩, robots 메타) UI/문서화 - 브랜치 푸쉬 완료: `feat/export-bundle-integrity` + - 이미지 최적화 옵션(1차): `fetchpriority`/`srcset`/`sizes` 추가 및 테스트 그린 ### 하드닝 - ZIP 경로 가드: `../`, 절대경로(`/`), 역슬래시(`\`) 차단 (테스트 포함) @@ -392,6 +401,7 @@ 2) CI 무결성 검증 스텝 추가: `verifyIntegrity`로 빌드 산출물 검사(요약 리포트 로깅) 3) PR 라벨 자동화 및 조건부 머지(Gitea Actions/웹훅) 4) README에 사용 가이드 보완(verifyIntegrity 활용 예시) +5) 이미지 최적화 2차: `sizes` 기본 정책 프리셋 및 변환 파이프라인(AVIF/WebP) 설계 초안(TDD 스텁) ### CI/CD 계획(초안) - PR 오픈 시: 라벨 자동 지정, 테스트/빌드, 무결성 검증 스텝 실행 diff --git a/README.md b/README.md index d7486e6..a8ce47b 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,13 @@ The Builder can export a production-ready ZIP that contains: - Image Options - Loading: `lazy` (default) or `eager` for `` - Decoding: `async` (default) or `sync` for `` + - Fetch Priority: optional `high|low` for `` + - Srcset/Sizes: optional responsive sources via `` and `` + - Auto Sizes: when enabled and only `srcset` is provided, a sensible default is applied to ``: `(max-width: 640px) 100vw, 600px` + - Sizes Preset: choose a preset for `` when `imageSizes` is not set. + - `mobile-first`: `(max-width: 640px) 100vw, 800px` + - `desktop-fixed`: `1200px` + - Precedence: explicit `imageSizes` > `imageSizesPreset` > `imageAutoSizes` > none ### site.webmanifest and robots.txt diff --git a/lib/exporter/html.ts b/lib/exporter/html.ts index ae52a8a..d2612c9 100644 --- a/lib/exporter/html.ts +++ b/lib/exporter/html.ts @@ -12,6 +12,8 @@ export type ExportOptions = { imageFetchPriority?: 'high' | 'low' imageSrcset?: string imageSizes?: string + imageAutoSizes?: boolean + imageSizesPreset?: 'mobile-first' | 'desktop-fixed' } function renderFaq(section: { props: { items: Array<{ q: string; a: string }> } }) { @@ -130,7 +132,15 @@ function renderHero(section: { props: { heading: string; subheading?: string; im 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)}"` : '' + const defaultSizes = '(max-width: 640px) 100vw, 600px' + const presetSizes = (() => { + if (!opts?.imageSizesPreset) return '' + if (opts.imageSizesPreset === 'mobile-first') return '(max-width: 640px) 100vw, 800px' + if (opts.imageSizesPreset === 'desktop-fixed') return '1200px' + return '' + })() + const sizesValue = opts?.imageSizes ?? (presetSizes || (opts?.imageSrcset && opts?.imageAutoSizes ? defaultSizes : '')) + const sizes = sizesValue ? ` sizes="${escapeHtml(sizesValue)}"` : '' return `
diff --git a/lib/exporter/image.responsive.options.test.ts b/lib/exporter/image.responsive.options.test.ts index a845df3..09308e8 100644 --- a/lib/exporter/image.responsive.options.test.ts +++ b/lib/exporter/image.responsive.options.test.ts @@ -35,4 +35,53 @@ describe('image responsive/export 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"/) }) + + it('applies default sizes when srcset is provided and imageAutoSizes is true', () => { + const options: ExportOptions = { + imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x', + imageAutoSizes: true, + } + 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"/) + }) + + it('does not include sizes when srcset is provided but imageAutoSizes is false and sizes not provided', () => { + const options: ExportOptions = { + imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x', + imageAutoSizes: false, + } + const { html } = exportPage(basePage, options) + expect(html).toMatch(/]*srcset="https:\/\/example.com\/hero.jpg 1x, https:\/\/example.com\/hero@2x.jpg 2x"/) + expect(html).not.toMatch(/]*sizes="/) + }) + + it('applies preset sizes when imageSizesPreset is set (mobile-first)', () => { + const options: ExportOptions = { + imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x', + imageSizesPreset: 'mobile-first', + } + const { html } = exportPage(basePage, options) + expect(html).toMatch(/]*sizes="\(max-width: 640px\) 100vw, 800px"/) + }) + + it('applies desktop-fixed preset when selected', () => { + const options: ExportOptions = { + imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x', + imageSizesPreset: 'desktop-fixed', + } + const { html } = exportPage(basePage, options) + expect(html).toMatch(/]*sizes="1200px"/) + }) + + it('explicit imageSizes overrides presets and auto', () => { + const options: ExportOptions = { + imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x', + imageAutoSizes: true, + imageSizesPreset: 'desktop-fixed', + imageSizes: '(max-width: 768px) 100vw, 700px', + } + const { html } = exportPage(basePage, options) + expect(html).toMatch(/]*sizes="\(max-width: 768px\) 100vw, 700px"/) + }) })