Compare commits

...

83 Commits

Author SHA1 Message Date
jaybe 98d33fc0d0 feat(wysiwyg): 레이어/속성 패널 및 Export(절대배치) 1차 TDD/구현
Auto PR / open-pr (push) Successful in 21s
Auto Label PR / add-automerge-label (pull_request) Successful in 6s
CI / test (pull_request) Successful in 1m16s
- LayersPanel: 순서 변경/잠금/숨김 토글
- PropsPanel: 위치/크기/회전 및 이미지 src/alt 편집
- exportFrame: frame-scale 토큰 및 오브젝트 절대배치 HTML/CSS 출력
- 테스트 추가 및 전체 그린 유지
2025-11-16 21:35:28 +09:00
jaybe 1737ae172e feat(wysiwyg): 캔버스 키보드 nudge/마우스 드래그 + 8px 스냅 (M1 1차)
- Frame/Layer/Object 스키마(zod) 및 테스트
- 키보드 화살표 nudge 시 8px 스냅 적용
- 마우스 드래그 이동 시 8px 스냅 적용
- Canvas 컴포넌트 초기 버전 및 테스트 추가
2025-11-16 21:23:51 +09:00
jaybe 5dce80e56d ci: verify 단계 폴더 보장 및 요약 로그 출력 추가\n\n- artifacts/export 폴더 보장 스텝 추가\n- 무결성 검증 요약(ok/total/missing/...) 한 줄 출력
Auto PR / open-pr (push) Successful in 17s
Auto Label PR / add-automerge-label (pull_request) Successful in 6s
CI / test (pull_request) Successful in 1m14s
2025-11-16 20:58:22 +09:00
jaybe e32d59ca44 ci: 무결성 검증 통합(권장 플로우)
Auto PR / open-pr (push) Successful in 16s
Auto Label PR / add-automerge-label (pull_request) Successful in 6s
CI / test (pull_request) Successful in 1m13s
- scripts/make-verify-json.js: 산출물 폴더 → extras/assets JSON 생성
- scripts/verify.js: JSON 입력으로 무결성 검증(종료코드 반영)
- package.json: verify 스크립트 추가
- .gitea/workflows/ci.yml: ./artifacts/export 존재 시 검증 스텝 실행
2025-11-16 20:52:18 +09:00
jaybe 86155c9fd3 feat: verifyIntegrity CLI 래퍼 추가 및 문서화 (TDD)
Auto PR / open-pr (push) Successful in 16s
Auto Label PR / add-automerge-label (pull_request) Successful in 5s
CI / test (pull_request) Successful in 1m11s
- verify.cli.ts/verify.cli.test.ts 추가
- README 사용법/종료코드/CI 가이드 문서화
- 전체 테스트 그린 유지
2025-11-16 20:24:30 +09:00
jaybe ba2ba588f7 feat: 이미지 최적화 2·3차(imageAutoSizes/imageSizesPreset) 및 문서/플랜 반영 (TDD)
Auto PR / open-pr (push) Successful in 15s
Auto Label PR / add-automerge-label (pull_request) Successful in 4s
CI / test (pull_request) Successful in 1m8s
- ExportOptions.imageAutoSizes 추가 및 기본 sizes 자동 적용
- ExportOptions.imageSizesPreset 추가(mobile-first/desktop-fixed)
- 우선순위: imageSizes > preset > auto > none
- 테스트 케이스 확장(image.responsive.options.test.ts)
- README/MAIN_PLAN 업데이트
2025-11-16 20:03:33 +09:00
jaybe 396d0ac7a0 feat: 이미지 최적화 옵션 추가(fetchpriority/srcset/sizes) (TDD)
- ExportOptions에 이미지 옵션 추가
- hero 이미지에 조건부 속성 렌더링
- 테스트: image.responsive.options.test.ts
- 전체 테스트 그린 유지
2025-11-16 18:49:39 +09:00
jaybe 19a39230be feat: verifyIntegrity 리포트 확장(summary) 및 테스트 추가\n\n- VerifyResult에 summary(total/missing/integrityMismatches/sizeMismatches/bundleHashMismatch) 추가\n- verify.integrity.test.ts에 summary 단언 추가\n- 전체 테스트 그린 유지 2025-11-16 18:33:16 +09:00
jaybe c519055e90 feat: Sheets 템플릿 가이드 보강(TDD)
- Code.gs에 @tool/@version 헤더, 필드 매핑 TODO, try/catch 에러 처리 추가
- README.txt에 Web App 배포/필드 매핑 가이드 추가
- 테스트 추가: sheets.template.enhance.test.ts
- 전체 테스트 그린 유지
2025-11-16 18:29:54 +09:00
jaybe 7928f9b6c5 feat: ZIP 경로 가드 추가 및 무결성 size 검증 도입 (TDD)
- buildZip에 경로 하드닝 추가: ../, 절대경로(/), 역슬래시(\) 금지
- assets.integrity.index.json에 size(byte) 필드 추가
- verifyIntegrity에서 size 불일치 검증 추가
- 경로/사이즈 검증 테스트 추가: zip.pathGuard.test.ts
- 전체 테스트 그린 유지
2025-11-16 17:30:47 +09:00
jaybe cf8f02bfca docs: 메인플랜 단일화 및 Option2 진행 현황 통합
Auto PR / open-pr (push) Successful in 15s
Auto Label PR / add-automerge-label (pull_request) Successful in 6s
CI / test (pull_request) Successful in 1m11s
2025-11-16 13:11:47 +09:00
jaybe 92aa4f19c7 Option2 무결성 강화: integrity index 추가, 인덱스 정렬/교차 검증, referencedAt 매트릭스, README 문서화 (TDD)
Auto PR / open-pr (push) Successful in 17s
Auto Label PR / add-automerge-label (pull_request) Successful in 6s
CI / test (pull_request) Successful in 1m18s
2025-11-16 12:30:41 +09:00
jaybe 3708ca3521 Option2-무결성: assets.json SHA-256 무결성 계산 및 referencedAt 추적 TDD/구현 2025-11-16 11:05:00 +09:00
jaybe 7bf16bfeee Phase7-PerfSEO: Export Options 문서 업데이트 (robots meta, font preload, image 옵션)
Auto PR / open-pr (push) Successful in 15s
Auto Label PR / add-automerge-label (pull_request) Successful in 3s
CI / test (pull_request) Successful in 1m15s
2025-11-16 10:55:42 +09:00
jaybe 0eb056e6b5 Phase7-PerfSEO: robots meta UI 연동(TDD) 및 Preview/Export 반영 2025-11-16 04:12:46 +09:00
jaybe ed7f6189eb Phase7-PerfSEO: 폰트 preload 옵션 및 이미지 loading/decoding UI 연동 TDD/구현 완료 2025-11-16 04:10:52 +09:00
jaybe 689edeaf24 feat(perf-seo): exporter에 robots 메타 옵션 추가(TDD) 2025-11-15 15:18:23 +09:00
jaybe 13439a384f test(assets): grouped 경로 전략 검증 테스트 추가
Auto PR / open-pr (push) Successful in 21s
Auto Label PR / add-automerge-label (pull_request) Successful in 8s
CI / test (pull_request) Successful in 1m12s
2025-11-15 15:14:19 +09:00
jaybe a5b0f07f2a fix(ci): 누락 파일 추가 및 AssetManager 경로 전략 변경사항 포함
Auto PR / open-pr (push) Failing after 11s
Auto Label PR / add-automerge-label (pull_request) Successful in 7s
CI / test (pull_request) Successful in 1m17s
2025-11-15 15:01:17 +09:00
jaybe 7e7eb1be15 feat(phase6): 자산 경로 구조 옵션(UI/미리보기/Export) + 폰트 preconnect 토글 + extras 자동 보강(robots/manifest) + 문서화
Auto PR / open-pr (push) Failing after 13s
Auto Label PR / add-automerge-label (pull_request) Successful in 7s
CI / test (pull_request) Failing after 35s
2025-11-15 14:28:05 +09:00
jaybe 2d6afaf665 feat(font): Export Options에 폰트 preconnect 토글 추가 및 exporter 옵션 연동
Auto PR / open-pr (push) Failing after 14s
Auto Label PR / add-automerge-label (pull_request) Successful in 8s
CI / test (pull_request) Failing after 36s
2025-11-15 14:22:41 +09:00
jaybe 31eaa9343a feat(export-options): Google Sheets 템플릿 모드 옵션(UI+extras) 추가
Auto PR / open-pr (push) Successful in 22s
Auto Label PR / add-automerge-label (pull_request) Successful in 8s
CI / test (pull_request) Failing after 33s
2025-11-15 13:57:42 +09:00
jaybe 032372bb01 chore(assets): assets.json에 group/referencedAt 추가 및 AssetManager 참조 추적
Auto PR / open-pr (push) Successful in 26s
Auto Label PR / add-automerge-label (pull_request) Successful in 7s
CI / test (pull_request) Successful in 59s
2025-11-15 13:44:23 +09:00
jaybe 176568f976 feat(assets): ZIP에 assets.json 포함 및 메타데이터(integrity 포함) 생성
Auto PR / open-pr (push) Successful in 23s
Auto Label PR / add-automerge-label (pull_request) Successful in 8s
CI / test (pull_request) Successful in 1m14s
2025-11-15 13:38:19 +09:00
jaybe 6a9a7939b2 chore: start Export Options UI polish (TDD)
Auto PR / open-pr (push) Successful in 23s
Auto Label PR / add-automerge-label (pull_request) Successful in 7s
CI / test (pull_request) Successful in 1m14s
2025-11-15 13:25:32 +09:00
jaybe 894acbb6a1 Merge remote-tracking branch 'origin/main' into feat/manifest-robots-restore
Auto PR / open-pr (push) Successful in 18s
Auto Label PR / add-automerge-label (pull_request) Successful in 7s
CI / test (pull_request) Successful in 1m16s
2025-11-15 13:24:32 +09:00
jaybe 899a1ba1a6 Merge pull request 'auto: PR for feat/manifest-robots-restore' (#55) from feat/manifest-robots-restore into main
CI / test (push) Successful in 57s
Auto PR / open-pr (push) Successful in 20s
Auto Label PR / add-automerge-label (pull_request) Successful in 9s
CI / test (pull_request) Successful in 1m2s
2025-11-15 04:09:15 +00:00
jaybe f385966f87 fix(exporter/head): manifest 링크와 theme-color 메타 추가로 테스트 실패 수정
Auto PR / open-pr (push) Failing after 10s
Auto Label PR / add-automerge-label (pull_request) Successful in 6s
CI / test (pull_request) Successful in 1m11s
2025-11-15 13:07:40 +09:00
jaybe c4d46a5de3 Merge remote-tracking branch 'origin/main' into feat/manifest-robots-restore
Auto PR / open-pr (push) Failing after 12s
Auto Label PR / add-automerge-label (pull_request) Successful in 8s
CI / test (pull_request) Failing after 36s
2025-11-15 12:59:19 +09:00
jaybe 2caffe9950 test(zip): Phase5 - robots/manifest 내용 검증 및 ZIP 빌드 스모크
Auto PR / open-pr (push) Failing after 4s
Auto Label PR / add-automerge-label (pull_request) Successful in 7s
CI / test (pull_request) Successful in 59s
2025-11-15 12:45:56 +09:00
jaybe 8757809928 test(extras): robots/manifest 오버라이드 검증 테스트 복구
Auto PR / open-pr (push) Failing after 11s
Auto Label PR / add-automerge-label (pull_request) Successful in 7s
CI / test (pull_request) Successful in 1m9s
2025-11-15 12:44:41 +09:00
jaybe 39c64e3acd Merge pull request 'auto: PR for feat/export-options-ui' (#43) from feat/export-options-ui into main
CI / test (push) Successful in 50s
2025-11-14 23:15:56 +00:00
jaybe df5786e418 Merge pull request 'auto: PR for feat/export-options-overrides' (#42) from feat/export-options-overrides into main
CI / test (push) Waiting to run
2025-11-14 23:13:57 +00:00
jaybe 76889080e8 feat(font): Google Fonts preconnect 추가 및 system-ui 폴백 TDD
Auto PR / open-pr (push) Successful in 20s
Auto Label PR / add-automerge-label (pull_request) Successful in 8s
CI / test (pull_request) Successful in 59s
2025-11-15 08:11:50 +09:00
jaybe 68c8c8ff15 feat(export-extras): manifest/robots 자동 생성 및 오버라이드 복구(TDD) 2025-11-15 08:11:16 +09:00
jaybe 9b8fbd6360 feat(export-ui): Display 드롭다운/테마컬러 HEX 검증 반영(TDD)
Auto PR / open-pr (push) Successful in 23s
CI / test (pull_request) Successful in 1m25s
Auto Label PR / add-automerge-label (pull_request) Failing after 13m45s
2025-11-15 07:37:42 +09:00
jaybe 8ada2d1947 feat(export-ui): Export 옵션 UI 추가(manifest short_name/start_url/display/theme_color, robots Disallow) 및 TDD 2025-11-15 02:35:52 +09:00
jaybe 1a405a54fa feat(export): extras 오버라이드 지원(robots/manifest) TDD 및 구현
Auto PR / open-pr (push) Successful in 22s
Auto Label PR / add-automerge-label (pull_request) Successful in 7s
CI / test (pull_request) Successful in 1m27s
2025-11-15 02:26:54 +09:00
jaybe 5c23cf3873 feat(seo): theme-color 메타 추가 및 hero 이미지 width/height로 CLS 개선(TDD)
Auto PR / open-pr (push) Successful in 19s
Auto Label PR / add-automerge-label (pull_request) Successful in 7s
CI / test (pull_request) Successful in 55s
2025-11-15 02:20:47 +09:00
jaybe cc2b3039a3 feat(head): manifest/ico 링크 반영 및 테스트 추가(manifest.link.tokens)
Auto PR / open-pr (push) Successful in 21s
Auto Label PR / add-automerge-label (pull_request) Successful in 8s
CI / test (pull_request) Successful in 55s
2025-11-15 02:15:44 +09:00
jaybe 0561c5d63c test(zip): ZIP 검증 강화 5차 - extras(robots.txt/manifest) 내용 검증 추가
Auto PR / open-pr (push) Successful in 19s
Auto Label PR / add-automerge-label (pull_request) Successful in 6s
CI / test (pull_request) Successful in 58s
2025-11-15 01:41:54 +09:00
jaybe e6c897a863 feat(export): site.webmanifest/robots.txt 자동 생성 및 extras 포함 (TDD)
Auto PR / open-pr (push) Successful in 19s
Auto Label PR / add-automerge-label (pull_request) Successful in 7s
CI / test (pull_request) Successful in 57s
2025-11-15 01:26:04 +09:00
jaybe 39318dd40d feat(export): 앱 Export 흐름에 extras 통합(buildExtrasForPage → buildZip)
Auto PR / open-pr (push) Successful in 19s
Auto Label PR / add-automerge-label (pull_request) Successful in 6s
CI / test (pull_request) Successful in 54s
2025-11-15 01:21:02 +09:00
jaybe be80f9339b feat(export): Google Sheets Apps Script 템플릿 extras 추가\n\n- actionUrl 미지정 시 sheets/Code.gs, README.txt 포함\n- buildExtrasForPage 유닛/통합 테스트 추가 2025-11-15 01:00:49 +09:00
jaybe df41aac2b1 feat(exporter): 폰트 최적화 - preconnect(googleapis/gstatic) 및 system-ui 폴백 테스트 추가
Auto PR / open-pr (push) Successful in 20s
Auto Label PR / add-automerge-label (pull_request) Successful in 7s
CI / test (pull_request) Successful in 56s
2025-11-15 00:41:48 +09:00
jaybe a620ff970a Merge branch 'main' of https://gitea.jaybe.dev/jaybe/landing-builder
CI / test (push) Successful in 54s
2025-11-15 00:23:32 +09:00
jaybe 524abda49c ignore 수정 2025-11-15 00:18:12 +09:00
jaybe fe62656d37 Merge pull request 'auto: PR for feat/zip-validation-phase4' (#25) from feat/zip-validation-phase4 into main 2025-11-14 15:12:40 +00:00
jaybe 8ad3a9e677 feat(zip): 루트 파일(extras) 포함 및 site.webmanifest JSON 검증 추가\n\n- favicon.ico/site.webmanifest/robots.txt ZIP 포함 지원\n- 잘못된 manifest JSON 시 에러\n- 테스트(phase4) 추가 및 통과
Auto PR / open-pr (push) Successful in 20s
Auto Label PR / add-automerge-label (pull_request) Successful in 6s
CI / test (pull_request) Successful in 55s
2025-11-15 00:11:05 +09:00
jaybe e9a87773ea Merge pull request 'auto: PR for feat/export-zip-validation' (#22) from feat/export-zip-validation into main 2025-11-14 14:53:48 +00:00
jaybe 678b081364 test(zip): 통합/메타 스냅샷 테스트 추가(exportPage+buildZip, SEO/OG/Analytics 보존)
Auto PR / open-pr (push) Successful in 21s
CI / test (pull_request) Failing after 11m23s
Auto Label PR / add-automerge-label (pull_request) Failing after 11m30s
2025-11-14 23:53:28 +09:00
jaybe 46a9c5c83d chore(ci): auto-pr.yml 충돌 해결 및 라벨 생성 금지 정책 유지 2025-11-14 23:52:16 +09:00
jaybe 6c86e1cf85 test(zip): Export ZIP 검증 강화(assets 유무/서브폴더 포함) 2025-11-14 23:45:08 +09:00
jaybe a16541e90d chore(ci): 라벨 자동 생성 로직 제거(auto-label/auto-pr) — 기존 'automerge'만 사용
Auto PR / open-pr (push) Successful in 21s
Auto Label PR / add-automerge-label (pull_request) Successful in 7s
CI / test (pull_request) Successful in 56s
2025-11-14 23:39:05 +09:00
jaybe aaea43f2cc Merge pull request 'auto: PR for feat/sections-edge-cases' (#20) from feat/sections-edge-cases into main 2025-11-14 14:38:44 +00:00
jaybe 3f7bf3f1c8 feat(exporter): 섹션 엣지 3차 보강(FAQ 줄바꿈/Testimonials author 생략, Hero subheading 트림) + 테스트 2025-11-14 23:36:25 +09:00
jaybe bc29faf60c Merge pull request 'auto: PR for feat/sections-edge-cases' (#19) from feat/sections-edge-cases into main 2025-11-14 14:35:52 +00:00
jaybe 4b9627d4ee feat(exporter): 섹션 엣지 케이스 보강(공백 제거/줄바꿈 토큰/CTA) + 테스트 2025-11-14 23:15:05 +09:00
jaybe 26a3b43d28 Merge pull request 'auto: PR for feat/exporter-dark-contrast' (#18) from feat/exporter-dark-contrast into main 2025-11-14 13:56:11 +00:00
jaybe a476756a6c feat(exporter): 다크모드 대비 보강(a 링크/호버, 버튼/CTA, FAQ 텍스트) + 테스트
Auto PR / open-pr (push) Successful in 23s
CI / test (pull_request) Failing after 11m12s
Auto Label PR / add-automerge-label (pull_request) Failing after 11m17s
2025-11-14 22:54:34 +09:00
jaybe 6ff27295e6 chore(ci): prevent duplicate 'automerge' creation — use existing label id only (auto-label/auto-pr)
Auto PR / open-pr (push) Successful in 29s
Auto Label PR / add-automerge-label (pull_request) Successful in 6s
CI / test (pull_request) Successful in 1m10s
2025-11-14 22:33:43 +09:00
jaybe 8d5501d927 Merge pull request 'auto: PR for feat/auto-label-payload-fix' (#15) from feat/auto-label-payload-fix into main 2025-11-14 13:24:01 +00:00
jaybe cd8ae32203 chore(ci): auto-label robust ID resolution (pagination)
Auto PR / open-pr (push) Successful in 32s
Auto Label PR / add-automerge-label (pull_request) Successful in 14s
CI / test (pull_request) Successful in 57s
2025-11-14 22:22:27 +09:00
jaybe 7110db8a4a Merge pull request 'auto: PR for feat/auto-label-payload-fix' (#14) from feat/auto-label-payload-fix into main 2025-11-14 13:17:16 +00:00
jaybe 7b36e14b65 chore(ci): auto-label payload fix 재검증 ping 2025-11-14 22:15:14 +09:00
jaybe 32a760dd9a Merge pull request 'auto: PR for feat/auto-label-payload-fix' (#13) from feat/auto-label-payload-fix into main 2025-11-14 13:14:31 +00:00
jaybe 3d2f1dc8d4 chore(ci): fix auto-label payload for Gitea API (use label id object) 2025-11-14 22:12:55 +09:00
jaybe a957401a59 test
Auto PR / open-pr (push) Successful in 16s
Auto Label PR / add-automerge-label (pull_request) Successful in 5s
CI / test (pull_request) Successful in 1m0s
2025-11-14 22:05:38 +09:00
jaybe 9cc5db5d28 chore(ci): PR 오픈/동기화 시 'automerge' 라벨 자동 부여 워크플로 추가 2025-11-14 21:55:39 +09:00
jaybe 16da17e533 chore(ci): auto-merge 단계에 'automerge' 라벨 가드 확정 반영 2025-11-14 21:52:06 +09:00
jaybe 8205215c17 chore(ci): auto-pr 라벨 자동부여 재검증 ping
Auto PR / open-pr (push) Successful in 18s
CI / test (pull_request) Successful in 52s
2025-11-14 21:45:15 +09:00
jaybe 36514286aa chore(ci): auto-pr 생성 시 'automerge' 라벨 자동 부여 2025-11-14 21:39:31 +09:00
jaybe cc271abedb Merge pull request 'auto: PR for feat/exporter-seo-perf' (#6) from feat/exporter-seo-perf into main 2025-11-14 12:23:09 +00:00
jaybe 8297002dc5 chore(ci): 자동 PR/그린시 자동 머지 구성(CI_TOKEN 사용)
Auto PR / open-pr (push) Successful in 11s
CI / test (pull_request) Successful in 56s
2025-11-14 21:22:00 +09:00
jaybe 25ed57da53 Merge pull request 'feat(exporter): SEO/Perf small improvements (canonical, lazy/async hero, dark token) + tests' (#5) from feat/exporter-seo-perf into main
Reviewed-on: #5
2025-11-14 11:53:17 +00:00
jaybe 90e410f49e feat(exporter): SEO/Perf small improvements (canonical, lazy/async hero, dark token) + tests 2025-11-14 20:50:13 +09:00
jaybe 14a3d32480 Merge pull request 'test(e2e): add exported-page a11y smoke (focus outline, skip-link tab order)' (#4) from feat/ci-verify-run into main
Reviewed-on: #4
2025-11-14 11:49:29 +00:00
jaybe f6c54d1074 fix(exporter): 스킵링크 활성화 시 main에 포커스 이동 처리 추가 2025-11-14 20:36:23 +09:00
jaybe 1ffebc774f test(e2e): add exported-page a11y smoke (focus outline, skip-link tab order) 2025-11-14 20:29:54 +09:00
jaybe 19b8becc30 Merge pull request 'feat/ci-verify-run' (#3) from feat/ci-verify-run into main
Reviewed-on: #3
2025-11-14 11:24:07 +00:00
jaybe 602f1eabde test: expand exporter/css/a11y/i18n regressions and add e2e skip-link focus check 2025-11-14 20:19:50 +09:00
jaybe d6510e2b05 docs(ci): add CI verification note to trigger Actions (#2)
Reviewed-on: #2
Co-authored-by: Jaybe <master@jaybe.dev>
Co-committed-by: Jaybe <master@jaybe.dev>
2025-11-14 09:45:10 +00:00
jaybe a9bbc357e7 test(ci): add exporter heading levels and a11y typography smoke; update MAIN_PLAN 2025-11-14 16:40:45 +09:00
187 changed files with 14005 additions and 13 deletions
+37
View File
@@ -0,0 +1,37 @@
name: Auto Label PR
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
add-automerge-label:
runs-on: [macos, host]
steps:
- name: Add 'automerge' label to PR
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
GITEA_BASE_URL: https://gitea.jaybe.dev
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
if [ -z "${CI_TOKEN:-}" ]; then
echo "CI_TOKEN not set; skipping labeling"; exit 0; fi
OWNER=$(echo "$REPO" | cut -d'/' -f1)
NAME=$(echo "$REPO" | cut -d'/' -f2)
echo "Resolving existing label id for 'automerge' in $OWNER/$NAME (no creation)"
curl -sS -o /tmp/labels.json -H "Authorization: token ${CI_TOKEN}" \
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/labels?limit=1000" || true
# Extract all ids whose name == automerge, prefer the smallest id (oldest)
LID=$(tr '\n' ' ' < /tmp/labels.json | \
grep -o '"id":[0-9][0-9]*[^\}]*"name":"automerge"' | \
sed -E 's/.*"id":([0-9]+).*/\1/' | sort -n | head -n1 || true)
if [ -z "${LID:-}" ]; then
echo "No existing 'automerge' label found; skipping labeling"; exit 0; fi
echo "Labeling PR #$PR_NUMBER with id $LID"
curl -sS -X POST \
-H "Content-Type: application/json" \
-H "Authorization: token ${CI_TOKEN}" \
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/labels" \
-d '{"labels":['"${LID}"']}' || true
+76
View File
@@ -0,0 +1,76 @@
name: Auto PR
on:
push:
branches:
- 'feat/**'
jobs:
open-pr:
runs-on: [macos, host]
steps:
- name: Create Pull Request via Gitea API
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
GITEA_BASE_URL: https://gitea.jaybe.dev
REPO: ${{ github.repository }}
HEAD_BRANCH: ${{ github.ref_name }}
BASE_BRANCH: main
run: |
set -euo pipefail
if [ -z "${CI_TOKEN:-}" ]; then
echo "GITEA_TOKEN not set; skipping auto-pr"; exit 0; fi
OWNER=$(echo "$REPO" | cut -d'/' -f1)
NAME=$(echo "$REPO" | cut -d'/' -f2)
TITLE="auto: PR for $HEAD_BRANCH"
BODY="Automated PR created on push to $HEAD_BRANCH"
echo "Creating PR $HEAD_BRANCH -> $BASE_BRANCH for $OWNER/$NAME"
HTTP_CODE=$(curl -sS -o /tmp/pr_resp.json -w "%{http_code}" -X POST \
-H "Content-Type: application/json" \
-H "Authorization: token ${CI_TOKEN}" \
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/pulls" \
-d "{\"head\":\"${HEAD_BRANCH}\",\"base\":\"${BASE_BRANCH}\",\"title\":\"${TITLE}\",\"body\":\"${BODY}\"}") || true
echo "API status: $HTTP_CODE"
if [ "$HTTP_CODE" = "201" ]; then
echo "PR created"; cat /tmp/pr_resp.json
PR_NUMBER=$(cat /tmp/pr_resp.json | sed -n 's/.*"number":\([0-9]*\).*/\1/p' | head -n1)
else
echo "PR not created (possibly exists). Response:"; cat /tmp/pr_resp.json || true
fi
# Retry to resolve PR number by listing open PRs and matching head/base
i=0
while [ -z "${PR_NUMBER:-}" ] && [ $i -lt 10 ]; do
i=$((i+1))
echo "[retry $i] Resolving PR number for $HEAD_BRANCH -> $BASE_BRANCH"
curl -sS -o /tmp/pr_list.json -H "Authorization: token ${CI_TOKEN}" \
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/pulls?state=open" || true
# Extract PR number matching head.ref==HEAD_BRANCH and base.ref==BASE_BRANCH
PR_NUMBER=$(awk -v hb="$HEAD_BRANCH" -v bb="$BASE_BRANCH" 'BEGIN{RS="},"; pr=""} {
if($0 ~ /\"head\":\{[^}]*\"ref\":\"" hb "\"/ && $0 ~ /\"base\":\{[^}]*\"ref\":\"" bb "\"/){
if (match($0, /\"number\":([0-9]+)/, m)) { print m[1]; exit }
}
}' /tmp/pr_list.json)
if [ -z "${PR_NUMBER:-}" ]; then sleep 2; fi
done
if [ -n "${PR_NUMBER:-}" ]; then
echo "Resolving existing label id for 'automerge' in $OWNER/$NAME (no creation)"
curl -sS -o /tmp/labels.json -H "Authorization: token ${CI_TOKEN}" \
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/labels?limit=1000" || true
LID=$(tr '\n' ' ' < /tmp/labels.json | \
grep -o '"id":[0-9][0-9]*[^\}]*"name":"automerge"' | \
sed -E 's/.*"id":([0-9]+).*/\1/' | sort -n | head -n1 || true)
if [ -n "${LID:-}" ]; then
echo "Adding 'automerge' (id=$LID) label to PR #$PR_NUMBER"
curl -sS -X POST \
-H "Content-Type: application/json" \
-H "Authorization: token ${CI_TOKEN}" \
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/labels" \
-d '{"labels":['"${LID}"']}' || true
else
echo "No existing 'automerge' label found; skipping label add"
fi
else
echo "PR number could not be determined; skipping label add"
fi
+109
View File
@@ -0,0 +1,109 @@
name: CI
on:
push:
branches: [ main, master ]
pull_request:
jobs:
test:
runs-on: [macos, host]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install deps
run: npm ci
- name: Unit/Component tests (Vitest)
run: npm test
env:
CI: true
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: E2E tests (Playwright)
run: npm run test:e2e -- --reporter=line
env:
CI: true
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report
if-no-files-found: ignore
retention-days: 3
- name: Ensure artifacts export folder
run: |
set -euo pipefail
mkdir -p ./artifacts/export
- name: Verify bundle integrity (optional)
run: |
set -euo pipefail
if [ -f ./artifacts/export/assets.integrity.index.json ]; then
echo "Found integrity index; generating verify inputs"
npm run verify:make-json
echo "Running verify CLI"
npm run verify:run | tee /tmp/verify_output.json
echo "--- verify summary (for quick view) ---"
node -e '
const fs = require("fs");
try {
const txt = fs.readFileSync("/tmp/verify_output.json", "utf-8");
const obj = JSON.parse(txt);
const s = obj.summary || {};
console.log(`ok=${obj.ok} total=${s.total||0} missing=${s.missing||0} integMismatch=${s.integrityMismatches||0} sizeMismatch=${s.sizeMismatches||0} bundleHashMismatch=${s.bundleHashMismatch||false}`);
} catch (e) { console.log("(verify summary parse skipped)"); }
'
else
echo "No integrity index at ./artifacts/export/assets.integrity.index.json; skipping integrity verification"
fi
- name: Auto-merge PR on green
if: ${{ github.event_name == 'pull_request' }}
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
GITEA_BASE_URL: https://gitea.jaybe.dev
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
if [ -z "${CI_TOKEN:-}" ]; then
echo "CI_TOKEN not set; skipping auto-merge"; exit 0; fi
OWNER=$(echo "$REPO" | cut -d'/' -f1)
NAME=$(echo "$REPO" | cut -d'/' -f2)
echo "Checking labels for PR #$PR_NUMBER on $OWNER/$NAME"
LABELS_JSON=$(curl -sS -X GET \
-H "Authorization: token ${CI_TOKEN}" \
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}")
echo "labels payload (first 200 chars):"; echo "$LABELS_JSON" | cut -c1-200 || true
if ! echo "$LABELS_JSON" | grep -qi '"name":"automerge"'; then
echo "Label 'automerge' not present; skipping merge"; exit 0; fi
echo "Label 'automerge' present; proceeding to merge"
# ensure PR is open
PR_JSON=$(curl -sS -X GET \
-H "Authorization: token ${CI_TOKEN}" \
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}")
STATE=$(echo "$PR_JSON" | sed -n 's/.*"state":"\([^"]*\)".*/\1/p')
echo "PR state: ${STATE:-unknown}"
if [ "${STATE:-}" != "open" ]; then
echo "PR state is not open; skipping merge"; exit 0; fi
echo "Merging PR #$PR_NUMBER for $OWNER/$NAME"
HTTP_CODE=$(curl -sS -o /tmp/merge_resp.json -w "%{http_code}" -X POST \
-H "Content-Type: application/json" \
-H "Authorization: token ${CI_TOKEN}" \
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/merge" \
-d '{"Do":"merge","MergeMessage":"auto-merge: CI green"}' || true)
echo "merge http: $HTTP_CODE"
echo "merge resp:"; cat /tmp/merge_resp.json || true
+35
View File
@@ -0,0 +1,35 @@
name: CI
on:
push:
branches: [ main, master ]
pull_request:
jobs:
test:
runs-on: [macos, host]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install deps
run: npm ci
- name: Unit/Component tests (Vitest)
run: npm test
env:
CI: true
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: E2E tests (Playwright)
run: npm run test:e2e
env:
CI: true
+1
View File
@@ -39,3 +39,4 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
MAIN_PLAN.md
+459
View File
@@ -0,0 +1,459 @@
# 메인플랜: 랜딩 페이지 빌더
## 목표
- 비개발자도 사용 가능한 단일 페이지 랜딩 페이지 빌더.
- 결과물: 정적 ZIP(HTML/CSS/JS) 다운로드. 이미지: 외부 URL 또는 로컬 업로드(서버 저장 없음, ZIP에 패키징). 폰트는 외부 CDN 허용.
- 폼: 다양한 필드 타입(파일 업로드 제외), 커스텀 name, action URL 설정. action 미지정 시 Google Sheets 저장.
- 다국어 지원(i18n), 접근성 준수, SEO/OG 메타, 분석 스니펫 삽입.
## 확정 요구사항
- 단일 페이지 기준.
- 이미지: 외부 URL 또는 로컬 업로드 허용(서버 저장 없음, ZIP 패키징). CDN 허용(예: Google Fonts 등).
- 폼 필드 타입: 텍스트/이메일/전화/셀렉트/라디오/체크박스/텍스트에어리어/날짜 등(파일 업로드는 추후).
- 검증 규칙 필요. 스팸 방지: 기본(honeypot, 제출지연 타임스탬프).
- 제출 경로: 1) 사용자가 action URL 지정 → 2) 미지정 시 Google Sheets.
- 기본: Google Apps Script 웹앱 템플릿 가이드 제공(사용자 배포 URL 사용)。
- 대안: 서비스계정 + Google Sheets API(선택).
- 다국어 지원 필요. 접근성 준수. SEO/OG 포함. 분석 스니펫 삽입 기능 필요.
- 스택: React 기반. Next.js(App Router) + TS + Tailwind + shadcn/ui 권장.
## 아키텍처 개요
- 빌더 앱: Next.js(App Router, TS), Tailwind, shadcn/ui, dnd-kit, Zustand.
- 도메인 스키마: Page → Section[] → Block[] → Form(fields, validation, actionUrl) → Theme.
- 에셋 매니저: 사용자가 업로드한 이미지를 메모리/브라우저에서 관리, 파일명 해싱 후 `assets/` 경로로 참조 재작성.
- HTML Exporter: JSON 스키마 → 정적 HTML/CSS/JS 생성. 로컬 업로드 이미지는 `assets/`로 참조, 외부 URL은 그대로 유지.
- 제출 경로: action 사용 또는 Google Sheets(기본 Apps Script 템플릿 제공).
- i18n: next-intl. a11y: Testing Library + axe-core 스모크. SEO/OG 메타 생성.
- 내보내기: ZIP(브라우저 JSZip 우선). 필요 시 서버(API Route) 대안 제공. 로컬 업로드 이미지는 ZIP 내 `assets/` 폴더에 파일로 포함.
## UI/UX 리디자인 계획(합의안)
- **정보 구조(3패널)**
- 좌: 컴포넌트 패널(Hero/FAQ/CTA + Form 프리셋)
- 중: 캔버스(dnd-kit, 섹션/필드 배치·정렬·삭제)
- 우: 인스펙터(선택 요소 속성/검증/스타일 편집)
- **상단 툴바**: 언어, 테마, 프리셋, Export ZIP
- **프리뷰**: 실시간 렌더, 반응형 뷰포트 토글
- **계약 고정**: 스키마(섹션/폼/검증), 상태 인터페이스, Exporter 입·출력
### 단계별 TDD 진행
1) 레이아웃 스캐폴딩(shadcn/ui) + 접근성 라벨링 테스트
2) dnd 캔버스 1차: 섹션 드래그 정렬/삭제 테스트 → 최소 구현
3) 인스펙터 1차: 선택 섹션/필드 기본 속성 편집 테스트 → 연동
4) 컴포넌트 패널: 카드 추가(섹션/필드 프리셋) 테스트 → 드래그 추가
5) 프리뷰 강화: 실시간 반영/뷰포트 토글 스모크 테스트
### 현재 상태(MVP)
- 섹션 추가/삭제, 폼 필드 구성(옵션/필수/패턴/최대길이), i18n, ZIP Export, 로컬 에셋 포함 동작 확인
- Next.js 16: `middleware``proxy` 마이그레이션 완료
- dnd 상호작용 테스트 및 구현 안정화(정렬/삭제 버튼 폴백 포함)
- 섹션 프리셋 확장: Features/Testimonials 카드 추가 및 프리뷰/Exporter 스텁 반영 완료
- SEO/Analytics/OG 인스펙터 추가 및 프리뷰 반영
- Exporter: og:image 메타 태그 반영 및 테스트 추가
- a11y: jest-axe 스모크 테스트 통과(FormBuilderPanel), dnd-kit 라이브 리전/안내 존재 검증
- 라벨-id 매핑 강화(중앙 입력/인스펙터/FAQ/CTA/Features/Testimonials)
- 프리뷰 뷰포트 토글(Desktop/Mobile) + localStorage 영속화
- Features/Testimonials 인스펙터 편집 UI(리스트 추가/삭제/편집) + 테스트
- Exporter 타입 보강(any 제거) 및 CSS 보강(Features/Testimonials)
- E2E(Playwright):
- 기본 플로우(섹션 추가→편집→프리뷰→Export 클릭)
- dnd 키보드 폴백(Up/Down) 재정렬
- SEO/Analytics/OG 프리뷰 반영 검증
- Export ZIP 메타(SEO/OG/Analytics) 검증(테스트 훅 사용)
- 프리뷰 뷰포트 토글(aria-pressed/키보드 접근) 검증
### a11y/반응형 정책(Exporter)
- 문서 내비게이션: skip link, `<main id="main" role="main">` 적용
- 섹션 식별: `aria-labelledby` + 섹션 헤딩 id(features/testimonials/faq)
- 이미지 대체텍스트: hero img `alt` = heading 파생
- 포커스 표시: 포커스 가능한 요소 outline 표시
- 반응형: 모바일 컨테이너 패딩 축소, grid 자동 칼럼, `prefers-reduced-motion` 준수
### 최근 보강 내역(접근성/테스트)
- 인스펙터 입력에 `aria-describedby` 연결(SEO/Analytics/OG/Hero/CTA)
- SEO/Analytics/OG/Hero/CTA/FAQ 헬프텍스트 추가
- Exporter 구조 스냅샷 강화: `<main>` 내부 섹션/폼 포함 검증
- Exporter CSS 토큰 스냅샷: skip-link/focus outline/반응형/모션 감소 토큰 존재 검증
- Features/Testimonials `aria-labelledby`/헤딩 id 일치 검증 추가
- Exporter 헤딩 레벨 일관성 전수 테스트(hero=h1, others=h2, FAQ=h3) 추가
- Exporter 색상/타이포그래피 토큰 스모크 테스트 추가
- Exporter(Form): helptext ↔ aria-describedby 연결 TDD 완료
- 각 필드 `id=field-<name>``<label for>` 연결
- `help`가 있을 경우 `id=field-<name>-help` 요소 생성 및 컨트롤에 `aria-describedby` 부여
- 라디오 그룹은 `fieldset``aria-describedby` 적용, 옵션별 `id/htmlFor` 연결
- next-intl 기본 timeZone 설정: `Asia/Seoul`로 ENVIRONMENT_FALLBACK 경고 제거
- a11y 색/폰트 최소 기준 스모크(본문 컬러/폰트 패밀리/모바일 h1 폰트 크기) 추가
- CTA 섹션에 `aria-labelledby` + `<h2 id="cta-heading">` 추가(헤딩 레벨 일관성)
- 전체 테스트 그린(유닛/컴포넌트/E2E)
- Inspector a11y 스모크: SEO/Analytics/OG 입력과 헬프텍스트의 `aria-describedby` 연결 검증
- Exporter CSS 스모크: 본문 텍스트 컬러/포커스 outline/모바일 h1 폰트 크기/MQ 토큰 존재 검증
- FAQ 인스펙터 Q/A 입력 `aria-describedby`(ins-faq-help) 연결 보강
- Inspector 키보드 UX 스모크: Features/Testimonials 항목 추가( Space/Enter ) 검증 추가
- Inspector 키보드 UX 스모크: Features/Testimonials 항목 삭제(Remove) Enter/Space 검증 추가
- 인스펙터 Remove 버튼 키보드 처리(onKeyDown/onKeyUp) 반영
- Exporter 회귀 스냅샷: heading/ARIA/반응형 토큰 통합 검증 추가
- Preview 토글 ARIA 회귀 스냅샷(Desktop/Mobile: aria-pressed/controls/describedby)
- Exporter CSS 토큰 회귀 확대(:root --primary, body font, skip-link base/focus, focus outline, reduced-motion)
- 컴포넌트 i18n 회귀 스냅샷(en/ko 주요 라벨/버튼 키 존재)
- 섹션 a11y 자동 검증 템플릿(testUtils/sectionA11y) 도입 및 템플릿 테스트 추가
- 이미지 최적화 옵션 1차(TDD): hero 이미지에 `fetchpriority`/`srcset`/`sizes` 조건부 렌더링
- 테스트: `lib/exporter/image.responsive.options.test.ts` (속성 존재 검증)
- 구현: `lib/exporter/html.ts`(`ExportOptions`에 옵션 추가 및 `<img>` 렌더 반영)
- 브랜치: `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 렌더 추가: `<section class="benefits" aria-labelledby>`, `<h2 id="benefits-heading">`, 리스트 렌더
- 테스트: sections.benefits.test.ts (h2/aria-labelledby/리스트 아이템 검증)
- 섹션 회귀 스냅샷(benefits 포함) 보강: sections.regression.benefits.test.ts 추가(ARIA/헤딩/리스트 구조 전수 검증)
- 섹션 회귀 스냅샷(CTA/Testimonials): sections.regression.cta-testimonials.test.ts 추가(figure/blockquote/figcaption, CTA 링크 클래스/URL 검증)
- 섹션 회귀 스냅샷(Features/FAQ): sections.regression.features-faq.test.ts 추가(리스트/클래스/아이템 수 검증)
- Exporter 이스케이프 회귀: sections.escape.regression.test.ts 추가(특수문자 <, >, &, ") 안전 이스케이프 검증)
- 인스펙터 a11y 자동 체크 헬퍼 도입: components/testUtils/inspectorA11y.ts + 템플릿 테스트 2종(InspectorA11y.template/sections)
- i18n 회귀 확장: Sidebar Add 버튼군(en/ko) 라벨 스모크(i18n.sidebar.actions.test.tsx)
- E2E 스모크: 프리뷰/Export 마커(heading/ARIA/반응형 CSS) 존재 검증 추가
- SEO description 기본값 설정으로 Export 스키마 안정화
- 프리뷰 토글 버튼에 `aria-controls="preview-container"` 추가로 제어 영역 명시
- 프리뷰 토글에 sr-only 설명 추가 및 두 토글 버튼 `aria-describedby` 연결
- SectionCanvas 제어 버튼 `aria-label` 스모크(Select/Up/Down/Remove) 추가
- CSS focus-visible 토큰 스모크(3px solid + HEX 컬러) 추가
- 인스펙터 Add 버튼 title 보조 힌트 추가(Features/Testimonials/FAQ) 및 일관성 테스트
- SectionCanvas 버튼 title/aria-label 일관성 스모크
- 전역 버튼 네이밍 일관성 보강: Inspector Add/Remove 및 SectionCanvas Select/Up/Down/Remove의 title/aria-label 일치 확인 및 스모크 완료
- a11y 커버리지 최종 점검: 전 섹션 인스펙터 입력 aria-describedby 연결 확인(체크리스트 충족)
### 다음 작업(계획)
- Exporter 헤딩 레벨 일관성 테스트: hero는 h1, 나머지 섹션은 h2, FAQ 항목은 h3 검증
- a11y 색/폰트 최소 기준 스모크: 대비/폰트크기 관련 CSS 토큰/규칙 존재 여부 스냅샷
### Inspector a11y 커버리지 체크리스트
- [x] Hero: `Hero Heading` 입력 `aria-describedby``ins-hero-heading-help`
- [x] CTA: `CTA Text`/`CTA Href` 입력 `aria-describedby``ins-cta-text-help`/`ins-cta-href-help`
- [x] FAQ: `FAQ Q n`/`FAQ A n` 입력 `aria-describedby``ins-faq-help`
- [x] Features: `Feature n` 입력 `aria-describedby``ins-features-help`
- [x] Testimonials: `Author n`/`Quote n` 입력 `aria-describedby``ins-testimonials-help`
- [x] Preview 토글: sr-only 설명(`preview-toggle-help`)과 버튼 `aria-describedby` 연결, `aria-controls="preview-container"`
### CI/테스트 실행
- 유닛/컴포넌트: `npm test` (Vitest)
- E2E: `npm run test:e2e` (Playwright)
- 로컬 개발 서버는 Playwright 설정에서 자동 기동(webServer: `npm run dev`)
- GitHub Actions 워크플로: `.github/workflows/ci.yml`
- Node 20 + `npm ci`
- Vitest 실행 → Playwright 브라우저 설치(`npx playwright install --with-deps`) → E2E 실행
### CI 최종 점검
- main/master 브랜치 푸시/PR 시 워크플로 자동 실행 확인
- 새로 추가된 테스트(헤딩 레벨 전수, 색/폰트 스모크) CI 통합 확인
- 작업 브랜치 푸시 완료: `chore/ci-verify-a11y-headings` (원격: origin)
- PR 생성 및 Actions 결과 기록 예정(base: main)
- CI verification note: 러너 온라인 이후, 사소 변경으로 CI 트리거 검증 예정
- CI rerun: 워크플로 재실행용 ping 커밋
### PR/CI 결과(2025-11-14)
- PR: feat/ci-verify-run → main 머지 완료
- CI: Vitest + Playwright 전부 그린
- E2E 추가: Exported page skip-link focus 표시 회귀 테스트 포함
- 워크플로: self-hosted runner [macos, host], Playwright 보고서 업로드 아티팩트 활성화
- 제안: 다음 릴리스 태그(v0.2.0)로 회귀 커버리지/Benefits 섹션 추가 반영
#### 결과: CI 통과 (self-hosted Gitea Actions)
- PR: https://gitea.jaybe.dev/jaybe/landing-builder/pulls/2
- 러너: self-hosted, labels: [macos, host]
- 테스트: Unit/Component + E2E 총 7/7 green
- 수정 요약(E2E 포커스):
- 초기 포커스 선점 방지: `body` 클릭으로 포커스 부여
- 스킵링크 등 선행 포커서블 회피: Desktop 버튼에 명시적 `focus()`
- 키보드 시나리오: Tab → Mobile 포커스 확인 → Space로 토글 → Shift+Tab → Enter
##### 추가 CI 결과(2025-11-14)
- E2E a11y 스모크 추가: 포커스 아웃라인(3px solid, 브랜드 컬러), 스킵링크 탭 순서(main 포커스 이동)
- Exporter JS에 스킵링크 클릭 시 `#main``tabindex="-1"` 부여 후 `focus()` 처리 → CI 그린 통과
#### 머지 상태
- 머지 방식: Squash and merge
- 브랜치 정리: `feat/ci-verify-run` 삭제 완료
### 다음 스프린트 후보 과제
- Exporter 회귀 스냅샷 보강(폼 토큰/검증 토큰 범위 확대)
- 인스펙터 신규 섹션 추가 시 a11y 베이스라인 재사용 가이드 마련
- 버튼 네이밍 일관성 전수 스캔 스모크 범위 확대
- node 20 + npm ci
- Vitest 실행 → Playwright 브라우저 설치 → E2E 실행
## 마일스톤
1. 스캐폴딩 및 테스트 환경 구성
2. 스키마 정의(Zod) 및 HTML Exporter TDD
3. 컴포넌트/상태관리(Zustand) 및 인스펙터 기본 기능
4. 접근성/키보드 네비게이션 강화 및 테스트 안정화
5. CI/CD(Self-hosted Gitea) 연동 및 그린 검증
## 릴리스
### v0.1.0
- CI 검증 완료(Self-hosted Gitea runner, labels: [macos, host])
- E2E 안정화: 초기 포커스 이슈 제거(Desktop 명시 focus), 스킵링크 고려
- Exporter(Form) a11y: helptext ↔ aria-describedby, id/htmlFor 연결, 라디오 fieldset 설명 연결
- i18n: en/ko 중첩 구조, 누락 키 보강(builder.action.*, builder.form.*)
- next-intl: 기본 timeZone=Asia/Seoul 설정으로 ENVIRONMENT_FALLBACK 경고 제거
- 태그: v0.1.0
## 접근성 베이스라인 가이드(신규 섹션/인스펙터 적용 규칙)
- **레이블 연결**: 모든 입력은 `id=ins-...` 또는 `id=field-...` 부여, `<label for>`로 연결
- **헬프텍스트 연결**: 헬프 요소 `id=...-help` 생성, 입력에 `aria-describedby`로 연결
- **라디오 그룹**: 개별 `<input>` 대신 `<fieldset>``aria-describedby` 적용, 옵션은 `id/htmlFor`로 연결
- **버튼 네이밍**: 보이는 텍스트 또는 `aria-label`로 안정적 접근 가능한 이름 제공
- **키보드 활성화**: Enter/Space 활성(onClick와 동일 동작), `focus-visible` 스타일 유지
- **스킵링크**: `<a class="skip-link">` 제공, 포커스 시 화면 내 표시
- **헤딩 레벨**: hero=h1, 1뎁스 섹션=h2, FAQ 항목=h3 일관성 유지
- **미리보기 토글**: 토글 버튼에 `aria-pressed`, `aria-controls`, `aria-describedby` 연결
- **i18n 키**: 중첩 객체 구조, 신규 키 추가 시 en/ko 동시 보강
- **테스트 원칙**: TDD로 유닛/컴포넌트/E2E 및 스냅샷 추가. 포커스/ARIA/토큰 스모크 포함
### 적용 가이드(신규 섹션 추가 시)
- **1) 섹션 구현**: `<section class="<name>" aria-labelledby="<name>-heading">``<h2 id="<name>-heading">` 구성
- **2) 인스펙터 입력**: `id=ins-...`, `<label for>`, 헬프텍스트 `...-help` + `aria-describedby` 연결
- **3) Exporter 반영**: 섹션 렌더 함수에 위 구조/속성 부여
- **4) 테스트 추가**:
- 공통 템플릿 사용: `lib/exporter/testUtils/sectionA11y.ts``assertSectionA11y(html)`
- 샘플: `lib/exporter/sectionA11y.template.test.ts` 참고하여 새 섹션 추가해 검증
- 필요 시 개별 스냅샷/스모크: 헤딩/ARIA/키보드/토큰 추가 검증
## 다음 단계(E2E 초안)
- 시나리오: 섹션 추가(Hero/FAQ/Features/Testimonials/CTA) → 인스펙터 편집 → Export ZIP 생성 → HTML 메타/섹션/폼 렌더 확인
- 환경: Playwright
- 체크리스트:
- 섹션 추가/정렬/삭제 동작
- 인스펙터 편집 반영(미리보기/Export HTML)
- i18n 라벨 안정성(data-testid 사용)
- a11y 스모크 무결성 유지
## TDD 전략
- 각 기능 단위로: 실패 테스트 작성 → 최소 구현 → 리팩터 → 커버리지 확인.
- 단위: Vitest + Testing Library + jsdom. E2E: Playwright.
- 접근성: axe-core 스모크 테스트.
## 작업 보드(요약)
- 스키마/도메인(Zod)
- HTML Exporter
- 에셋 매니저
- 폼 엔진
- Sheets 연동
- 빌더 UI
- 내보내기 ZIP
- i18n
- a11y/SEO/OG/Analytics
## 오류/버그 기록
- [2025-11-13 22:55] BuilderClientPage 렌더 중 ref 접근 경고 / useRef.current를 렌더 중 참조하여 ESLint 경고 발생 / FormBuilderPanel 통합 시 `useRef(createFormStore())` 사용 / `useMemo(() => createFormStore(), [])`로 스토어 인스턴스 고정하고 `useSyncExternalStore`에 직접 주입하여 해결 / 렌더 중 ref 접근 금지 원칙 재확인
- [2025-11-14 09:35] Vitest 로그에서 i18n 누락 메시지 경고 확인(en: `builder.action.addSelect/addRadio`, `builder.form.actionUrl/honeypot/minSeconds`) → `messages/en.json`에 키 존재 여부 점검 및 누락 보완 후 재실행, 모든 테스트 그린 유지
- [2025-11-14 09:45] 프리뷰 텍스트 매칭 불안정으로 테스트 실패 → `data-testid` 기반 안정화(`preview-seo-title` 등) 및 텍스트 매칭 지점 `Title:``Locale:`로 조정, 전면 그린
- [2025-11-14 20:30] E2E 실패: 스킵링크 활성화 후 main 포커스 미이동 → Exporter JS에 스킵링크 클릭 핸들러 추가(`tabindex=-1` + `focus()`) → 로컬/CI 모두 그린
- [2025-11-14 23:45] Gitea 라벨 중복 생성/페이로드 오류 → 워크플로 수정
- auto-label.yml/auto-pr.yml에서 라벨 생성 로직 제거(기존 레이블만 사용)
- 라벨 지정 시 JSON 페이로드를 `{"labels":[id]}` 형태로 고정
- 다중 동일 라벨 존재 시 가장 작은 ID를 선택하도록 정렬 적용
- ci.yml 자동 머지 가드: PR 라벨 조회 후 `automerge` 존재시에만 머지
## CI 파이프라인 자동화 결과(automerge)
- 브랜치 푸시 → auto-pr로 PR 생성 → auto-label로 `automerge` 부여(기존 레이블만) → ci.yml에서 테스트 그린 시 자동 머지
- 안전장치: `automerge` 라벨 없으면 머지 스킵, PR 닫힘/드래프트 시 머지 스킵
- 진단 로그: API status/라벨 JSON 일부/머지 응답 코드 출력으로 디버그 용이성 확보
## 충돌 해결 기록
- [2025-11-14 23:50] `.gitea/workflows/auto-pr.yml` main 병합 충돌 해결
- 충돌 마커 제거, 메시지 통일: "기존 'automerge' 라벨만, 생성 금지"
- PR 번호 해석 재시도 루프 유지 및 라벨 ID 해석 로직 안정화
## ZIP 검증 TDD(자산/메타)
- 통합/메타 테스트 추가: `zip.validation.integrated.test.ts`, `zip.validation.meta.test.ts`
- exportPage + buildZip 통합: 자산 경로 재작성 및 포함, 하위 폴더 포함 검증
### ZIP 검증 TDD Phase4(루트 파일/manifest)
- 테스트: `zip.validation.phase4.test.ts`
- ZIP 루트에 `favicon.ico` / `site.webmanifest` / `robots.txt` 포함 검증
- `site.webmanifest` JSON 파싱 유효성 검증(잘못된 JSON 시 에러)
- 구현: `lib/exporter/zip.ts`
- `BuildZipInput.extras` 추가(루트 파일 수집)
- manifest JSON 파싱 검증 로직 추가
- 브랜치/PR: `feat/manifest-robots-auto` → CI 트리거 및 자동 머지 대상(automerge 라벨 조건)
### SEO/Perf 개선(TDD): theme-color, hero width/height
- 테스트: `lib/exporter/seoPerf.themeColor.heroDims.test.ts`
- `<meta name="theme-color" content="#0ea5e9" />` 헤드 포함 검증(page.theme.primaryColor 사용)
- hero 이미지에 `width`/`height` 속성 존재 검증(CLS 저감)
- 구현: `lib/exporter/html.ts`
- `buildHead`에 theme-color 메타 추가
- `renderHero` 이미지 태그에 width/height 기본값 추가(1200x675)
- 브랜치/PR: `feat/seo-perf-themecolor-hero-dims` → CI 트리거 및 자동 머지 대상
### Export 옵션 기반(TDD): extras 오버라이드(robots/manifest)
- 테스트: `lib/exporter/extras.overrides.test.ts`
- robots.txt 내용 오버라이드(Disallow 경로) 반영 검증
- manifest 필드(name/short_name/start_url/display/theme_color) 오버라이드 검증
- 구현: `lib/exporter/extras.ts`
- `buildExtrasForPage(page, overrides?)` 시그니처로 확장
- `overrides.robotsText`/`overrides.manifest` 병합 적용, 기본값 유지
- 브랜치/PR: `feat/export-options-overrides` → CI 트리거 및 자동 머지 대상
### ZIP 검증 TDD Phase5(extras 내용 검증)
- 테스트: `lib/exporter/zip.validation.phase5.test.ts`
- `robots.txt` 텍스트 베이스라인 확인(`User-agent: *`)
- `site.webmanifest` JSON 파싱 및 필수 키(`start_url`, `display`) 확인
- 구현: 기존 `buildExtrasForPage`/`buildZip` 경로 사용(추가 구현 불필요)
- 브랜치/PR: `feat/zip-validation-phase5` → CI 트리거 및 자동 머지 대상
### Export 옵션 UI 폴리시(TDD): display 드롭다운 + theme_color HEX 검증
- 테스트: `components/ExportOptions.polish.ui.test.tsx`
- Manifest `display` 입력이 드롭다운(select)으로 제공되고 옵션 값(`standalone`/`minimal-ui`/`browser`) 중에서만 선택됨을 검증
- `theme_color``#RGB`/`#RRGGBB` 형식의 HEX만 허용하며, 잘못된 값은 오버라이드에 반영되지 않음을 검증
- 구현:
- `components/BuilderClientPage.tsx`
- `display` 입력을 `<select>`로 변경하고 옵션 추가
- `theme_color`는 정규식 `^#([0-9a-fA-F]{3}){1,2}$` 통과 시에만 `manifestOverrideBase.theme_color`에 반영
- 결과: 전체 테스트 그린(유닛/컴포넌트/E2E)
- 브랜치/PR 제안: `feat/export-ui-polish` → CI 트리거 및 자동 머지 대상(automerge 라벨 조건)
### 폰트 최적화(TDD)
- 테스트: `lib/exporter/font.optimization.test.ts`
- head에 `<link rel="preconnect" href="https://fonts.googleapis.com">`, `<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>` 존재 검증
- CSS에 `system-ui` 폴백 포함 검증
- 구현: `lib/exporter/html.ts`
- head에 googleapis/gstatic preconnect 추가
- CSS body `font-family`에 system-ui 폴백 유지
- 브랜치/PR: `feat/font-optimization` → CI 트리거 및 자동 머지 대상(automerge 라벨 조건)
### 폼 제출: Google Sheets 템플릿 Export 포함(TDD)
- 테스트: `lib/exporter/sheets.template.test.ts`
- actionUrl 미지정 시 ZIP extras에 `sheets/Code.gs`, `sheets/README.txt` 포함 검증
- actionUrl 지정 시 extras에 포함되지 않음 검증
- 구현: `lib/exporter/extras.ts`
- `buildExtrasForPage(page)`로 extras 생성
- Apps Script Code.gs/README.txt 기본 템플릿 제공
- 브랜치/PR: `feat/form-sheets-template` → CI 트리거 및 자동 머지 대상(automerge 라벨 조건)
### site.webmanifest/robots.txt 자동 생성(TDD)
- 테스트: `lib/exporter/manifest.robots.auto.test.ts`
- extras에 `site.webmanifest`/`robots.txt` 자동 포함 검증
- manifest 파생 키 검사(name/short_name/start_url/display/theme_color)
- 구현: `lib/exporter/extras.ts`
- `buildExtrasForPage(page)`에서 robots.txt/manifest 자동 생성 및 포함
- 브랜치/PR: `feat/manifest-robots-auto` → CI 트리거 및 자동 머지 대상(automerge 라벨 조건)
## 의사결정 기록(ADR)
- [YYYY-MM-DD] Next.js(App Router) 채택: 생태계/배포/서버리스/SEO 편의성.
- [YYYY-MM-DD] Sheets 기본 방안: Apps Script 웹앱 템플릿(사용자 편의/트래픽 분산).
- [YYYY-MM-DD] 로컬 이미지 업로드 허용: 서버 저장 없이 ZIP에 `assets/`로 패키징(브라우저 JSZip 우선).
- [ci] automerge 재검증용 ping (2025-11-14 21:36:38)
- [ci] auto-pr 라벨 자동부여 재검증 ping (2025-11-14 21:45:14)
- [ci] auto-label 재트리거 ping (2025-11-14 21:56:22)
- [ci] automerge 라벨 수동부여 후 재트리거 ping (2025-11-14 22:01:41)
- [ci] full auto pipeline 재검증 ping (2025-11-14 22:09:02)
- [ci] auto-label payload fix 재검증 ping (2025-11-14 22:15:14)
- [ci] auto pipeline 최종 재검증 최종 ping (2025-11-14 22:26:59)
### Export ZIP 무결성 강화(Option2) 진행 현황
### 목표
- Export ZIP 무결성 강화: 자산 무결성 지표와 번들 검증 체계 구축
- CI/CD(Gitea)에서 PR 자동화, 라벨링, 조건부 머지 적용
- 모든 개발은 TDD로 진행하고, 기능 단위 커밋/대브랜치 단위 푸쉬
### 브랜치 전략
- 대범위 기능 묶음 브랜치: `feat/export-bundle-integrity`
- 관련 기능 완료까지 로컬 커밋만 진행, 완료 시점에 일괄 푸쉬
### 진행 현황
- SHA-256(base64) 무결성 구현 및 `assets.integrity.index.json` 생성
- `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`
- 이미지 최적화 옵션(1차): `fetchpriority`/`srcset`/`sizes` 추가 및 테스트 그린
- 이미지 최적화 옵션(2차): `imageAutoSizes`(srcset만 있을 때 기본 sizes 자동 적용)
- 이미지 최적화 옵션(3차): `imageSizesPreset`(mobile-first/desktop-fixed) + 우선순위(imageSizes > preset > auto > none)
- 브랜치/PR: `feat/image-optimization` 푸쉬 완료
- verifyIntegrity CLI 래퍼(TDD) 추가: `lib/exporter/verify.cli.ts` + 테스트
- 폴더 기반 직렬화 유틸(TDD) 추가: `lib/exporter/verify.serialize.ts` + 테스트
- CI 통합(권장 플로우): `.gitea/workflows/ci.yml``./artifacts/export` 존재 시 검증 스텝 자동 실행
- 스크립트: `scripts/make-verify-json.js`, `scripts/verify.js` + `npm run verify:make-json`/`verify:run`
- 브랜치/PR: `feat/verify-integrity-cli` 푸쉬 완료
### 하드닝
- ZIP 경로 가드: `../`, 절대경로(`/`), 역슬래시(`\`) 차단 (테스트 포함)
### TDD 계획 및 상태
- 테스트 선작성 → 최소 구현 → 리팩터/문서화
- 현재 테스트: 110 files / 173 tests 모두 통과
- 무결성 인덱스/검증에 대한 정상/오류 케이스 커버 완료
### 오류/디버깅 로그 요약
- 그룹 경로(other) 정규식 기대값 수정으로 테스트 안정화
- SHA-256 재계산 불일치 회피: 테스트에서 중복 해시 구현 제거, 산출물 기준 비교
- Node/브라우저 호환 btoa/atob 제거, 안전한 변환 로직 적용
### 다음 작업
1) PR 생성/머지 순서 관리: `feat/image-optimization`, `feat/verify-integrity-cli` → main
2) CI 검증 리포트 형식 보강: 요약 필드(total/missing/...)를 워크플로 로그에 하이라이트
3) PR 라벨 자동화 및 조건부 머지(Gitea Actions/웹훅) 유지
4) 변환 파이프라인(AVIF/WebP) 설계 초안(TDD 스텁)
5) README/개발자 가이드에 `./artifacts/export` 폴더 정책 명시(로컬/CI 공통)
### 산출물 경로 정책(CI)
- 기본 폴더: `./artifacts/export/` (ZIP 해제 또는 직접 생성)
- 검증 입력: `npm run verify:make-json``./artifacts/extras.json`/`./artifacts/assets.json`
- 검증 실행: `npm run verify:run` (종료코드 0/1/2)
### CI/CD 계획(초안)
- PR 오픈 시: 라벨 자동 지정, 테스트/빌드, 무결성 검증 스텝 실행
- main 머지 조건: 테스트/빌드 통과 + 무결성 검증 통과 + 최소 라벨 셋 충족
### 커밋/푸쉬 원칙
- 기능 단위 커밋은 한국어 메시지
- 대범위 브랜치 완료 전에 푸쉬 금지(현 단계는 완료되어 푸쉬 수행)
## WYSIWYG 캔버스(드림위버 스타일) 계획
### 목표
- 컨텐츠 영역에서 오브젝트를 자유 배치(드래그/리사이즈/회전)하여 랜딩을 구성
- 웹표준/접근성/모바일·PC 반응형을 모두 충족하는 Export 산출
### 핵심 설계 원칙
- 의미론적 HTML Export: Text→h1~h6/p, Button→a/button, Image→img/picture
- 접근성
- 레이어(탭) 순서 ↔ DOM 순서 매핑, 필요 시 탭 순서 수동 설정
- 이미지 alt/버튼 aria-label 필수, 헤딩 레벨 강제, 키보드 포커스/스킵링크 유지
- 색 대비/타겟 크기 가이드 및 경고
- 반응형
- 1차: 고정 비율 스케일(MVP)
- 2차: 브레이크포인트 재배치(선택 그룹)
- 3차: 유동 단위/컨테이너 쿼리, clamp 폰트 확장
- 품질 가드: 8px 격자 + 가장자리/중심 스냅, 오버랩/가독성 경고, 역할/속성 프리셋
### 기술/구현 전략
- 모델: Frame/Layer/Object(Text/Image/Button) + 의미/접근성 필드(zod 스키마)
- 편집: Moveable로 드래그/리사이즈/회전 + 스냅/가이드/키보드 nudge
- Export: 오브젝트→의미론 태그 매핑, 반응형(스케일→브레이크포인트), 이미지 최적화 옵션 반영
### 마일스톤
- M1: 캔버스 조작(드/리/회+스냅) TDD → 최소 구현
- M2: 레이어/속성 패널 TDD → UI 연결(순서/잠금/숨김, 위치/크기/회전/폰트/색/링크/이미지)
- M3: Export(의미론+스케일) TDD → ZIP 생성 연동
- M4: a11y/반응형 회귀 스모크 확대 + 품질 가드/경고
### 진행 현황
- 브랜치: feat/wysiwyg-canvas 생성
- 스키마 TDD 완료: lib/wysiwyg/schema.ts(+test)
- 다음 작업: Moveable 도입 캔버스 조작 TDD(8px 격자/가장자리 스냅/키보드 nudge)
+121
View File
@@ -34,3 +34,124 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
## Export Bundle (Landing ZIP)
The Builder can export a production-ready ZIP that contains:
- index.html, styles.css, app.js
- assets/ hashed assets (optionally grouped by type)
- site.webmanifest, robots.txt
- Optional: sheets/ (Google Apps Script template)
- Metadata files: assets.json, assets.index.json
- Integrity: assets.integrity.index.json
### Export Options UI
- Manifest
- Name, Short Name, Start URL, Display (dropdown), Theme Color (HEX only)
- Robots
- Disallow lines (textarea). Each line becomes a `Disallow:` rule.
- Robots Meta: `<meta name="robots" content="...">` value (e.g. `noindex, nofollow`). Empty disables the tag.
- Google Sheets Template Mode
- Auto: include when `form.actionUrl` is empty
- Include: always include
- Exclude: never include
- Font Optimization
- Preconnect: toggle inclusion of Google Fonts preconnect links.
- Preload: preload & load Google Fonts stylesheet with `media="print" onload` pattern.
- Image Options
- Loading: `lazy` (default) or `eager` for `<img loading>`
- Decoding: `async` (default) or `sync` for `<img decoding>`
- Fetch Priority: optional `high|low` for `<img fetchpriority>`
- Srcset/Sizes: optional responsive sources via `<img srcset>` and `<img sizes>`
- Auto Sizes: when enabled and only `srcset` is provided, a sensible default is applied to `<img sizes>`: `(max-width: 640px) 100vw, 600px`
- Sizes Preset: choose a preset for `<img sizes>` 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
- `site.webmanifest` is derived from page data; Export Options can override fields.
- `robots.txt` defaults to a permissive header and adds `Disallow` lines from UI.
- `meta name="robots"` can be injected via Export Options and is written in `<head>`.
### Asset paths and grouping
- AssetManager writes files to `assets/` with stable hash-based filenames.
### verifyIntegrity CLI
- Purpose: Validate exported bundle assets against `assets.integrity.index.json`.
- Input format: two JSON files mapping file path → base64 bytes
- Extras JSON: must contain `"assets.integrity.index.json"` key with base64 of the index file
- Assets JSON: keys are asset paths, values are base64 of file bytes
- Usage:
```bash
node scripts/verify.js --extras extras.json --assets assets.json
```
- Output: prints a JSON with fields of `VerifyResult` (`ok`, `errors`, `summary`)
- Exit codes:
- 0: ok
- 1: verification failed or invalid inputs
- 2: usage error (missing args)
- CI: add a step to run the CLI after build/export; fail the job if exit code != 0.
- Path strategy can be configured:
- `flat` (default): `assets/<hash>.<ext>`
- `grouped`: `assets/images/...`, `assets/fonts/...`, `assets/other/...`
### assets.json
Structured metadata for all assets. Example fields per entry:
- originalName, mimeType, size
- hash8, ext, zipPath
- integrity: `sha256-<base64>` (placeholder, base64 of bytes)
- group: `images|fonts|other`
- referencedAt: array of `local:<originalName>` when referenced by the page
### assets.index.json
Simple index to quickly find assets by group:
- images: string[] of zip paths
- fonts: string[] of zip paths
- other: string[] of zip paths
### assets.integrity.index.json
- entries: array of `{ path, integrity, size }`
- `path`: asset `zipPath` (e.g. `assets/abcd1234.png` or grouped `assets/images/...`)
- `integrity`: `sha256-<base64>` (real SHA-256 of the asset bytes)
- `size`: byte length of the asset
- bundleHash: `sha256-<base64>` over a deterministic join of `path` and `integrity` for all entries
Usage:
- Verify file-level integrity by recomputing SHA-256(base64) of each asset and comparing with `entries[].integrity`.
- 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:
- `sheets/Code.gs`: minimal Apps Script handler (Web App)
- `sheets/README.txt`: setup instructions
- Inclusion is controlled by `sheetsMode` (Auto/Include/Exclude) and `form.actionUrl` in Auto mode.
+5
View File
@@ -0,0 +1,5 @@
import BuilderClientPage from '@/components/BuilderClientPage'
export default function BuilderPage() {
return <BuilderClientPage />
}
+25
View File
@@ -0,0 +1,25 @@
import type { ReactNode } from 'react'
import { getMessages } from '@/lib/i18n/getMessages'
import type { AppLocale } from '@/lib/i18n/locales'
import I18nProvider from '@/components/I18nProvider'
import '../globals.css'
export const dynamic = 'force-static'
export default async function LocaleLayout({
children,
params,
}: {
children: ReactNode
params: Promise<{ locale: AppLocale }>
}) {
const { locale } = await params
const messages = await getMessages(locale)
return (
<html lang={locale}>
<body>
<I18nProvider locale={locale} messages={messages}>{children}</I18nProvider>
</body>
</html>
)
}
+15
View File
@@ -0,0 +1,15 @@
import Link from 'next/link'
import { defaultLocale } from '@/lib/i18n/locales'
export default function IndexPage({ params }: { params: { locale: string } }) {
const loc = params.locale || defaultLocale
return (
<main className="p-6">
<h1 className="text-2xl font-semibold mb-4">Landing Builder</h1>
<p className="mb-4">Start building your landing page.</p>
<Link className="text-sky-600 underline" href={`/${loc}/builder`}>
Go to Builder
</Link>
</main>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function BuilderPage() {
redirect('/en/builder')
}
@@ -0,0 +1,44 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { NextIntlClientProvider } from 'next-intl'
import { describe, expect, test } from 'vitest'
import BuilderClientPage from './BuilderClientPage'
import messages from '../messages/en.json'
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<NextIntlClientProvider messages={messages} locale="en">
{children}
</NextIntlClientProvider>
)
}
describe('Inspector Add buttons title consistency', () => {
test('Features/Testimonials/FAQ Add buttons have title matching visible label', async () => {
render(
<Wrapper>
<BuilderClientPage />
</Wrapper>
)
// Features
fireEvent.click(screen.getByTestId('card-features'))
fireEvent.click(await screen.findByLabelText('Select 0'))
const addFeature = await screen.findByRole('button', { name: /Add Feature Item/i })
expect(addFeature).toBeInTheDocument()
expect(addFeature.getAttribute('title')).toBe('Add Feature Item')
// Testimonials
fireEvent.click(screen.getByTestId('card-testimonials'))
fireEvent.click(await screen.findByLabelText('Select 1'))
const addTestimonial = await screen.findByRole('button', { name: /Add Testimonial/i })
expect(addTestimonial).toBeInTheDocument()
expect(addTestimonial.getAttribute('title')).toBe('Add Testimonial')
// FAQ
fireEvent.click(screen.getByTestId('card-faq'))
fireEvent.click(await screen.findByLabelText('Select 2'))
const addFaq = await screen.findByRole('button', { name: /Add FAQ Item/i })
expect(addFaq).toBeInTheDocument()
expect(addFaq.getAttribute('title')).toBe('Add FAQ Item')
})
})
@@ -0,0 +1,47 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { NextIntlClientProvider } from 'next-intl'
import { describe, expect, test } from 'vitest'
import BuilderClientPage from './BuilderClientPage'
import messages from '../messages/en.json'
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<NextIntlClientProvider messages={messages} locale="en">
{children}
</NextIntlClientProvider>
)
}
describe('BuilderClientPage - inspector aria-describedby (Features/Testimonials)', () => {
test('Features inputs reference help via aria-describedby', async () => {
render(
<Wrapper>
<BuilderClientPage />
</Wrapper>
)
fireEvent.click(screen.getByTestId('card-features'))
fireEvent.click(await screen.findByLabelText('Select 0'))
const feature0 = await screen.findByLabelText('Feature 0')
expect(feature0).toHaveAttribute('aria-describedby', 'ins-features-help')
})
test('Testimonials inputs reference help via aria-describedby', async () => {
render(
<Wrapper>
<BuilderClientPage />
</Wrapper>
)
fireEvent.click(screen.getByTestId('card-testimonials'))
fireEvent.click(await screen.findByLabelText('Select 0'))
fireEvent.click(screen.getByRole('button', { name: /Add Testimonial/i }))
const author0 = await screen.findByLabelText('Author 0')
const quote0 = await screen.findByLabelText('Quote 0')
expect(author0).toHaveAttribute('aria-describedby', 'ins-testimonials-help')
expect(quote0).toHaveAttribute('aria-describedby', 'ins-testimonials-help')
})
})
@@ -0,0 +1,62 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { NextIntlClientProvider } from 'next-intl'
import { describe, expect, test } from 'vitest'
import BuilderClientPage from './BuilderClientPage'
import messages from '../messages/en.json'
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<NextIntlClientProvider messages={messages} locale="en">
{children}
</NextIntlClientProvider>
)
}
describe('BuilderClientPage - inspector aria-describedby (Hero/CTA/FAQ)', () => {
test('Hero input references help via aria-describedby', async () => {
render(
<Wrapper>
<BuilderClientPage />
</Wrapper>
)
fireEvent.click(screen.getByTestId('card-hero'))
fireEvent.click(await screen.findByLabelText('Select 0'))
const hero = await screen.findByLabelText('Hero Heading')
expect(hero).toHaveAttribute('aria-describedby', 'ins-hero-heading-help')
})
test('CTA inputs reference help via aria-describedby', async () => {
render(
<Wrapper>
<BuilderClientPage />
</Wrapper>
)
fireEvent.click(screen.getByTestId('card-cta'))
fireEvent.click(await screen.findByLabelText('Select 0'))
const text = await screen.findByLabelText('CTA Text')
const href = await screen.findByLabelText('CTA Href')
expect(text).toHaveAttribute('aria-describedby', 'ins-cta-text-help')
expect(href).toHaveAttribute('aria-describedby', 'ins-cta-href-help')
})
test('FAQ inputs reference help via aria-describedby', async () => {
render(
<Wrapper>
<BuilderClientPage />
</Wrapper>
)
fireEvent.click(screen.getByTestId('card-faq'))
fireEvent.click(await screen.findByLabelText('Select 0'))
fireEvent.click(await screen.findByRole('button', { name: 'Add FAQ Item' }))
const q0 = await screen.findByLabelText('FAQ Q 0')
const a0 = await screen.findByLabelText('FAQ A 0')
expect(q0).toHaveAttribute('aria-describedby', 'ins-faq-help')
expect(a0).toHaveAttribute('aria-describedby', 'ins-faq-help')
})
})
@@ -0,0 +1,35 @@
import { render, screen } from '@testing-library/react'
import { NextIntlClientProvider } from 'next-intl'
import { describe, expect, test } from 'vitest'
import BuilderClientPage from './BuilderClientPage'
import messages from '../messages/en.json'
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<NextIntlClientProvider messages={messages} locale="en">
{children}
</NextIntlClientProvider>
)
}
describe('BuilderClientPage - inspector aria-describedby smoke', () => {
test('SEO/Analytics inputs reference helptext via aria-describedby', async () => {
render(
<Wrapper>
<BuilderClientPage />
</Wrapper>
)
const seoTitle = await screen.findByLabelText('SEO Title')
const seoDesc = await screen.findByLabelText('SEO Description')
const ogImage = await screen.findByLabelText('OG Image URL')
const ga4 = await screen.findByLabelText('GA4 Measurement ID')
const pixel = await screen.findByLabelText('Meta Pixel ID')
expect(seoTitle).toHaveAttribute('aria-describedby', 'ins-seo-title-help')
expect(seoDesc).toHaveAttribute('aria-describedby', 'ins-seo-desc-help')
expect(ogImage).toHaveAttribute('aria-describedby', 'ins-og-image-help')
expect(ga4).toHaveAttribute('aria-describedby', 'ins-ga4-help')
expect(pixel).toHaveAttribute('aria-describedby', 'ins-meta-pixel-help')
})
})
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import React from 'react'
import { NextIntlClientProvider } from 'next-intl'
import enMessages from '@/messages/en.json'
import BuilderClientPage from '@/components/BuilderClientPage'
const messages: Record<string, string> = enMessages as Record<string, string>
describe('BuilderClientPage - features/testimonials preview', () => {
it('사이드바 카드 클릭으로 features/testimonials 섹션이 추가되고 프리뷰에 표기된다', () => {
render(
<NextIntlClientProvider locale="en" messages={messages}>
<BuilderClientPage />
</NextIntlClientProvider>
)
// features 추가
fireEvent.click(screen.getByTestId('card-features'))
// testimonials 추가
fireEvent.click(screen.getByTestId('card-testimonials'))
// 프리뷰에서 섹션 요약 표기 확인
expect(screen.getByText(/features — items: 3/)).toBeInTheDocument()
expect(screen.getByText(/testimonials — items: 2/)).toBeInTheDocument()
})
})
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import React from 'react'
import BuilderClientPage from '@/components/BuilderClientPage'
import { NextIntlClientProvider } from 'next-intl'
import enMessages from '@/messages/en.json'
const messages: Record<string, string> = enMessages as Record<string, string>
describe('BuilderClientPage - form preview', () => {
it('폼 필드 목록과 필수 여부가 미리보기에 표시된다', () => {
render(
<NextIntlClientProvider locale="en" messages={messages}>
<BuilderClientPage />
</NextIntlClientProvider>
)
// FormBuilderPanel의 텍스트 필드 추가 버튼 클릭 (data-testid 사용)
fireEvent.click(screen.getByTestId('btn-add-text'))
// 프리뷰 존재 확인(컴팩트 인스펙터 영역으로 이동됨)
expect(screen.getByText(/Locale:/)).toBeInTheDocument()
// 첫 필드 항목 표기: 라벨/네임/required 여부(초기 false)
expect(screen.getByText(/Text 1 \(field1\)/)).toBeInTheDocument()
expect(screen.queryByText(/required/)).not.toBeNull()
// required 토글 후 프리뷰 갱신 확인
fireEvent.click(screen.getByLabelText('Required 0'))
expect(screen.getAllByText('required').length).toBeGreaterThan(0)
})
})
@@ -0,0 +1,53 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { NextIntlClientProvider } from 'next-intl'
import BuilderClientPage from '@/components/BuilderClientPage'
declare global {
interface Window { __exportPreview?: () => { html: string } }
}
describe('BuilderClientPage 이미지 옵션 UI 연동', () => {
beforeEach(() => {
// JSDOM 환경 초기화용
window.__exportPreview = undefined
})
it('기본값으로 img에 loading="lazy" decoding="async"가 포함된다', async () => {
render(
<NextIntlClientProvider locale="en" messages={{}}>
<BuilderClientPage />
</NextIntlClientProvider>
)
// Ensure a hero section with image is present
const addHeroBtn = screen.getByTestId('btn-add-hero')
addHeroBtn.click()
expect(typeof window.__exportPreview).toBe('function')
await waitFor(() => {
const out = window.__exportPreview!()
expect(out.html).toContain('loading="lazy"')
expect(out.html).toContain('decoding="async"')
})
})
it('UI에서 eager/sync로 변경 시 preview HTML에 반영된다', async () => {
render(
<NextIntlClientProvider locale="en" messages={{}}>
<BuilderClientPage />
</NextIntlClientProvider>
)
const addHeroBtn = screen.getByTestId('btn-add-hero')
addHeroBtn.click()
const loadingSelect = screen.getByLabelText('Image Loading') as HTMLSelectElement
const decodingSelect = screen.getByLabelText('Image Decoding') as HTMLSelectElement
fireEvent.change(loadingSelect, { target: { value: 'eager' } })
fireEvent.change(decodingSelect, { target: { value: 'sync' } })
await waitFor(() => {
const out = window.__exportPreview!()
expect(out.html).toContain('loading="eager"')
expect(out.html).toContain('decoding="sync"')
})
})
})
@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import React from 'react'
import { NextIntlClientProvider } from 'next-intl'
import enMessages from '@/messages/en.json'
import BuilderClientPage from '@/components/BuilderClientPage'
const messages: Record<string, string> = enMessages as Record<string, string>
describe('BuilderClientPage - Inspector CTA', () => {
it('CTA 선택 후 인스펙터에서 텍스트/링크를 편집할 수 있다', () => {
render(
<NextIntlClientProvider locale="en" messages={messages}>
<BuilderClientPage />
</NextIntlClientProvider>
)
// CTA 섹션 추가
fireEvent.click(screen.getByTestId('btn-add-cta'))
// 선택
fireEvent.click(screen.getByLabelText('Select 0'))
// 인풋 존재 및 값 변경
const textInput = screen.getByLabelText('CTA Text') as HTMLInputElement
const hrefInput = screen.getByLabelText('CTA Href') as HTMLInputElement
expect(textInput.value).toBeTruthy()
expect(hrefInput.value).toBeTruthy()
fireEvent.change(textInput, { target: { value: 'Buy Now' } })
fireEvent.change(hrefInput, { target: { value: 'https://example.com' } })
expect((screen.getByLabelText('CTA Text') as HTMLInputElement).value).toBe('Buy Now')
expect((screen.getByLabelText('CTA Href') as HTMLInputElement).value).toBe('https://example.com')
})
})
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import React from 'react'
import { NextIntlClientProvider } from 'next-intl'
import enMessages from '@/messages/en.json'
import BuilderClientPage from '@/components/BuilderClientPage'
const messages: Record<string, string> = enMessages as Record<string, string>
describe('BuilderClientPage - Inspector FAQ', () => {
it('FAQ 선택 후 인스펙터에서 아이템을 편집할 수 있다(쉼표 구분)', () => {
render(
<NextIntlClientProvider locale="en" messages={messages}>
<BuilderClientPage />
</NextIntlClientProvider>
)
// FAQ 섹션 추가 및 선택
fireEvent.click(screen.getByTestId('btn-add-faq'))
fireEvent.click(screen.getByLabelText('Select 0'))
// 리스트 UI: Add FAQ Item 후 Q/A 입력 변경 확인
fireEvent.click(screen.getByRole('button', { name: 'Add FAQ Item' }))
const q0 = screen.getByLabelText('FAQ Q 0') as HTMLInputElement
const a0 = screen.getByLabelText('FAQ A 0') as HTMLInputElement
fireEvent.change(q0, { target: { value: 'Q1?' } })
fireEvent.change(a0, { target: { value: 'A1' } })
expect((screen.getByLabelText('FAQ Q 0') as HTMLInputElement).value).toBe('Q1?')
expect((screen.getByLabelText('FAQ A 0') as HTMLInputElement).value).toBe('A1')
})
})
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import React from 'react'
import { NextIntlClientProvider } from 'next-intl'
import enMessages from '@/messages/en.json'
import BuilderClientPage from '@/components/BuilderClientPage'
const messages: Record<string, string> = enMessages as Record<string, string>
describe('BuilderClientPage - Inspector FAQ List UI', () => {
it('FAQ 항목을 추가/편집/삭제할 수 있다', () => {
render(
<NextIntlClientProvider locale="en" messages={messages}>
<BuilderClientPage />
</NextIntlClientProvider>
)
// FAQ 섹션 추가 및 선택
fireEvent.click(screen.getByTestId('btn-add-faq'))
fireEvent.click(screen.getByLabelText('Select 0'))
// Add FAQ Item 버튼 클릭 → 첫 항목 생성
fireEvent.click(screen.getByRole('button', { name: 'Add FAQ Item' }))
const q0 = screen.getByLabelText('FAQ Q 0') as HTMLInputElement
const a0 = screen.getByLabelText('FAQ A 0') as HTMLInputElement
fireEvent.change(q0, { target: { value: 'What is it?' } })
fireEvent.change(a0, { target: { value: 'A landing builder' } })
expect((screen.getByLabelText('FAQ Q 0') as HTMLInputElement).value).toBe('What is it?')
expect((screen.getByLabelText('FAQ A 0') as HTMLInputElement).value).toBe('A landing builder')
// 두 번째 항목 추가 후 삭제
fireEvent.click(screen.getByRole('button', { name: 'Add FAQ Item' }))
const q1 = screen.getByLabelText('FAQ Q 1') as HTMLInputElement
fireEvent.change(q1, { target: { value: 'How to export?' } })
fireEvent.click(screen.getByLabelText('Remove FAQ 1'))
// 삭제 후 첫 항목만 남음
expect(screen.queryByLabelText('FAQ Q 1')).toBeNull()
expect(screen.getByLabelText('FAQ Q 0')).toBeInTheDocument()
})
})
@@ -0,0 +1,63 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { NextIntlClientProvider } from 'next-intl'
import { describe, expect, test } from 'vitest'
import BuilderClientPage from './BuilderClientPage'
import messages from '../messages/en.json'
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<NextIntlClientProvider messages={messages} locale="en">
{children}
</NextIntlClientProvider>
)
}
describe('Inspector - Features & Testimonials editing', () => {
test('Features: add item and edit value', () => {
render(
<Wrapper>
<BuilderClientPage />
</Wrapper>
)
// add Features section via sidebar
fireEvent.click(screen.getByTestId('card-features'))
// select the section in canvas (index 0)
fireEvent.click(screen.getByLabelText('Select 0'))
// add item
fireEvent.click(screen.getByRole('button', { name: /Add Feature Item/i }))
// edit item 0
const input = screen.getByLabelText('Feature 0') as HTMLInputElement
fireEvent.change(input, { target: { value: 'New Feature' } })
expect(input.value).toBe('New Feature')
})
test('Testimonials: add item and edit author/quote', () => {
render(
<Wrapper>
<BuilderClientPage />
</Wrapper>
)
// add Testimonials section via sidebar
fireEvent.click(screen.getByTestId('card-testimonials'))
// select the section (index 0)
fireEvent.click(screen.getByLabelText('Select 0'))
// add item
fireEvent.click(screen.getByRole('button', { name: /Add Testimonial/i }))
const author = screen.getByLabelText('Author 0') as HTMLInputElement
const quote = screen.getByLabelText('Quote 0') as HTMLInputElement
fireEvent.change(author, { target: { value: 'Alice' } })
fireEvent.change(quote, { target: { value: 'Awesome!' } })
expect(author.value).toBe('Alice')
expect(quote.value).toBe('Awesome!')
})
})
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import React from 'react'
import { NextIntlClientProvider } from 'next-intl'
import enMessages from '@/messages/en.json'
import BuilderClientPage from '@/components/BuilderClientPage'
const messages: Record<string, string> = enMessages as Record<string, string>
describe('BuilderClientPage - Inspector panel', () => {
it('섹션 선택 후 인스펙터에서 Hero heading을 편집할 수 있다', () => {
render(
<NextIntlClientProvider locale="en" messages={messages}>
<BuilderClientPage />
</NextIntlClientProvider>
)
// Hero 섹션 추가 (data-testid 사용)
fireEvent.click(screen.getByTestId('btn-add-hero'))
// 캔버스에서 첫 섹션 선택
fireEvent.click(screen.getByLabelText('Select 0'))
// 인스펙터에서 Hero Heading 입력 존재
const heading = screen.getByLabelText('Hero Heading') as HTMLInputElement
expect(heading).toBeInTheDocument()
// 기본값 존재(Welcome)
expect(heading.value).toBeTruthy()
// 값 변경
fireEvent.change(heading, { target: { value: 'Hello World' } })
expect((screen.getByLabelText('Hero Heading') as HTMLInputElement).value).toBe('Hello World')
})
})
@@ -0,0 +1,42 @@
import { render, screen, within } from '@testing-library/react'
import { NextIntlClientProvider } from 'next-intl'
import { describe, expect, test } from 'vitest'
import BuilderClientPage from './BuilderClientPage'
import messages from '../messages/en.json'
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<NextIntlClientProvider messages={messages} locale="en">
{children}
</NextIntlClientProvider>
)
}
describe('BuilderClientPage - Inspector helptexts (Hero/CTA/FAQ)', () => {
test('Hero, CTA, FAQ helptexts render', async () => {
render(
<Wrapper>
<BuilderClientPage />
</Wrapper>
)
// add hero and select it from canvas
screen.getByTestId('card-hero').click()
const sec0 = await screen.findByTestId('sec-item-0')
within(sec0).getByRole('button', { name: /Select/ }).click()
expect(await screen.findByText(/Main heading displayed prominently/i)).toBeInTheDocument()
// add cta and select it from canvas
screen.getByTestId('card-cta').click()
const sec1 = await screen.findByTestId('sec-item-1')
within(sec1).getByRole('button', { name: /Select/ }).click()
expect(await screen.findByLabelText('CTA Text')).toBeInTheDocument()
expect(await screen.findByLabelText('CTA Href')).toBeInTheDocument()
// add faq and select it from canvas
screen.getByTestId('card-faq').click()
const sec2 = await screen.findByTestId('sec-item-2')
within(sec2).getByRole('button', { name: /Select/ }).click()
expect(await screen.findByText(/Add at least one FAQ item with Q and A/i)).toBeInTheDocument()
})
})
@@ -0,0 +1,66 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { NextIntlClientProvider } from 'next-intl'
import { describe, expect, test } from 'vitest'
import BuilderClientPage from './BuilderClientPage'
import messages from '../messages/en.json'
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<NextIntlClientProvider messages={messages} locale="en">
{children}
</NextIntlClientProvider>
)
}
describe('Inspector keyboard UX - Features & Testimonials add item', () => {
test('Features: Space/Enter activates Add Feature Item', () => {
render(
<Wrapper>
<BuilderClientPage />
</Wrapper>
)
// Add features section and select it
fireEvent.click(screen.getByTestId('card-features'))
fireEvent.click(screen.getByLabelText('Select 0'))
const addBtn = screen.getByRole('button', { name: /Add Feature Item/i })
// Space key
addBtn.focus()
fireEvent.keyDown(addBtn, { key: ' ', code: 'Space', charCode: 32 })
// Expect first input to appear (Feature 0)
expect(screen.getByLabelText('Feature 0')).toBeInTheDocument()
// Enter key adds another
addBtn.focus()
fireEvent.keyDown(addBtn, { key: 'Enter', code: 'Enter', charCode: 13 })
expect(screen.getByLabelText('Feature 1')).toBeInTheDocument()
})
test('Testimonials: Space/Enter activates Add Testimonial', () => {
render(
<Wrapper>
<BuilderClientPage />
</Wrapper>
)
// Add testimonials section and select it
fireEvent.click(screen.getByTestId('card-testimonials'))
fireEvent.click(screen.getByLabelText('Select 0'))
const addBtn = screen.getByRole('button', { name: /Add Testimonial/i })
// Space
addBtn.focus()
fireEvent.keyDown(addBtn, { key: ' ', code: 'Space', charCode: 32 })
expect(screen.getByLabelText('Author 0')).toBeInTheDocument()
expect(screen.getByLabelText('Quote 0')).toBeInTheDocument()
// Enter adds another
addBtn.focus()
fireEvent.keyDown(addBtn, { key: 'Enter', code: 'Enter', charCode: 13 })
expect(screen.getByLabelText('Author 1')).toBeInTheDocument()
expect(screen.getByLabelText('Quote 1')).toBeInTheDocument()
})
})
@@ -0,0 +1,111 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NextIntlClientProvider } from 'next-intl'
import { describe, expect, test } from 'vitest'
import BuilderClientPage from './BuilderClientPage'
import messages from '../messages/en.json'
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<NextIntlClientProvider messages={messages} locale="en">
{children}
</NextIntlClientProvider>
)
}
describe('Inspector keyboard UX - Remove actions', () => {
test('Features: Remove with Space/Enter', async () => {
render(
<Wrapper>
<BuilderClientPage />
</Wrapper>
)
// Add features and select
fireEvent.click(screen.getByTestId('card-features'))
fireEvent.click(screen.getByLabelText('Select 0'))
// Add two items
const addBtn = screen.getByRole('button', { name: /Add Feature Item/i })
let inputs = screen.queryAllByRole('textbox', { name: /Feature \d+/ })
const beforeAdd = inputs.length
fireEvent.click(addBtn)
fireEvent.click(addBtn)
inputs = screen.queryAllByRole('textbox', { name: /Feature \d+/ })
expect(inputs.length).toBe(beforeAdd + 2)
// Remove last item via Space
inputs = screen.queryAllByRole('textbox', { name: /Feature \d+/ })
const beforeRemoveA = inputs.length
let removeButtons = screen.queryAllByRole('button', { name: /Remove Feature \d+/ })
const rmLastA = removeButtons[removeButtons.length - 1]
rmLastA.focus()
fireEvent.keyUp(rmLastA, { key: ' ', code: 'Space', charCode: 32 })
await waitFor(() => {
const now = screen.queryAllByRole('textbox', { name: /Feature \d+/ })
expect(now.length).toBe(beforeRemoveA - 1)
})
// Add another then remove last via Enter
fireEvent.click(addBtn)
inputs = screen.queryAllByRole('textbox', { name: /Feature \d+/ })
const beforeRemoveB = inputs.length
removeButtons = screen.queryAllByRole('button', { name: /Remove Feature \d+/ })
const rmLastB = removeButtons[removeButtons.length - 1]
rmLastB.focus()
fireEvent.keyUp(rmLastB, { key: 'Enter', code: 'Enter', charCode: 13 })
await waitFor(() => {
const now = screen.queryAllByRole('textbox', { name: /Feature \d+/ })
expect(now.length).toBe(beforeRemoveB - 1)
})
})
test('Testimonials: Remove with Space/Enter', async () => {
render(
<Wrapper>
<BuilderClientPage />
</Wrapper>
)
// Add testimonials and select
fireEvent.click(screen.getByTestId('card-testimonials'))
fireEvent.click(screen.getByLabelText('Select 0'))
// Add two items
const addBtn = screen.getByRole('button', { name: /Add Testimonial/i })
let authors = screen.queryAllByRole('textbox', { name: /Author \d+/ })
let quotes = screen.queryAllByRole('textbox', { name: /Quote \d+/ })
const beforeAddT = authors.length
fireEvent.click(addBtn)
fireEvent.click(addBtn)
authors = screen.queryAllByRole('textbox', { name: /Author \d+/ })
quotes = screen.queryAllByRole('textbox', { name: /Quote \d+/ })
expect(authors.length).toBe(beforeAddT + 2)
expect(quotes.length).toBe(beforeAddT + 2)
// Remove last item via Enter
authors = screen.queryAllByRole('textbox', { name: /Author \d+/ })
let beforeRemoveT = authors.length
let rmTButtons = screen.queryAllByRole('button', { name: /Remove Testimonial \d+/ })
let rmLastT = rmTButtons[rmTButtons.length - 1]
rmLastT.focus()
fireEvent.keyUp(rmLastT, { key: 'Enter', code: 'Enter', charCode: 13 })
await waitFor(() => {
const aNow = screen.queryAllByRole('textbox', { name: /Author \d+/ })
expect(aNow.length).toBe(beforeRemoveT - 1)
})
// Add another then remove last via Space
fireEvent.click(addBtn)
authors = screen.queryAllByRole('textbox', { name: /Author \d+/ })
beforeRemoveT = authors.length
rmTButtons = screen.queryAllByRole('button', { name: /Remove Testimonial \d+/ })
rmLastT = rmTButtons[rmTButtons.length - 1]
rmLastT.focus()
fireEvent.keyUp(rmLastT, { key: ' ', code: 'Space', charCode: 32 })
await waitFor(() => {
const aNow = screen.queryAllByRole('textbox', { name: /Author \d+/ })
expect(aNow.length).toBe(beforeRemoveT - 1)
})
})
})
@@ -0,0 +1,59 @@
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
import { NextIntlClientProvider } from 'next-intl'
import { describe, expect, test, beforeEach } from 'vitest'
import BuilderClientPage from './BuilderClientPage'
import messages from '../messages/en.json'
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<NextIntlClientProvider messages={messages} locale="en">
{children}
</NextIntlClientProvider>
)
}
describe('BuilderClientPage - preview viewport persistence & placeholders', () => {
beforeEach(() => {
localStorage.clear()
cleanup()
})
test('persists viewport selection in localStorage', () => {
render(
<Wrapper>
<BuilderClientPage />
</Wrapper>
)
const container = screen.getByTestId('preview-container')
expect(container).toHaveClass('w-[768px]')
fireEvent.click(screen.getByRole('button', { name: /Mobile/i }))
expect(container).toHaveClass('w-[375px]')
// remount
cleanup()
render(
<Wrapper>
<BuilderClientPage />
</Wrapper>
)
const container2 = screen.getByTestId('preview-container')
expect(container2).toHaveClass('w-[375px]')
})
test('inspector inputs have helpful placeholders', () => {
render(
<Wrapper>
<BuilderClientPage />
</Wrapper>
)
expect(screen.getByLabelText('SEO Title')).toHaveAttribute('placeholder')
expect(screen.getByLabelText('SEO Description')).toHaveAttribute('placeholder')
expect(screen.getByLabelText('OG Image URL')).toHaveAttribute('placeholder')
expect(screen.getByLabelText('GA4 Measurement ID')).toHaveAttribute('placeholder')
expect(screen.getByLabelText('Meta Pixel ID')).toHaveAttribute('placeholder')
})
})
@@ -0,0 +1,33 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { NextIntlClientProvider } from 'next-intl'
import { describe, expect, test } from 'vitest'
import BuilderClientPage from './BuilderClientPage'
import messages from '../messages/en.json'
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<NextIntlClientProvider messages={messages} locale="en">
{children}
</NextIntlClientProvider>
)
}
describe('BuilderClientPage - preview viewport toggle', () => {
test('toggles preview width between mobile and desktop', () => {
render(
<Wrapper>
<BuilderClientPage />
</Wrapper>
)
const container = screen.getByTestId('preview-container')
// default is desktop
expect(container).toHaveClass('w-[768px]')
fireEvent.click(screen.getByRole('button', { name: /Mobile/i }))
expect(container).toHaveClass('w-[375px]')
fireEvent.click(screen.getByRole('button', { name: /Desktop/i }))
expect(container).toHaveClass('w-[768px]')
})
})
@@ -0,0 +1,49 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { NextIntlClientProvider } from 'next-intl'
import BuilderClientPage from '@/components/BuilderClientPage'
declare global {
interface Window { __exportPreview?: () => { html: string } }
}
describe('BuilderClientPage robots meta UI 연동', () => {
beforeEach(() => {
window.__exportPreview = undefined
})
it('기본값에서는 robots meta가 존재하지 않는다', async () => {
render(
<NextIntlClientProvider locale="en" messages={{}}>
<BuilderClientPage />
</NextIntlClientProvider>
)
// Hero 추가(렌더 내용 명확화)
const addHeroBtn = screen.getByTestId('btn-add-hero')
addHeroBtn.click()
expect(typeof window.__exportPreview).toBe('function')
await waitFor(() => {
const out = window.__exportPreview!()
expect(out.html).not.toContain('<meta name="robots"')
})
})
it('UI 입력값이 head robots meta에 반영된다', async () => {
render(
<NextIntlClientProvider locale="en" messages={{}}>
<BuilderClientPage />
</NextIntlClientProvider>
)
const addHeroBtn = screen.getByTestId('btn-add-hero')
addHeroBtn.click()
const robotsInput = screen.getByLabelText('Robots Meta') as HTMLInputElement
fireEvent.change(robotsInput, { target: { value: 'noindex, nofollow' } })
await waitFor(() => {
const out = window.__exportPreview!()
expect(out.html).toContain('<meta name="robots" content="noindex, nofollow"')
})
})
})
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import React from 'react'
import { NextIntlClientProvider } from 'next-intl'
import enMessages from '@/messages/en.json'
import BuilderClientPage from '@/components/BuilderClientPage'
const messages: Record<string, string> = enMessages as Record<string, string>
describe('BuilderClientPage - SectionCanvas integration', () => {
it('섹션을 추가하고 이동/삭제가 동작한다', () => {
render(
<NextIntlClientProvider locale="en" messages={messages}>
<BuilderClientPage />
</NextIntlClientProvider>
)
// 섹션 3개 추가: Hero, FAQ, CTA (data-testid 사용)
fireEvent.click(screen.getByTestId('btn-add-hero'))
fireEvent.click(screen.getByTestId('btn-add-faq'))
fireEvent.click(screen.getByTestId('btn-add-cta'))
const canvas = screen.getByTestId('section-canvas')
expect(canvas).toBeInTheDocument()
// 초기 순서 확인: hero, faq, cta
let items = canvas.querySelectorAll('li')
expect(items.length).toBe(3)
expect(items[0].textContent).toMatch(/hero/)
expect(items[1].textContent).toMatch(/faq/)
expect(items[2].textContent).toMatch(/cta/)
// 두 번째 항목(faq)을 위로 이동 (Up 1)
fireEvent.click(screen.getByLabelText('Up 1'))
items = canvas.querySelectorAll('li')
expect(items[0].textContent).toMatch(/faq/)
expect(items[1].textContent).toMatch(/hero/)
// 마지막 항목(cta)을 위로 이동 (Up 2)
fireEvent.click(screen.getByLabelText('Up 2'))
items = canvas.querySelectorAll('li')
expect(items[0].textContent).toMatch(/faq/)
expect(items[1].textContent).toMatch(/cta/)
expect(items[2].textContent).toMatch(/hero/)
// 가운데 항목(cta) 삭제 (Remove 1)
fireEvent.click(screen.getByLabelText('Remove 1'))
items = canvas.querySelectorAll('li')
expect(items.length).toBe(2)
expect(items[0].textContent).toMatch(/faq/)
expect(items[1].textContent).toMatch(/hero/)
})
})
@@ -0,0 +1,49 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { NextIntlClientProvider } from 'next-intl'
import { describe, expect, test } from 'vitest'
import BuilderClientPage from './BuilderClientPage'
import messages from '../messages/en.json'
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<NextIntlClientProvider messages={messages} locale="en">
{children}
</NextIntlClientProvider>
)
}
describe('SEO/Analytics inspector', () => {
test('edits SEO title/description and shows in preview', async () => {
render(
<Wrapper>
<BuilderClientPage />
</Wrapper>
)
const seoTitle = screen.getByLabelText('SEO Title')
const seoDesc = screen.getByLabelText('SEO Description')
fireEvent.change(seoTitle, { target: { value: 'Landing SEO' } })
fireEvent.change(seoDesc, { target: { value: 'Great page' } })
expect(await screen.findByTestId('preview-seo-title')).toHaveTextContent('Landing SEO')
expect(await screen.findByTestId('preview-seo-description')).toHaveTextContent('Great page')
})
test('edits Analytics fields and shows in preview', async () => {
render(
<Wrapper>
<BuilderClientPage />
</Wrapper>
)
const ga4 = screen.getByLabelText('GA4 Measurement ID')
const mp = screen.getByLabelText('Meta Pixel ID')
fireEvent.change(ga4, { target: { value: 'G-TEST123' } })
fireEvent.change(mp, { target: { value: '1234567890' } })
expect(await screen.findByTestId('preview-ga4')).toHaveTextContent('G-TEST123')
expect(await screen.findByTestId('preview-meta-pixel')).toHaveTextContent('1234567890')
})
})
@@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react'
import { NextIntlClientProvider } from 'next-intl'
import { describe, expect, test } from 'vitest'
import BuilderClientPage from './BuilderClientPage'
import messages from '../messages/en.json'
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<NextIntlClientProvider messages={messages} locale="en">
{children}
</NextIntlClientProvider>
)
}
describe('BuilderClientPage - SEO/Analytics helptexts', () => {
test('renders helpful hints under SEO/Analytics/OG inputs', () => {
render(
<Wrapper>
<BuilderClientPage />
</Wrapper>
)
expect(screen.getByText(/Shown in browser title and social previews/i)).toBeInTheDocument()
expect(screen.getByText(/Used by search engines and OG description/i)).toBeInTheDocument()
expect(screen.getByText(/Absolute URL recommended/i)).toBeInTheDocument()
expect(screen.getByText(/Format: G-XXXXXXXXXX/i)).toBeInTheDocument()
expect(screen.getByText(/Numeric Page ID/i)).toBeInTheDocument()
})
})
+956
View File
@@ -0,0 +1,956 @@
"use client"
import React, { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { useTranslations } from 'next-intl'
import { pageSchema, type Page } from '@/lib/schema/page'
import { exportPage } from '@/lib/exporter/html'
import { AssetManager } from '@/lib/assets/assetManager'
import { buildZip } from '@/lib/exporter/zip'
import { buildExtrasForPage } from '@/lib/exporter/extras'
import { buildAssetsMetadata } from '@/lib/exporter/assets.meta'
import { buildAssetsIndex } from '@/lib/exporter/assets.index'
import { buildAssetsIntegrityIndex } from '@/lib/exporter/assets.integrity.index'
import type { Section } from '@/lib/state/store'
import { createFormStore, type FormState } from '@/lib/state/formStore'
import FormBuilderPanel from '@/components/FormBuilderPanel'
import BuilderShell from '@/components/BuilderShell'
import SectionCanvas from '@/components/SectionCanvas'
import SidebarComponents from '@/components/SidebarComponents'
function download(filename: string, data: Uint8Array) {
const ab = data.slice().buffer as ArrayBuffer
const blob = new Blob([ab], { type: 'application/zip' })
try {
if (typeof window !== 'undefined') {
const w = window as unknown as { __captureExport?: boolean; __lastBlob?: Blob }
if (w.__captureExport) {
w.__lastBlob = blob
}
}
} catch {}
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
export default function BuilderClientPage() {
const t = useTranslations()
const [locale, setLocale] = useState<'en' | 'ko'>('en')
const [title, setTitle] = useState('My Landing')
const [primaryColor, setPrimaryColor] = useState('#0ea5e9')
const [fontFamily, setFontFamily] = useState('Inter')
const [sections, setSections] = useState<Section[]>([])
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
const [seoTitle, setSeoTitle] = useState<string>('')
const [seoDescription, setSeoDescription] = useState<string>('')
const [ogImage, setOgImage] = useState<string>('')
const [ga4MeasurementId, setGa4MeasurementId] = useState<string>('')
const [metaPixelId, setMetaPixelId] = useState<string>('')
// Export Options (manifest/robots overrides)
const [manifestName, setManifestName] = useState<string>('')
const [manifestShortName, setManifestShortName] = useState<string>('')
const [manifestStartUrl, setManifestStartUrl] = useState<string>('')
const [manifestDisplay, setManifestDisplay] = useState<string>('')
const [manifestThemeColor, setManifestThemeColor] = useState<string>('')
const [robotsDisallow, setRobotsDisallow] = useState<string>('')
const [robotsMeta, setRobotsMeta] = useState<string>('')
const [sheetsMode, setSheetsMode] = useState<'auto' | 'include' | 'exclude'>('auto')
const [assetPathStrategy, setAssetPathStrategy] = useState<'flat' | 'grouped'>('flat')
// Font optimization: allow toggling Google Fonts preconnect links in exported HTML
const [fontPreconnect, setFontPreconnect] = useState<boolean>(true)
const [fontPreload, setFontPreload] = useState<boolean>(false)
const [imageLoading, setImageLoading] = useState<'lazy' | 'eager'>('lazy')
const [imageDecoding, setImageDecoding] = useState<'async' | 'sync'>('async')
const [viewport, setViewport] = useState<'desktop' | 'mobile'>(() => {
if (typeof window !== 'undefined') {
try {
const saved = localStorage.getItem('builder.viewport') as 'desktop' | 'mobile' | null
if (saved === 'desktop' || saved === 'mobile') return saved
} catch {}
}
return 'desktop'
})
const fileInputRef = useRef<HTMLInputElement | null>(null)
const assetManagerRef = useRef<AssetManager>(
new AssetManager({ maxBytes: 10 * 1024 * 1024, allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'] })
)
useEffect(() => {
try {
localStorage.setItem('builder.viewport', viewport)
} catch {}
}, [viewport])
const [am, setAm] = useState<AssetManager | null>(null)
useEffect(() => {
setAm(assetManagerRef.current)
}, [])
const [selectedFileName, setSelectedFileName] = useState<string>('')
// form store (stable instance without ref access in render)
const formStore = useMemo(() => createFormStore(), [])
const formState: FormState = useSyncExternalStore(
formStore.subscribe,
formStore.getState,
formStore.getState
)
const addHero = useCallback(async () => {
const fileEl = fileInputRef.current
if (fileEl && fileEl.files && fileEl.files.length > 0) {
const file = fileEl.files[0]
const buf = new Uint8Array(await file.arrayBuffer())
if (am) {
await am.add({ name: file.name, type: file.type, bytes: buf })
}
setSelectedFileName(file.name)
}
// 파일이 없어도 Hero 섹션은 추가(이미지는 placeholder 사용)
const hero: Section = { type: 'hero', props: { heading: 'Welcome', subheading: '' } }
setSections((prev) => [...prev, hero])
}, [am])
const addFaq = useCallback(() => {
const faq: Section = { type: 'faq', props: { items: [] } }
setSections((prev) => [...prev, faq])
}, [])
const addCta = useCallback(() => {
const cta: Section = { type: 'cta', props: { text: 'Get Started', href: '#' } }
setSections((prev) => [...prev, cta])
}, [])
const addFeatures = useCallback(() => {
const features: Section = { type: 'features', props: { items: ['Feature A', 'Feature B', 'Feature C'] } }
setSections((prev) => [...prev, features])
}, [])
const addTestimonials = useCallback(() => {
const testimonials: Section = {
type: 'testimonials',
props: { items: [{ author: 'Jane', quote: 'Great product!' }, { author: 'John', quote: 'Loved it.' }] },
}
setSections((prev) => [...prev, testimonials])
}, [])
const removeAt = useCallback((idx: number) => {
setSections((prev) => prev.filter((_, i) => i !== idx))
}, [])
const moveSection = useCallback((from: number, to: number) => {
setSections((prev) => {
if (from < 0 || to < 0 || from >= prev.length || to >= prev.length) return prev
const next = prev.slice()
const [sp] = next.splice(from, 1)
next.splice(to, 0, sp)
return next
})
}, [])
const pageData = useMemo<Page>(() => {
const firstName = selectedFileName
const mappedSections = sections.map((s: Section) => {
if (s.type === 'hero') {
return {
type: 'hero' as const,
props: {
heading: s.props?.heading || 'Welcome',
subheading: s.props?.subheading || '',
imageUrl: firstName ? `local:${firstName}` : 'https://via.placeholder.com/800x400',
},
}
}
return s
})
return ({
title,
locale,
theme: { primaryColor, fontFamily },
sections: (mappedSections as unknown) as Page['sections'],
form: formState,
analytics: { ga4MeasurementId, metaPixelId, customHeadHtml: '' },
seo: { title: seoTitle || title, description: (seoDescription && seoDescription.trim()) ? seoDescription : 'Landing page', ogImage: (ogImage && ogImage.trim()) ? ogImage : undefined },
} as unknown) as Page
}, [title, locale, primaryColor, fontFamily, sections, formState, selectedFileName, seoTitle, seoDescription, ga4MeasurementId, metaPixelId, ogImage])
// test-only: expose preview exporter for E2E to read exported HTML/CSS/JS directly
useEffect(() => {
try {
if (typeof window !== 'undefined') {
const w = window as unknown as { __exportPreview?: () => { html: string; css: string; js: string } }
// Build an effective AssetManager based on selected path strategy
const buildEffectiveAm = (): AssetManager | undefined => {
const base = am ?? undefined
if (!base) return undefined
if (assetPathStrategy === 'flat') return base
const clone = new AssetManager({ maxBytes: 10 * 1024 * 1024, allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'], pathStrategy: assetPathStrategy })
for (const ref of base.list()) {
// re-add to apply new path strategy and recreate byOriginal mapping
clone.add({ name: ref.originalName, type: ref.mimeType, bytes: ref.bytes })
}
return clone
}
w.__exportPreview = () => {
try {
// Export preview with current optimization options
return exportPage(pageSchema.parse(pageData), { assetManager: buildEffectiveAm(), fontPreconnect, fontPreload, imageLoading, imageDecoding, robotsMeta: robotsMeta.trim() || undefined })
} catch {
const fixed: Page = {
...pageData,
form: {
actionUrl: pageData.form?.actionUrl ?? '',
fields: pageData.form?.fields ?? [],
spamProtection: pageData.form?.spamProtection ?? { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
},
}
return exportPage(pageSchema.parse(fixed), { assetManager: buildEffectiveAm(), fontPreconnect, fontPreload, imageLoading, imageDecoding, robotsMeta: robotsMeta.trim() || undefined })
}
}
}
} catch {}
}, [pageData, am, fontPreconnect, fontPreload, imageLoading, imageDecoding, robotsMeta, assetPathStrategy])
const doExport = useCallback(async () => {
let valid = pageData as Page
try {
valid = pageSchema.parse(pageData)
} catch {
const fixed: Page = {
...pageData,
form: {
actionUrl: pageData.form?.actionUrl ?? '',
fields: pageData.form?.fields ?? [],
spamProtection: pageData.form?.spamProtection ?? { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
},
} as Page
valid = pageSchema.parse(fixed)
}
// Build an effective AssetManager based on selected path strategy
const buildEffectiveAm = (): AssetManager | undefined => {
const base = am ?? undefined
if (!base) return undefined
if (assetPathStrategy === 'flat') return base
const clone = new AssetManager({ maxBytes: 10 * 1024 * 1024, allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'], pathStrategy: assetPathStrategy })
for (const ref of base.list()) {
clone.add({ name: ref.originalName, type: ref.mimeType, bytes: ref.bytes })
}
return clone
}
// Respect font, image, robots meta optimization toggles when exporting
const exported = exportPage(valid, { assetManager: buildEffectiveAm(), fontPreconnect, fontPreload, imageLoading, imageDecoding, robotsMeta: robotsMeta.trim() || undefined })
try {
if (typeof window !== 'undefined') {
const w = window as unknown as { __captureExport?: boolean; __lastExport?: { html: string; css: string; js: string } }
if (w.__captureExport) {
w.__lastExport = exported
}
}
} catch {}
const effectiveAm = (() => {
const base = am ?? undefined
if (!base) return undefined
if (assetPathStrategy === 'flat') return base
const clone = new AssetManager({ maxBytes: 10 * 1024 * 1024, allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'], pathStrategy: assetPathStrategy })
for (const ref of base.list()) {
clone.add({ name: ref.originalName, type: ref.mimeType, bytes: ref.bytes })
}
return clone
})()
const assets = effectiveAm ? effectiveAm.toZipStructure() : {}
// Build overrides from UI options
const manifestOverrideBase: Record<string, string> = {}
if (manifestName && manifestName.trim().length > 0) manifestOverrideBase.name = manifestName.trim()
if (manifestShortName && manifestShortName.trim().length > 0) manifestOverrideBase.short_name = manifestShortName.trim()
if (manifestStartUrl && manifestStartUrl.trim().length > 0) manifestOverrideBase.start_url = manifestStartUrl.trim()
if (manifestDisplay && manifestDisplay.trim().length > 0) manifestOverrideBase.display = manifestDisplay.trim()
if (manifestThemeColor && manifestThemeColor.trim().length > 0) {
const v = manifestThemeColor.trim()
const hexOk = /^#([0-9a-fA-F]{3}){1,2}$/.test(v)
if (hexOk) {
manifestOverrideBase.theme_color = v
}
}
const manifestOverride = Object.keys(manifestOverrideBase).length > 0 ? (manifestOverrideBase as { name?: string; short_name?: string; start_url?: string; display?: string; theme_color?: string }) : undefined
const robotsTextOverride = (() => {
const lines = (robotsDisallow || '').split(/\n+/).map((s) => s.trim()).filter((s) => s.length > 0)
if (lines.length === 0) return undefined
const disallowLines = lines.map((l) => (l.startsWith('Disallow:') ? l : `Disallow: ${l}`))
return `User-agent: *\n${disallowLines.join('\n')}`
})()
const baseExtras = buildExtrasForPage(valid, { robotsText: robotsTextOverride, manifest: manifestOverride, sheetsMode })
const extras: Record<string, Uint8Array> = {
...baseExtras,
...(am ? {
'assets.json': buildAssetsMetadata(am),
'assets.index.json': buildAssetsIndex(am),
'assets.integrity.index.json': buildAssetsIntegrityIndex(am),
} : {}),
}
const zipBytes = await buildZip({ html: exported.html, css: exported.css, js: exported.js, assets, extras })
download('landing.zip', zipBytes)
}, [pageData, am, manifestName, manifestShortName, manifestStartUrl, manifestDisplay, manifestThemeColor, robotsDisallow, sheetsMode, fontPreconnect, fontPreload, imageLoading, imageDecoding, robotsMeta, assetPathStrategy])
return (
<div className="p-6 space-y-6">
<h1 className="text-2xl font-semibold">{t('builder.title')}</h1>
<BuilderShell
left={
<div className="space-y-4">
<SidebarComponents
onAddHero={addHero}
onAddFAQ={addFaq}
onAddCTA={addCta}
onAddFeatures={addFeatures}
onAddTestimonials={addTestimonials}
/>
<FormBuilderPanel store={formStore} />
</div>
}
center={
<div className="space-y-4">
<div className="space-y-2">
<label className="block text-sm font-medium" htmlFor="builder-title">{t('builder.field.title')}</label>
<input id="builder-title" className="border rounded px-3 py-2 w-full" value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
<div className="flex gap-4">
<div className="flex-1">
<label className="block text-sm font-medium" htmlFor="builder-locale">{t('builder.field.locale')}</label>
<select id="builder-locale" className="border rounded px-3 py-2 w-full" value={locale} onChange={(e) => setLocale(e.target.value as 'en' | 'ko')}>
<option value="en">en</option>
<option value="ko">ko</option>
</select>
</div>
<div className="flex-1">
<label className="block text-sm font-medium" htmlFor="builder-primary-color">{t('builder.field.primaryColor')}</label>
<input id="builder-primary-color" type="color" className="border rounded px-2 py-2 w-full h-10" value={primaryColor} onChange={(e) => setPrimaryColor(e.target.value)} />
</div>
<div className="flex-1">
<label className="block text-sm font-medium" htmlFor="builder-font-family">{t('builder.field.fontFamily')}</label>
<input id="builder-font-family" className="border rounded px-3 py-2 w-full" value={fontFamily} onChange={(e) => setFontFamily(e.target.value)} />
</div>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium" htmlFor="builder-hero-file">{t('builder.upload.hero')}</label>
<input id="builder-hero-file" ref={fileInputRef} type="file" accept="image/*" className="block" />
</div>
<div className="flex gap-2">
<button data-testid="btn-add-hero" className="bg-sky-600 text-white rounded px-4 py-2" onClick={addHero}>{t('builder.action.addHero')}</button>
<button data-testid="btn-add-faq" className="bg-gray-200 rounded px-4 py-2" onClick={addFaq}>{t('builder.action.addFAQ')}</button>
<button data-testid="btn-add-cta" className="bg-gray-200 rounded px-4 py-2" onClick={addCta}>{t('builder.action.addCTA')}</button>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium">{t('builder.sections')}</label>
<SectionCanvas
sections={sections}
onMove={moveSection}
onRemove={removeAt}
onSelect={(i) => setSelectedIndex(i)}
/>
</div>
<div className="border rounded p-3 space-y-2">
<h2 className="text-sm font-semibold mb-1">Export Options</h2>
{/* Asset path strategy: flat vs grouped */}
<label className="block" htmlFor="ins-asset-path-strategy">
<span className="sr-only">Asset Path Strategy</span>
<select
id="ins-asset-path-strategy"
aria-label="Asset Path Strategy"
className="border rounded px-3 py-2 w-full"
value={assetPathStrategy}
onChange={(e) => setAssetPathStrategy(e.target.value as 'flat' | 'grouped')}
>
<option value="flat">Assets Path: Flat</option>
<option value="grouped">Assets Path: Grouped (images/fonts/other)</option>
</select>
</label>
{/* Font optimization toggle for Google Fonts preconnect */}
<label className="flex items-center gap-2" htmlFor="ins-font-preconnect">
<input
id="ins-font-preconnect"
type="checkbox"
aria-label="Font Preconnect"
checked={fontPreconnect}
onChange={(e) => setFontPreconnect(e.target.checked)}
/>
<span className="text-sm">Enable font preconnect (Google Fonts)</span>
</label>
<label className="flex items-center gap-2" htmlFor="ins-font-preload">
<input
id="ins-font-preload"
type="checkbox"
aria-label="Font Preload"
checked={fontPreload}
onChange={(e) => setFontPreload(e.target.checked)}
/>
<span className="text-sm">Enable font preload (Google Fonts)</span>
</label>
<div className="grid grid-cols-2 gap-2">
<label className="block" htmlFor="ins-image-loading">
<span className="sr-only">Image Loading</span>
<select
id="ins-image-loading"
aria-label="Image Loading"
className="border rounded px-3 py-2 w-full"
value={imageLoading}
onChange={(e) => setImageLoading(e.target.value as 'lazy' | 'eager')}
>
<option value="lazy">Image Loading: lazy</option>
<option value="eager">Image Loading: eager</option>
</select>
</label>
<label className="block" htmlFor="ins-image-decoding">
<span className="sr-only">Image Decoding</span>
<select
id="ins-image-decoding"
aria-label="Image Decoding"
className="border rounded px-3 py-2 w-full"
value={imageDecoding}
onChange={(e) => setImageDecoding(e.target.value as 'async' | 'sync')}
>
<option value="async">Image Decoding: async</option>
<option value="sync">Image Decoding: sync</option>
</select>
</label>
</div>
<label className="block" htmlFor="ins-manifest-name">
<span className="sr-only">Manifest Name</span>
<input
id="ins-manifest-name"
aria-label="Manifest Name"
className="border rounded px-3 py-2 w-full"
value={manifestName}
onChange={(e) => setManifestName(e.target.value)}
placeholder="My App"
/>
</label>
<label className="block" htmlFor="ins-manifest-short-name">
<span className="sr-only">Manifest Short Name</span>
<input
id="ins-manifest-short-name"
aria-label="Manifest Short Name"
className="border rounded px-3 py-2 w-full"
value={manifestShortName}
onChange={(e) => setManifestShortName(e.target.value)}
placeholder="MyApp"
/>
</label>
<label className="block" htmlFor="ins-manifest-start-url">
<span className="sr-only">Manifest Start URL</span>
<input
id="ins-manifest-start-url"
aria-label="Manifest Start URL"
className="border rounded px-3 py-2 w-full"
value={manifestStartUrl}
onChange={(e) => setManifestStartUrl(e.target.value)}
placeholder="/"
/>
</label>
<label className="block" htmlFor="ins-manifest-display">
<span className="sr-only">Manifest Display</span>
<select
id="ins-manifest-display"
aria-label="Manifest Display"
className="border rounded px-3 py-2 w-full"
value={manifestDisplay}
onChange={(e) => setManifestDisplay(e.target.value)}
>
<option value="">(default)</option>
<option value="standalone">standalone</option>
<option value="minimal-ui">minimal-ui</option>
<option value="browser">browser</option>
</select>
</label>
<label className="block" htmlFor="ins-manifest-theme-color">
<span className="sr-only">Manifest Theme Color</span>
<input
id="ins-manifest-theme-color"
aria-label="Manifest Theme Color"
className="border rounded px-3 py-2 w-full"
value={manifestThemeColor}
onChange={(e) => setManifestThemeColor(e.target.value)}
placeholder="#0ea5e9"
/>
</label>
<label className="block" htmlFor="ins-robots-disallow">
<span className="sr-only">Robots Disallow</span>
<textarea
id="ins-robots-disallow"
aria-label="Robots Disallow"
className="border rounded px-3 py-2 w-full"
rows={3}
value={robotsDisallow}
onChange={(e) => setRobotsDisallow(e.target.value)}
placeholder="/private\n/tmp"
/>
</label>
<label className="block" htmlFor="ins-robots-meta">
<span className="sr-only">Robots Meta</span>
<input
id="ins-robots-meta"
aria-label="Robots Meta"
className="border rounded px-3 py-2 w-full"
value={robotsMeta}
onChange={(e) => setRobotsMeta(e.target.value)}
placeholder="noindex, nofollow"
/>
</label>
<label className="block" htmlFor="ins-sheets-mode">
<span className="sr-only">Google Sheets Template Mode</span>
<select
id="ins-sheets-mode"
aria-label="Google Sheets Template Mode"
className="border rounded px-3 py-2 w-full"
value={sheetsMode}
onChange={(e) => setSheetsMode(e.target.value as 'auto'|'include'|'exclude')}
>
<option value="auto">Sheets Template: Auto</option>
<option value="include">Sheets Template: Include</option>
<option value="exclude">Sheets Template: Exclude</option>
</select>
</label>
</div>
</div>
}
right={
<div className="space-y-3">
<button className="bg-emerald-600 text-white rounded px-4 py-2 w-full" onClick={doExport}>{t('builder.action.export')}</button>
<div className="border rounded p-3 space-y-2">
<h2 className="text-sm font-semibold mb-1">SEO / Analytics</h2>
<label className="block" htmlFor="ins-seo-title">
<span className="sr-only">SEO Title</span>
<input
id="ins-seo-title"
aria-label="SEO Title"
aria-describedby="ins-seo-title-help"
className="border rounded px-3 py-2 w-full"
placeholder="e.g. Product landing page"
value={seoTitle}
onChange={(e) => setSeoTitle(e.target.value)}
/>
<p id="ins-seo-title-help" className="mt-1 text-xs text-gray-500">Shown in browser title and social previews</p>
</label>
<label className="block" htmlFor="ins-seo-desc">
<span className="sr-only">SEO Description</span>
<input
id="ins-seo-desc"
aria-label="SEO Description"
aria-describedby="ins-seo-desc-help"
className="border rounded px-3 py-2 w-full"
placeholder="Short description shown in search/OG"
value={seoDescription}
onChange={(e) => setSeoDescription(e.target.value)}
/>
<p id="ins-seo-desc-help" className="mt-1 text-xs text-gray-500">Used by search engines and OG description</p>
</label>
<label className="block" htmlFor="ins-og-image">
<span className="sr-only">OG Image URL</span>
<input
id="ins-og-image"
aria-label="OG Image URL"
aria-describedby="ins-og-image-help"
className="border rounded px-3 py-2 w-full"
placeholder="https://.../og-image.png"
value={ogImage}
onChange={(e) => setOgImage(e.target.value)}
/>
<p id="ins-og-image-help" className="mt-1 text-xs text-gray-500">Absolute URL recommended</p>
</label>
<label className="block" htmlFor="ins-ga4">
<span className="sr-only">GA4 Measurement ID</span>
<input
id="ins-ga4"
aria-label="GA4 Measurement ID"
aria-describedby="ins-ga4-help"
className="border rounded px-3 py-2 w-full"
placeholder="G-XXXXXXXXXX"
value={ga4MeasurementId}
onChange={(e) => setGa4MeasurementId(e.target.value)}
/>
<p id="ins-ga4-help" className="mt-1 text-xs text-gray-500">Format: G-XXXXXXXXXX</p>
</label>
<label className="block" htmlFor="ins-meta-pixel">
<span className="sr-only">Meta Pixel ID</span>
<input
id="ins-meta-pixel"
aria-label="Meta Pixel ID"
aria-describedby="ins-meta-pixel-help"
className="border rounded px-3 py-2 w-full"
placeholder="1234567890"
value={metaPixelId}
onChange={(e) => setMetaPixelId(e.target.value)}
/>
<p id="ins-meta-pixel-help" className="mt-1 text-xs text-gray-500">Numeric Page ID</p>
</label>
</div>
<div className="border rounded p-3">
<h2 className="text-sm font-semibold mb-2">Inspector</h2>
{selectedIndex !== null && sections[selectedIndex] ? (
(() => {
const s = sections[selectedIndex]
if (s.type === 'hero') {
const heading = s.props?.heading ?? 'Welcome'
return (
<div className="space-y-2">
<label className="block" htmlFor="ins-hero-heading">
<span className="sr-only">Hero Heading</span>
<input
id="ins-hero-heading"
aria-label="Hero Heading"
aria-describedby="ins-hero-heading-help"
className="border rounded px-3 py-2 w-full"
value={heading}
onChange={(e) => {
const v = e.target.value
setSections((prev) => prev.map((it, idx) => {
if (idx !== selectedIndex) return it
return { ...it, props: { ...(it.props || {}), heading: v } } as Section
}))
}}
/>
</label>
<p id="ins-hero-heading-help" className="mt-1 text-xs text-gray-500">Main heading displayed prominently</p>
</div>
)
}
if (s.type === 'cta') {
const text = s.props?.text ?? 'Get Started'
const href = s.props?.href ?? '#'
return (
<div className="space-y-2">
<label className="block" htmlFor="ins-cta-text">
<span className="sr-only">CTA Text</span>
<input
id="ins-cta-text"
aria-label="CTA Text"
aria-describedby="ins-cta-text-help"
className="border rounded px-3 py-2 w-full"
value={text}
onChange={(e) => {
const v = e.target.value
setSections((prev) => prev.map((it, idx) => {
if (idx !== selectedIndex) return it
return { ...it, props: { ...(it.props || {}), text: v } } as Section
}))
}}
/>
</label>
<p id="ins-cta-text-help" className="mt-1 text-xs text-gray-500">Button label and link for primary action</p>
<label className="block" htmlFor="ins-cta-href">
<span className="sr-only">CTA Href</span>
<input
id="ins-cta-href"
aria-label="CTA Href"
aria-describedby="ins-cta-href-help"
className="border rounded px-3 py-2 w-full"
value={href}
onChange={(e) => {
const v = e.target.value
setSections((prev) => prev.map((it, idx) => {
if (idx !== selectedIndex) return it
return { ...it, props: { ...(it.props || {}), href: v } } as Section
}))
}}
/>
</label>
<p id="ins-cta-href-help" className="mt-1 text-xs text-gray-500">Button label and link for primary action</p>
</div>
)
}
if (s.type === 'faq') {
const items: Array<{ q: string; a: string }> = Array.isArray(s.props?.items) ? (s.props.items as Array<{ q: string; a: string }>) : []
const addItem = () => {
const nextItems = [...items, { q: '', a: '' }]
setSections((prev) => prev.map((it, idx) => {
if (idx !== selectedIndex) return it
return { ...it, props: { ...(it.props || {}), items: nextItems } } as Section
}))
}
const updateItem = (i: number, patch: { q?: string; a?: string }) => {
const nextItems = items.map((it: { q: string; a: string }, idx: number) => (idx === i ? { ...it, ...patch } : it))
setSections((prev) => prev.map((it, idx) => {
if (idx !== selectedIndex) return it
return { ...it, props: { ...(it.props || {}), items: nextItems } } as Section
}))
}
const removeItem = (i: number) => {
const nextItems = items.filter((_: { q: string; a: string }, idx: number) => idx !== i)
setSections((prev) => prev.map((it, idx) => {
if (idx !== selectedIndex) return it
return { ...it, props: { ...(it.props || {}), items: nextItems } } as Section
}))
}
return (
<div className="space-y-2">
<button type="button" className="px-2 py-1 border rounded text-sm" title="Add FAQ Item" onClick={addItem}>Add FAQ Item</button>
<p id="ins-faq-help" className="text-xs text-gray-500">Add at least one FAQ item with Q and A</p>
<ul className="space-y-2">
{items.map((it: { q: string; a: string }, i: number) => (
<li key={i} className="flex items-start gap-2">
<div className="flex-1 grid grid-cols-2 gap-2">
<label className="block" htmlFor={`ins-faq-q-${i}`}>
<span className="sr-only">FAQ Q {i}</span>
<input
id={`ins-faq-q-${i}`}
aria-label={`FAQ Q ${i}`}
aria-describedby="ins-faq-help"
className="border rounded px-2 py-1 w-full"
value={it.q ?? ''}
onChange={(e) => updateItem(i, { q: e.target.value })}
/>
</label>
<label className="block" htmlFor={`ins-faq-a-${i}`}>
<span className="sr-only">FAQ A {i}</span>
<input
id={`ins-faq-a-${i}`}
aria-label={`FAQ A ${i}`}
aria-describedby="ins-faq-help"
className="border rounded px-2 py-1 w-full"
value={it.a ?? ''}
onChange={(e) => updateItem(i, { a: e.target.value })}
/>
</label>
</div>
<button type="button" aria-label={`Remove FAQ ${i}`} className="px-2 py-1 border rounded text-sm text-red-600" onClick={() => removeItem(i)}>Remove</button>
</li>
))}
</ul>
</div>
)
}
if (s.type === 'features') {
const items: string[] = Array.isArray(s.props?.items) ? (s.props.items as string[]) : []
const addItem = () => {
const nextItems = [...items, '']
setSections((prev) => prev.map((it, idx) => (idx === selectedIndex ? ({ ...it, props: { ...(it.props || {}), items: nextItems } } as Section) : it)))
}
const updateItem = (i: number, v: string) => {
const nextItems = items.map((it, idx) => (idx === i ? v : it))
setSections((prev) => prev.map((it, idx) => (idx === selectedIndex ? ({ ...it, props: { ...(it.props || {}), items: nextItems } } as Section) : it)))
}
const removeItem = (i: number) => {
const nextItems = items.filter((_, idx) => idx !== i)
setSections((prev) => prev.map((it, idx) => (idx === selectedIndex ? ({ ...it, props: { ...(it.props || {}), items: nextItems } } as Section) : it)))
}
return (
<div className="space-y-2">
<button type="button" className="px-2 py-1 border rounded text-sm" title="Add Feature Item" onClick={addItem}>Add Feature Item</button>
<p id="ins-features-help" className="text-xs text-gray-500">Describe a single feature per line</p>
<ul className="space-y-2">
{items.map((val, i) => (
<li key={i} className="flex items-center gap-2">
<label className="block flex-1" htmlFor={`ins-feature-${i}`}>
<span className="sr-only">Feature {i}</span>
<input
id={`ins-feature-${i}`}
aria-label={`Feature ${i}`}
aria-describedby="ins-features-help"
className="border rounded px-2 py-1 w-full"
value={val}
onChange={(e) => updateItem(i, e.target.value)}
/>
</label>
<button
type="button"
aria-label={`Remove Feature ${i}`}
title={`Remove Feature ${i}`}
className="px-2 py-1 border rounded text-sm text-red-600"
onClick={() => removeItem(i)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') removeItem(i)
}}
onKeyUp={(e) => {
if (e.key === 'Enter' || e.key === ' ') removeItem(i)
}}
>
Remove
</button>
</li>
))}
</ul>
</div>
)
}
if (s.type === 'testimonials') {
const items: Array<{ author: string; quote: string }> = Array.isArray(s.props?.items)
? (s.props.items as Array<{ author: string; quote: string }>)
: []
const addItem = () => {
const nextItems = [...items, { author: '', quote: '' }]
setSections((prev) => prev.map((it, idx) => (idx === selectedIndex ? ({ ...it, props: { ...(it.props || {}), items: nextItems } } as Section) : it)))
}
const updateItem = (i: number, patch: Partial<{ author: string; quote: string }>) => {
const nextItems = items.map((it, idx) => (idx === i ? { ...it, ...patch } : it))
setSections((prev) => prev.map((it, idx) => (idx === selectedIndex ? ({ ...it, props: { ...(it.props || {}), items: nextItems } } as Section) : it)))
}
const removeItem = (i: number) => {
const nextItems = items.filter((_, idx) => idx !== i)
setSections((prev) => prev.map((it, idx) => (idx === selectedIndex ? ({ ...it, props: { ...(it.props || {}), items: nextItems } } as Section) : it)))
}
return (
<div className="space-y-2">
<button type="button" className="px-2 py-1 border rounded text-sm" title="Add Testimonial" onClick={addItem}>Add Testimonial</button>
<p id="ins-testimonials-help" className="text-xs text-gray-500">Add author and quote for each testimonial</p>
<ul className="space-y-2">
{items.map((it, i) => (
<li key={i} className="grid grid-cols-2 gap-2 items-start">
<label className="block" htmlFor={`ins-author-${i}`}>
<span className="sr-only">Author {i}</span>
<input
id={`ins-author-${i}`}
aria-label={`Author ${i}`}
aria-describedby="ins-testimonials-help"
className="border rounded px-2 py-1 w-full"
value={it.author}
onChange={(e) => updateItem(i, { author: e.target.value })}
/>
</label>
<label className="block" htmlFor={`ins-quote-${i}`}>
<span className="sr-only">Quote {i}</span>
<input
id={`ins-quote-${i}`}
aria-label={`Quote ${i}`}
aria-describedby="ins-testimonials-help"
className="border rounded px-2 py-1 w-full"
value={it.quote}
onChange={(e) => updateItem(i, { quote: e.target.value })}
/>
</label>
<div className="col-span-2">
<button
type="button"
aria-label={`Remove Testimonial ${i}`}
title={`Remove Testimonial ${i}`}
className="px-2 py-1 border rounded text-sm text-red-600"
onClick={() => removeItem(i)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') removeItem(i)
}}
onKeyUp={(e) => {
if (e.key === 'Enter' || e.key === ' ') removeItem(i)
}}
>
Remove
</button>
</div>
</li>
))}
</ul>
</div>
)
}
return <div className="text-xs text-gray-500">No editable fields</div>
})()
) : (
<div className="text-xs text-gray-500">Nothing selected</div>
)}
</div>
<div className="border rounded p-3">
<h2 className="text-sm font-semibold mb-2">{t('builder.preview')}</h2>
<p id="preview-toggle-help" className="sr-only">Toggle preview viewport for the builder preview area</p>
<div className="mb-2 flex gap-2">
<button
type="button"
aria-label="Desktop"
aria-pressed={viewport === 'desktop'}
aria-controls="preview-container"
aria-describedby="preview-toggle-help"
className={`rounded px-2 py-1 text-sm transition-colors border focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600 ${
viewport === 'desktop'
? 'bg-sky-600 text-white border-sky-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
onClick={() => setViewport('desktop')}
>
Desktop
</button>
<button
type="button"
aria-label="Mobile"
aria-pressed={viewport === 'mobile'}
aria-controls="preview-container"
aria-describedby="preview-toggle-help"
className={`rounded px-2 py-1 text-sm transition-colors border focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600 ${
viewport === 'mobile'
? 'bg-sky-600 text-white border-sky-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
onClick={() => setViewport('mobile')}
>
Mobile
</button>
</div>
<div className="prose max-w-none">
<div
data-testid="preview-container"
className={`border rounded p-3 ${viewport === 'desktop' ? 'w-[768px]' : 'w-[375px]'} mx-auto`}
>
<div className="mb-2"><strong>Title:</strong> {pageData.title}</div>
<div className="mb-2"><strong>Locale:</strong> {pageData.locale}</div>
<div className="mb-2"><strong>SEO Title:</strong> <span data-testid="preview-seo-title">{pageData.seo.title}</span></div>
<div className="mb-2"><strong>SEO Description:</strong> <span data-testid="preview-seo-description">{pageData.seo.description}</span></div>
<div className="mb-2"><strong>OG Image:</strong> <span data-testid="preview-og-image">{pageData.seo.ogImage || ''}</span></div>
<div className="mb-2"><strong>GA4:</strong> <span data-testid="preview-ga4">{pageData.analytics?.ga4MeasurementId || ''}</span></div>
<div className="mb-2"><strong>Meta Pixel:</strong> <span data-testid="preview-meta-pixel">{pageData.analytics?.metaPixelId || ''}</span></div>
<div className="space-y-2">
{sections.map((s: Section, i: number) => {
switch (s.type) {
case 'hero':
return (
<div key={i} className="text-sm text-gray-700"> hero heading: {s.props?.heading || 'Welcome'}</div>
)
case 'faq': {
const count = s.props.items?.length || 0
return (
<div key={i} className="text-sm text-gray-700"> faq items: {count}</div>
)
}
case 'cta': {
const text = s.props.text || 'CTA'
const href = s.props.href || '#'
return (
<div key={i} className="text-sm text-gray-700"> cta {text} {href}</div>
)
}
case 'features': {
const count = s.props.items?.length || 0
return (
<div key={i} className="text-sm text-gray-700"> features items: {count}</div>
)
}
case 'testimonials': {
const count = s.props.items?.length || 0
return (
<div key={i} className="text-sm text-gray-700"> testimonials items: {count}</div>
)
}
default:
return null
}
})}
</div>
<ul className="list-disc pl-5 mt-2">
{formState.fields.map((f, idx) => (
<li key={`${f.name}-${idx}`} className="text-sm">
{f.label} ({f.name}) {f.required ? 'required' : ''}
</li>
))}
</ul>
</div>
</div>
</div>
</div>
}
/>
</div>
)
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import React from 'react'
import BuilderShell from '@/components/BuilderShell'
describe('BuilderShell', () => {
it('좌/중/우 3패널과 접근성 라벨을 렌더링한다', () => {
render(
<BuilderShell left={<div>LEFT</div>} center={<div>CENTER</div>} right={<div>RIGHT</div>} />
)
expect(screen.getByTestId('builder-shell')).toBeInTheDocument()
expect(screen.getByLabelText('Components Panel')).toBeInTheDocument()
expect(screen.getByLabelText('Canvas')).toBeInTheDocument()
expect(screen.getByLabelText('Inspector')).toBeInTheDocument()
})
})
+27
View File
@@ -0,0 +1,27 @@
"use client"
import React from 'react'
export default function BuilderShell({
left,
center,
right,
}: {
left: React.ReactNode
center: React.ReactNode
right: React.ReactNode
}) {
return (
<div className="h-full w-full grid grid-cols-1 md:grid-cols-[280px_1fr_320px] gap-4 p-4" data-testid="builder-shell">
<aside className="border rounded p-3 min-h-[200px]" aria-label="Components Panel" data-testid="panel-left">
{left}
</aside>
<main className="border rounded p-3 min-h-[200px]" aria-label="Canvas" data-testid="panel-center">
{center}
</main>
<aside className="border rounded p-3 min-h-[200px]" aria-label="Inspector" data-testid="panel-right">
{right}
</aside>
</div>
)
}
@@ -0,0 +1,67 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import BuilderClientPage from '@/components/BuilderClientPage'
vi.mock('next-intl', () => {
return {
useTranslations: () => (key: string) => key,
}
})
const buildZipSpy = vi.fn(async (_arg: {
html: string
css: string
js: string
assets: Record<string, Uint8Array>
extras?: Record<string, Uint8Array>
}) => new Uint8Array([80, 75]))
vi.mock('@/lib/exporter/zip', () => {
return {
buildZip: (arg: {
html: string
css: string
js: string
assets: Record<string, Uint8Array>
extras?: Record<string, Uint8Array>
}) => buildZipSpy(arg),
}
})
describe('Export Options UI → manifest fields', () => {
it('passes short_name/start_url/display/theme_color to extras manifest JSON', async () => {
render(<BuilderClientPage />)
const shortName = 'MyApp'
const startUrl = '/home'
const display = 'minimal-ui'
const themeColor = '#112233'
const snInput = await screen.findByLabelText('Manifest Short Name')
fireEvent.change(snInput, { target: { value: shortName } })
const startInput = await screen.findByLabelText('Manifest Start URL')
fireEvent.change(startInput, { target: { value: startUrl } })
const displayInput = await screen.findByLabelText('Manifest Display')
fireEvent.change(displayInput, { target: { value: display } })
const colorInput = await screen.findByLabelText('Manifest Theme Color')
fireEvent.change(colorInput, { target: { value: themeColor } })
const exportBtn = await screen.findByRole('button', { name: 'builder.action.export' })
fireEvent.click(exportBtn)
expect(buildZipSpy).toHaveBeenCalled()
const first = buildZipSpy.mock.calls[0]
const extras: Record<string, Uint8Array> = (first![0] as { extras: Record<string, Uint8Array> }).extras
const td = new TextDecoder()
const manifestRaw = td.decode(extras['site.webmanifest'])
const manifest = JSON.parse(manifestRaw)
expect(manifest.short_name).toBe(shortName)
expect(manifest.start_url).toBe(startUrl)
expect(manifest.display).toBe(display)
expect(manifest.theme_color).toBe(themeColor)
})
})
@@ -0,0 +1,64 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import BuilderClientPage from '@/components/BuilderClientPage'
vi.mock('next-intl', () => {
return {
useTranslations: () => (key: string) => key,
}
})
const buildZipSpy = vi.fn(async (_arg: {
html: string
css: string
js: string
assets: Record<string, Uint8Array>
extras?: Record<string, Uint8Array>
}) => new Uint8Array([80, 75]))
vi.mock('@/lib/exporter/zip', () => {
return {
buildZip: (arg: {
html: string
css: string
js: string
assets: Record<string, Uint8Array>
extras?: Record<string, Uint8Array>
}) => buildZipSpy(arg),
}
})
describe('Export Options UI polish', () => {
it('uses a dropdown for Manifest Display and passes selected value', async () => {
render(<BuilderClientPage />)
// choose display = minimal-ui
const displaySelect = await screen.findByLabelText('Manifest Display')
fireEvent.change(displaySelect, { target: { value: 'minimal-ui' } })
const exportBtn = await screen.findByRole('button', { name: 'builder.action.export' })
fireEvent.click(exportBtn)
const first = buildZipSpy.mock.calls[0]
const extras: Record<string, Uint8Array> = (first![0] as { extras: Record<string, Uint8Array> }).extras
const td = new TextDecoder()
const manifest = JSON.parse(td.decode(extras['site.webmanifest']))
expect(manifest.display).toBe('minimal-ui')
})
it('ignores invalid theme_color override and keeps default theme when not a hex', async () => {
render(<BuilderClientPage />)
const colorInput = await screen.findByLabelText('Manifest Theme Color')
fireEvent.change(colorInput, { target: { value: 'blue' } }) // invalid hex
const exportBtn = await screen.findByRole('button', { name: 'builder.action.export' })
fireEvent.click(exportBtn)
const first = buildZipSpy.mock.calls[0]
const extras: Record<string, Uint8Array> = (first![0] as { extras: Record<string, Uint8Array> }).extras
const td = new TextDecoder()
const manifest = JSON.parse(td.decode(extras['site.webmanifest']))
expect(manifest.theme_color).toBe('#0ea5e9') // default from page.theme.primaryColor
})
})
+62
View File
@@ -0,0 +1,62 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import BuilderClientPage from '@/components/BuilderClientPage'
vi.mock('next-intl', () => {
return {
useTranslations: () => (key: string) => key,
}
})
const buildZipSpy = vi.fn(async (_arg: {
html: string
css: string
js: string
assets: Record<string, Uint8Array>
extras?: Record<string, Uint8Array>
}) => new Uint8Array([80, 75]))
vi.mock('@/lib/exporter/zip', () => {
return {
buildZip: (arg: {
html: string
css: string
js: string
assets: Record<string, Uint8Array>
extras?: Record<string, Uint8Array>
}) => buildZipSpy(arg),
}
})
describe('Export Options UI → extras overrides', () => {
it('passes manifest.name and robots Disallow to buildExtrasForPage via buildZip extras', async () => {
render(<BuilderClientPage />)
const manifestName = 'My App Custom'
const robotsDisallow = '/private\n/tmp'
const nameInput = await screen.findByLabelText('Manifest Name')
fireEvent.change(nameInput, { target: { value: manifestName } })
const robotsInput = await screen.findByLabelText('Robots Disallow')
fireEvent.change(robotsInput, { target: { value: robotsDisallow } })
const exportBtn = await screen.findByRole('button', { name: 'builder.action.export' })
fireEvent.click(exportBtn)
expect(buildZipSpy).toHaveBeenCalled()
const first = buildZipSpy.mock.calls[0]
expect(first && first[0]).toBeTruthy()
const extras: Record<string, Uint8Array> = (first![0] as { extras: Record<string, Uint8Array> }).extras
const td = new TextDecoder()
const manifestRaw = td.decode(extras['site.webmanifest'])
const robotsRaw = td.decode(extras['robots.txt'])
const manifest = JSON.parse(manifestRaw)
expect(manifest.name).toBe(manifestName)
expect(robotsRaw).toMatch(/Disallow: \/private/)
expect(robotsRaw).toMatch(/Disallow: \/tmp/)
// integrity index should be included
expect(Object.prototype.hasOwnProperty.call(extras, 'assets.integrity.index.json')).toBe(true)
})
})
+28
View File
@@ -0,0 +1,28 @@
import { render } from '@testing-library/react'
import { NextIntlClientProvider } from 'next-intl'
import { expect, test } from 'vitest'
import { axe, toHaveNoViolations } from 'jest-axe'
import FormBuilderPanel from './FormBuilderPanel'
import { createFormStore } from '@/lib/state/formStore'
expect.extend(toHaveNoViolations)
import messages from '../messages/en.json'
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<NextIntlClientProvider messages={messages} locale="en">
{children}
</NextIntlClientProvider>
)
}
test('FormBuilderPanel has no obvious a11y violations', async () => {
const store = createFormStore()
const { container } = render(
<Wrapper>
<FormBuilderPanel store={store} />
</Wrapper>
)
const results = await axe(container)
expect(results).toHaveNoViolations()
})
+138
View File
@@ -0,0 +1,138 @@
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import React from 'react'
import FormBuilderPanel from '@/components/FormBuilderPanel'
import { createFormStore } from '@/lib/state/formStore'
import type { SelectField, RadioField } from '@/lib/state/formStore'
import { NextIntlClientProvider } from 'next-intl'
import enMessages from '@/messages/en.json'
const messages: Record<string, string> = enMessages as Record<string, string>
describe('FormBuilderPanel', () => {
it('필드 추가와 action URL/스팸 옵션 변경을 수행한다', () => {
const store = createFormStore()
render(
<NextIntlClientProvider locale="en" messages={messages}>
<FormBuilderPanel store={store} />
</NextIntlClientProvider>
)
// 초기 상태
expect(screen.queryAllByTestId('field-item').length).toBe(0)
// 필드 추가 2개 (번역 키 이슈 회피: 첫 번째 버튼 클릭)
fireEvent.click(screen.getAllByRole('button')[0])
fireEvent.click(screen.getAllByRole('button')[0])
expect(screen.queryAllByTestId('field-item').length).toBe(2)
// action URL 입력
const actionInput = screen.getByLabelText('Action URL') as HTMLInputElement
fireEvent.change(actionInput, { target: { value: 'https://example.com/form' } })
expect(actionInput.value).toBe('https://example.com/form')
expect(store.getState().actionUrl).toBe('https://example.com/form')
// 스팸 옵션 - honeypot 변경
const hpInput = screen.getByLabelText('Honeypot Field') as HTMLInputElement
fireEvent.change(hpInput, { target: { value: '_trap' } })
expect(store.getState().spam.honeypotFieldName).toBe('_trap')
// 스팸 옵션 - min seconds 변경
const tsInput = screen.getByLabelText('Min Submit Seconds') as HTMLInputElement
fireEvent.change(tsInput, { target: { value: '5' } })
expect(store.getState().spam.minSubmitSeconds).toBe(5)
})
it('pattern과 maxLength 편집이 상태에 반영된다', () => {
const store = createFormStore()
render(
<NextIntlClientProvider locale="en" messages={messages}>
<FormBuilderPanel store={store} />
</NextIntlClientProvider>
)
// 텍스트 필드 1개 추가
fireEvent.click(screen.getAllByRole('button')[0])
const pat0 = screen.getByLabelText('Pattern 0') as HTMLInputElement
fireEvent.change(pat0, { target: { value: '^[A-Za-z ]+$' } })
expect(store.getState().fields[0].pattern).toBe('^[A-Za-z ]+$')
const max0 = screen.getByLabelText('MaxLength 0') as HTMLInputElement
fireEvent.change(max0, { target: { value: '50' } })
expect(store.getState().fields[0].maxLength).toBe(50)
})
it('select/radio 옵션 편집이 동작하고 추가 버튼으로 타입별 필드를 만든다', () => {
const store = createFormStore()
render(
<NextIntlClientProvider locale="en" messages={messages}>
<FormBuilderPanel store={store} />
</NextIntlClientProvider>
)
// select 추가 후 옵션 편집 (버튼 인덱스로 접근)
const buttons1 = screen.getAllByRole('button')
// 버튼 순서: Text, Email, Tel, Date, Textarea, Checkbox, Select, Radio
fireEvent.click(buttons1[6])
const opts0 = screen.getByLabelText('Options 0') as HTMLInputElement
fireEvent.change(opts0, { target: { value: 'A,B,C' } })
expect(store.getState().fields[0].type).toBe('select')
expect((store.getState().fields[0] as SelectField).options).toEqual(['A','B','C'])
// radio 추가 후 옵션 편집
const buttons2 = screen.getAllByRole('button')
fireEvent.click(buttons2[7])
const opts1 = screen.getByLabelText('Options 1') as HTMLInputElement
fireEvent.change(opts1, { target: { value: 'S,M,L' } })
expect(store.getState().fields[1].type).toBe('radio')
expect((store.getState().fields[1] as RadioField).options).toEqual(['S','M','L'])
// email/checkbox/textarea/date/tel도 추가 버튼 존재 (인덱스 기반 클릭)
const buttons3 = screen.getAllByRole('button')
fireEvent.click(buttons3[1]) // Email
fireEvent.click(buttons3[5]) // Checkbox
fireEvent.click(buttons3[4]) // Textarea
fireEvent.click(buttons3[3]) // Date
fireEvent.click(buttons3[2]) // Tel
const names = store.getState().fields.map((f) => f.type)
expect(names).toContain('email')
expect(names).toContain('checkbox')
expect(names).toContain('textarea')
expect(names).toContain('date')
expect(names).toContain('tel')
})
it('필드 레이블/필수 편집, 이동/삭제가 동작한다', () => {
const store = createFormStore()
render(
<NextIntlClientProvider locale="en" messages={messages}>
<FormBuilderPanel store={store} />
</NextIntlClientProvider>
)
// 3개 추가
fireEvent.click(screen.getAllByRole('button')[0])
fireEvent.click(screen.getAllByRole('button')[0])
fireEvent.click(screen.getAllByRole('button')[0])
expect(screen.queryAllByTestId('field-item').length).toBe(3)
// 첫 필드 레이블 변경
const label0 = screen.getByLabelText('Label 0') as HTMLInputElement
fireEvent.change(label0, { target: { value: 'Name' } })
expect(store.getState().fields[0].label).toBe('Name')
// 첫 필드 required 토글
const req0 = screen.getByLabelText('Required 0') as HTMLInputElement
fireEvent.click(req0)
expect(store.getState().fields[0].required).toBe(true)
// 첫 필드를 아래로 이동 (0 -> 1)
fireEvent.click(screen.getByRole('button', { name: 'Down 0' }))
expect(store.getState().fields.map(f => f.name)).toEqual(['field2','field1','field3'])
// 두 번째(이전 첫) 필드 삭제
fireEvent.click(screen.getByRole('button', { name: 'Remove 1' }))
expect(store.getState().fields.map(f => f.name)).toEqual(['field2','field3'])
})
})
+160
View File
@@ -0,0 +1,160 @@
"use client"
import React, { useCallback, useSyncExternalStore } from 'react'
import type { FormState, FormField } from '@/lib/state/formStore'
import { useTranslations } from 'next-intl'
type Store = {
getState: () => FormState
subscribe: (listener: () => void) => () => void
}
export default function FormBuilderPanel({ store }: { store: Store }) {
const t = useTranslations()
const state = useSyncExternalStore(store.subscribe, store.getState, store.getState)
const onAddText = useCallback(() => {
const idx = state.fields.length + 1
const name = `field${idx}`
store.getState().addField({ type: 'text', name, label: `Text ${idx}`, required: false })
}, [state.fields.length, store])
const nextName = () => `field${state.fields.length + 1}`
const onAddEmail = () => store.getState().addField({ type: 'email', name: nextName(), label: 'Email', required: false })
const onAddTel = () => store.getState().addField({ type: 'tel', name: nextName(), label: 'Tel', required: false })
const onAddDate = () => store.getState().addField({ type: 'date', name: nextName(), label: 'Date', required: false })
const onAddTextarea = () => store.getState().addField({ type: 'textarea', name: nextName(), label: 'Message', required: false })
const onAddCheckbox = () => store.getState().addField({ type: 'checkbox', name: nextName(), label: 'Agree', required: false })
const onAddSelect = () => store.getState().addField({ type: 'select', name: nextName(), label: 'Select', required: false, options: [] })
const onAddRadio = () => store.getState().addField({ type: 'radio', name: nextName(), label: 'Radio', required: false, options: [] })
const onChangeAction = (e: React.ChangeEvent<HTMLInputElement>) => {
store.getState().setActionUrl(e.target.value)
}
const onChangeHp = (e: React.ChangeEvent<HTMLInputElement>) => {
store.getState().setSpam({ honeypotFieldName: e.target.value })
}
const onChangeTs = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = parseInt(e.target.value || '0', 10)
store.getState().setSpam({ minSubmitSeconds: Number.isNaN(v) ? 0 : v })
}
const onChangeLabel = (i: number, e: React.ChangeEvent<HTMLInputElement>) => {
store.getState().updateField(i, { label: e.target.value })
}
const onToggleRequired = (i: number) => {
const cur = store.getState().fields[i]?.required ?? false
store.getState().updateField(i, { required: !cur })
}
const onDown = (i: number) => {
if (i < state.fields.length - 1) {
store.getState().moveField(i, i + 1)
}
}
const onRemove = (i: number) => {
store.getState().removeField(i)
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<button type="button" data-testid="btn-add-text" onClick={onAddText} className="px-2 py-1 border rounded">{t('builder.action.addText')}</button>
<button type="button" onClick={onAddEmail} className="px-2 py-1 border rounded">{t('builder.action.addEmail')}</button>
<button type="button" onClick={onAddTel} className="px-2 py-1 border rounded">{t('builder.action.addTel')}</button>
<button type="button" onClick={onAddDate} className="px-2 py-1 border rounded">{t('builder.action.addDate')}</button>
<button type="button" onClick={onAddTextarea} className="px-2 py-1 border rounded">{t('builder.action.addTextarea')}</button>
<button type="button" onClick={onAddCheckbox} className="px-2 py-1 border rounded">{t('builder.action.addCheckbox')}</button>
<button type="button" onClick={onAddSelect} className="px-2 py-1 border rounded">{t('builder.action.addSelect')}</button>
<button type="button" onClick={onAddRadio} className="px-2 py-1 border rounded">{t('builder.action.addRadio')}</button>
</div>
<div className="space-y-2">
<label className="block" htmlFor="form-action-url">
<span className="block text-sm">{t('builder.form.actionUrl')}</span>
<input id="form-action-url" aria-label="Action URL" className="border px-2 py-1 w-full" value={state.actionUrl}
onChange={onChangeAction} />
</label>
<label className="block" htmlFor="form-honeypot">
<span className="block text-sm">{t('builder.form.honeypot')}</span>
<input id="form-honeypot" aria-label="Honeypot Field" className="border px-2 py-1 w-full" value={state.spam.honeypotFieldName}
onChange={onChangeHp} />
</label>
<label className="block" htmlFor="form-min-seconds">
<span className="block text-sm">{t('builder.form.minSeconds')}</span>
<input id="form-min-seconds" aria-label="Min Submit Seconds" type="number" className="border px-2 py-1 w-full" value={state.spam.minSubmitSeconds}
onChange={onChangeTs} />
</label>
</div>
<ul className="space-y-2">
{state.fields.map((f: FormField, i: number) => (
<li key={`${f.name}-${i}`} data-testid="field-item" className="text-sm border rounded p-2 flex items-center gap-2">
<div className="flex-1">
<div className="text-xs text-gray-500">{f.type} - {f.name}</div>
<label className="block" htmlFor={`field-label-${i}`}>
<span className="sr-only">Label {i}</span>
<input id={`field-label-${i}`} aria-label={`Label ${i}`} className="border px-2 py-1 w-full" value={f.label}
onChange={(e) => onChangeLabel(i, e)} />
</label>
<label className="inline-flex items-center gap-2 mt-1" htmlFor={`field-required-${i}`}>
<input id={`field-required-${i}`} aria-label={`Required ${i}`} type="checkbox" checked={!!f.required}
onChange={() => onToggleRequired(i)} />
<span className="text-xs">required</span>
</label>
{(f.type === 'text' || f.type === 'email' || f.type === 'tel' || f.type === 'date' || f.type === 'textarea') && (
<div className="mt-1 grid grid-cols-2 gap-2">
<label className="block" htmlFor={`field-pattern-${i}`}>
<span className="sr-only">Pattern {i}</span>
<input
id={`field-pattern-${i}`}
aria-label={`Pattern ${i}`}
className="border px-2 py-1 w-full"
value={f.pattern ?? ''}
onChange={(e) => store.getState().updateField(i, { pattern: e.target.value })}
/>
</label>
<label className="block" htmlFor={`field-maxlen-${i}`}>
<span className="sr-only">MaxLength {i}</span>
<input
id={`field-maxlen-${i}`}
aria-label={`MaxLength ${i}`}
type="number"
className="border px-2 py-1 w-full"
value={f.maxLength ?? ''}
onChange={(e) => {
const v = parseInt(e.target.value || '0', 10)
store.getState().updateField(i, { maxLength: Number.isNaN(v) ? undefined : v })
}}
/>
</label>
</div>
)}
{(f.type === 'select' || f.type === 'radio') && (
<label className="block mt-1" htmlFor={`field-options-${i}`}>
<span className="sr-only">Options {i}</span>
<input id={`field-options-${i}`} aria-label={`Options ${i}`} className="border px-2 py-1 w-full"
value={(f.type === 'select' || f.type === 'radio') ? f.options.join(',') : ''}
onChange={(e) => {
const opts = e.target.value.split(',').map((s) => s.trim()).filter(Boolean)
if (f.type === 'select' || f.type === 'radio') {
store.getState().updateField(i, { options: opts } as Partial<FormField>)
}
}} />
</label>
)}
</div>
<div className="flex items-center gap-2">
<button type="button" aria-label={`Down ${i}`} onClick={() => onDown(i)} className="px-2 py-1 border rounded">Down {i}</button>
<button type="button" aria-label={`Remove ${i}`} onClick={() => onRemove(i)} className="px-2 py-1 border rounded text-red-600">Remove {i}</button>
</div>
</li>
))}
</ul>
</div>
)
}
+21
View File
@@ -0,0 +1,21 @@
"use client"
import { NextIntlClientProvider } from 'next-intl'
import React from 'react'
export default function I18nProvider({
locale,
messages,
children,
}: {
locale: string
messages: Record<string, string>
children: React.ReactNode
}) {
return (
// next-intl: Provide a default timeZone to avoid ENVIRONMENT_FALLBACK mismatches
<NextIntlClientProvider locale={locale} messages={messages} timeZone="Asia/Seoul">
{children}
</NextIntlClientProvider>
)
}
@@ -0,0 +1,53 @@
import { render, screen, fireEvent } from '@testing-library/react'
import '@testing-library/jest-dom'
import React from 'react'
import BuilderClientPage from '@/components/BuilderClientPage'
import I18nProvider from '@/components/I18nProvider'
import en from '@/messages/en.json'
import { assertDescribedBy } from '@/components/testUtils/inspectorA11y'
describe('Inspector a11y sections template - Hero/CTA/FAQ describedby wiring', () => {
it('Hero, CTA, FAQ inspector inputs are wired to helptexts', () => {
render(
<I18nProvider locale="en" messages={en as unknown as Record<string, string>}>
<BuilderClientPage />
</I18nProvider>
)
// Add hero to enable inspector
const addHero = screen.getByRole('button', { name: 'Add Hero' })
fireEvent.click(addHero)
// Select first section (Hero)
const selectButtons = screen.getAllByRole('button', { name: /Select \d+/ })
fireEvent.click(selectButtons[0])
const heroHeading = screen.getByLabelText('Hero Heading')
assertDescribedBy(heroHeading, 'ins-hero-heading-help')
// Replace with CTA and test describedby
const addCta = screen.getByRole('button', { name: 'Add CTA' })
fireEvent.click(addCta)
const selectSecond = screen.getAllByRole('button', { name: /Select \d+/ })[1]
fireEvent.click(selectSecond)
const ctaText = screen.getByLabelText('CTA Text')
assertDescribedBy(ctaText, 'ins-cta-text-help')
const ctaHref = screen.getByLabelText('CTA Href')
assertDescribedBy(ctaHref, 'ins-cta-href-help')
// Add FAQ and test using shared help id
const addFaq = screen.getByRole('button', { name: 'Add FAQ' })
fireEvent.click(addFaq)
const selectThird = screen.getAllByRole('button', { name: /Select \d+/ })[2]
fireEvent.click(selectThird)
// Add one FAQ item to render inputs
const addFaqItem = screen.getByRole('button', { name: 'Add FAQ Item' })
fireEvent.click(addFaqItem)
const faqQ = screen.getByLabelText('FAQ Q 0')
const faqA = screen.getByLabelText('FAQ A 0')
assertDescribedBy(faqQ, 'ins-faq-help')
assertDescribedBy(faqA, 'ins-faq-help')
})
})
@@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import React from 'react'
import BuilderClientPage from '@/components/BuilderClientPage'
import I18nProvider from '@/components/I18nProvider'
import en from '@/messages/en.json'
import { assertDescribedBy } from '@/components/testUtils/inspectorA11y'
describe('Inspector a11y template - describedby wiring', () => {
it('SEO/Analytics inputs are wired to their helptexts via aria-describedby', () => {
render(
<I18nProvider locale="en" messages={en as unknown as Record<string, string>}>
<BuilderClientPage />
</I18nProvider>
)
const seoTitle = screen.getByLabelText('SEO Title')
const seoDesc = screen.getByLabelText('SEO Description')
const ogImage = screen.getByLabelText('OG Image URL')
const ga4 = screen.getByLabelText('GA4 Measurement ID')
const meta = screen.getByLabelText('Meta Pixel ID')
assertDescribedBy(seoTitle, 'ins-seo-title-help')
assertDescribedBy(seoDesc, 'ins-seo-desc-help')
assertDescribedBy(ogImage, 'ins-og-image-help')
assertDescribedBy(ga4, 'ins-ga4-help')
assertDescribedBy(meta, 'ins-meta-pixel-help')
})
})
+25
View File
@@ -0,0 +1,25 @@
import { render, screen } from '@testing-library/react'
import { describe, test, expect } from 'vitest'
import SectionCanvas from './SectionCanvas'
import type { Section } from '@/lib/state/store'
describe('SectionCanvas a11y', () => {
test('renders dnd-kit live region and instructions', () => {
const sections: Section[] = []
render(
<SectionCanvas
sections={sections}
onMove={() => {}}
onRemove={() => {}}
onSelect={() => {}}
/>
)
// dnd-kit screen reader instructions should be present in the DOM
expect(screen.getByText(/To pick up a draggable item/i)).toBeInTheDocument()
// Live region for announcements should exist
const liveRegion = screen.getByRole('status')
expect(liveRegion).toHaveAttribute('aria-live', 'assertive')
})
})
@@ -0,0 +1,77 @@
import { describe, expect, test } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import React, { useState } from 'react'
import SectionCanvas from './SectionCanvas'
import type { Section } from '@/lib/state/store'
function Wrapper({ withSelect = true }: { withSelect?: boolean }) {
const [sections, setSections] = useState<Section[]>([
{ type: 'hero', props: { heading: 'H', subheading: '' } },
{ type: 'faq', props: { items: [] } },
{ type: 'cta', props: { text: 'Go', href: '#' } },
])
const onMove = (from: number, to: number) => {
setSections((prev) => {
const next = prev.slice()
const [sp] = next.splice(from, 1)
next.splice(to, 0, sp)
return next
})
}
const onRemove = () => {}
const onSelect = withSelect ? () => {} : undefined
return <SectionCanvas sections={sections} onMove={onMove} onRemove={onRemove} onSelect={onSelect} />
}
describe('SectionCanvas aria-labels on control buttons', () => {
test('Select/Up/Down/Remove buttons expose index-based aria-labels', () => {
render(<Wrapper />)
const list = screen.getByTestId('section-canvas')
const items = within(list).getAllByTestId(/sec-item-/)
// check first item labels (index 0)
const first = items[0]
const sel0 = within(first).getByLabelText('Select 0') as HTMLButtonElement
const up0 = within(first).getByLabelText('Up 0') as HTMLButtonElement
const down0 = within(first).getByLabelText('Down 0') as HTMLButtonElement
const rm0 = within(first).getByLabelText('Remove 0') as HTMLButtonElement
expect(sel0).toBeInTheDocument()
expect(up0).toBeInTheDocument()
expect(down0).toBeInTheDocument()
expect(rm0).toBeInTheDocument()
expect(sel0.title).toBe(sel0.getAttribute('aria-label'))
expect(up0.title).toBe(up0.getAttribute('aria-label'))
expect(down0.title).toBe(down0.getAttribute('aria-label'))
expect(rm0.title).toBe(rm0.getAttribute('aria-label'))
// check last item labels (index 2)
const last = items[2]
const sel2 = within(last).getByLabelText('Select 2') as HTMLButtonElement
const up2 = within(last).getByLabelText('Up 2') as HTMLButtonElement
const down2 = within(last).getByLabelText('Down 2') as HTMLButtonElement
const rm2 = within(last).getByLabelText('Remove 2') as HTMLButtonElement
expect(sel2).toBeInTheDocument()
expect(up2).toBeInTheDocument()
expect(down2).toBeInTheDocument()
expect(rm2).toBeInTheDocument()
expect(sel2.title).toBe(sel2.getAttribute('aria-label'))
expect(up2.title).toBe(up2.getAttribute('aria-label'))
expect(down2.title).toBe(down2.getAttribute('aria-label'))
expect(rm2.title).toBe(rm2.getAttribute('aria-label'))
})
test('Up/Down buttons still present when onSelect not provided', () => {
render(<Wrapper withSelect={false} />)
const list = screen.getByTestId('section-canvas')
const items = within(list).getAllByTestId(/sec-item-/)
// when onSelect is undefined, Select button should not exist
expect(within(items[0]).queryByLabelText('Select 0')).toBeNull()
// Up/Down/Remove must still be present
expect(within(items[0]).getByLabelText('Up 0')).toBeInTheDocument()
expect(within(items[0]).getByLabelText('Down 0')).toBeInTheDocument()
expect(within(items[0]).getByLabelText('Remove 0')).toBeInTheDocument()
})
})
+77
View File
@@ -0,0 +1,77 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import React, { useState } from 'react'
import SectionCanvas from '@/components/SectionCanvas'
import type { Section } from '@/lib/state/store'
type DragEvt = { active: { id: string }; over: { id: string } }
vi.mock('@dnd-kit/core', async () => {
const actual = await vi.importActual<typeof import('@dnd-kit/core')>('@dnd-kit/core')
return {
...actual,
DndContext: ({ onDragEnd, children }: { onDragEnd?: (e: DragEvt) => void; children: React.ReactNode }) => (
<div data-testid="mock-dnd" onClick={() => onDragEnd?.({ active: { id: 'sec-0' }, over: { id: 'sec-2' } })}>
{children}
</div>
),
closestCenter: vi.fn(),
PointerSensor: function () {},
useSensor: vi.fn(() => ({})),
useSensors: vi.fn(() => []),
}
})
vi.mock('@dnd-kit/sortable', async () => {
const actual = await vi.importActual<typeof import('@dnd-kit/sortable')>('@dnd-kit/sortable')
return {
...actual,
SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useSortable: () => ({
attributes: {} as Record<string, unknown>,
listeners: {} as Record<string, unknown>,
setNodeRef: () => {},
transform: null as unknown,
transition: null as unknown,
}),
verticalListSortingStrategy: vi.fn(),
}
})
function Wrapper() {
const [sections, setSections] = useState<Section[]>([
{ type: 'hero', props: { heading: 'Welcome', subheading: '' } },
{ type: 'faq', props: { items: [] } },
{ type: 'cta', props: { text: 'Go', href: '#' } },
])
return (
<SectionCanvas
sections={sections}
onMove={(from, to) => {
const next = sections.slice()
const [sp] = next.splice(from, 1)
next.splice(to, 0, sp)
setSections(next)
}}
onRemove={(i) => setSections((prev) => prev.filter((_, idx) => idx !== i))}
/>
)
}
describe('SectionCanvas - dnd', () => {
it('드래그 종료 이벤트로 섹션 순서를 변경한다', () => {
render(<Wrapper />)
const list = screen.getByTestId('section-canvas')
let items = list.querySelectorAll('li')
expect(items[0].textContent).toMatch(/hero/)
expect(items[1].textContent).toMatch(/faq/)
expect(items[2].textContent).toMatch(/cta/)
fireEvent.click(screen.getByTestId('mock-dnd'))
items = list.querySelectorAll('li')
expect(items[0].textContent).toMatch(/faq/)
expect(items[1].textContent).toMatch(/cta/)
expect(items[2].textContent).toMatch(/hero/)
})
})
@@ -0,0 +1,60 @@
import { describe, expect, test } from 'vitest'
import { render, screen, fireEvent, within } from '@testing-library/react'
import React, { useState } from 'react'
import SectionCanvas from './SectionCanvas'
import type { Section } from '@/lib/state/store'
function Wrapper() {
const [sections, setSections] = useState<Section[]>([
{ type: 'hero', props: { heading: 'H', subheading: '' } },
{ type: 'faq', props: { items: [] } },
{ type: 'cta', props: { text: 'Go', href: '#' } },
])
const onMove = (from: number, to: number) => {
setSections((prev) => {
const next = prev.slice()
const [sp] = next.splice(from, 1)
next.splice(to, 0, sp)
return next
})
}
return (
<SectionCanvas
sections={sections}
onMove={onMove}
onRemove={() => {}}
onSelect={() => {}}
/>
)
}
describe('SectionCanvas keyboard/fallback reorder', () => {
test('clicking Down and Up buttons reorders items', () => {
render(<Wrapper />)
// initial order: hero, faq, cta
const list = screen.getByTestId('section-canvas')
let items = within(list).getAllByTestId(/sec-item-/)
expect(items[0]).toHaveTextContent('hero')
expect(items[1]).toHaveTextContent('faq')
expect(items[2]).toHaveTextContent('cta')
// move first item (index 0) down
fireEvent.click(within(items[0]).getByLabelText('Down 0'))
items = within(list).getAllByTestId(/sec-item-/)
expect(items[0]).toHaveTextContent('faq')
expect(items[1]).toHaveTextContent('hero')
expect(items[2]).toHaveTextContent('cta')
// move last item up
fireEvent.click(within(items[2]).getByLabelText('Up 2'))
items = within(list).getAllByTestId(/sec-item-/)
expect(items[0]).toHaveTextContent('faq')
expect(items[1]).toHaveTextContent('cta')
expect(items[2]).toHaveTextContent('hero')
})
})
+74
View File
@@ -0,0 +1,74 @@
"use client"
import React, { useMemo } from 'react'
import type { Section } from '@/lib/state/store'
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragEndEvent } from '@dnd-kit/core'
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
function SortableItem({ id, children, testId }: { id: string; children: React.ReactNode; testId?: string }) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<li ref={setNodeRef} style={style} {...attributes} {...listeners} data-testid={testId} className="flex items-center justify-between border rounded px-3 py-2">
{children}
</li>
)
}
export default function SectionCanvas({
sections,
onMove,
onRemove,
onSelect,
}: {
sections: Section[]
onMove: (from: number, to: number) => void
onRemove: (index: number) => void
onSelect?: (index: number) => void
}) {
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
const ids = useMemo(() => sections.map((_, i) => `sec-${i}`), [sections])
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over) return
if (active.id === over.id) return
const from = ids.indexOf(String(active.id))
const to = ids.indexOf(String(over.id))
if (from !== -1 && to !== -1) onMove(from, to)
}
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
<ul className="space-y-1" data-testid="section-canvas">
{sections.map((s, i) => (
<SortableItem id={`sec-${i}`} key={`sec-${i}`} testId={`sec-item-${i}`}>
<span className="text-sm">{s.type}</span>
<div className="flex items-center gap-2">
{onSelect && (
<button aria-label={`Select ${i}`} title={`Select ${i}`} className="text-sm border rounded px-2 py-1" onClick={() => onSelect(i)}>
Select
</button>
)}
<button aria-label={`Up ${i}`} title={`Up ${i}`} className="text-sm border rounded px-2 py-1" onClick={() => i > 0 && onMove(i, i - 1)}>
Up
</button>
<button aria-label={`Down ${i}`} title={`Down ${i}`} className="text-sm border rounded px-2 py-1" onClick={() => i < sections.length - 1 && onMove(i, i + 1)}>
Down
</button>
<button aria-label={`Remove ${i}`} title={`Remove ${i}`} className="text-sm text-red-600 border rounded px-2 py-1" onClick={() => onRemove(i)}>
Remove
</button>
</div>
</SortableItem>
))}
</ul>
</SortableContext>
</DndContext>
)
}
+45
View File
@@ -0,0 +1,45 @@
"use client"
import React from 'react'
export default function SidebarComponents({
onAddHero,
onAddFAQ,
onAddCTA,
onAddFeatures,
onAddTestimonials,
}: {
onAddHero: () => void
onAddFAQ: () => void
onAddCTA: () => void
onAddFeatures: () => void
onAddTestimonials: () => void
}) {
return (
<div className="space-y-2" data-testid="sidebar-components">
<h2 className="text-sm font-semibold">Components</h2>
<div className="grid grid-cols-1 gap-2">
<button data-testid="card-hero" className="border rounded p-2 text-left" onClick={onAddHero}>
<div className="text-sm font-medium">Hero</div>
<div className="text-xs text-gray-500">Large heading + image</div>
</button>
<button data-testid="card-faq" className="border rounded p-2 text-left" onClick={onAddFAQ}>
<div className="text-sm font-medium">FAQ</div>
<div className="text-xs text-gray-500">Questions & answers</div>
</button>
<button data-testid="card-cta" className="border rounded p-2 text-left" onClick={onAddCTA}>
<div className="text-sm font-medium">CTA</div>
<div className="text-xs text-gray-500">Action button</div>
</button>
<button data-testid="card-features" className="border rounded p-2 text-left" onClick={onAddFeatures}>
<div className="text-sm font-medium">Features</div>
<div className="text-xs text-gray-500">List of product features</div>
</button>
<button data-testid="card-testimonials" className="border rounded p-2 text-left" onClick={onAddTestimonials}>
<div className="text-sm font-medium">Testimonials</div>
<div className="text-xs text-gray-500">Customer quotes</div>
</button>
</div>
</div>
)
}
@@ -0,0 +1,51 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { NextIntlClientProvider } from 'next-intl'
import { describe, expect, test } from 'vitest'
import BuilderClientPage from './BuilderClientPage'
import messages from '../messages/en.json'
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<NextIntlClientProvider messages={messages} locale="en">
{children}
</NextIntlClientProvider>
)
}
describe('Buttons title/aria-label consistency', () => {
test('Inspector Remove buttons have title matching aria-label', async () => {
render(
<Wrapper>
<BuilderClientPage />
</Wrapper>
)
// Add Features section and select
fireEvent.click(screen.getByTestId('card-features'))
fireEvent.click(await screen.findByLabelText('Select 0'))
// Ensure at least one item
fireEvent.click(screen.getByRole('button', { name: /Add Feature Item/i }))
// Validate Features remove buttons
const featureRemoveButtons = screen.getAllByRole('button', { name: /Remove Feature \d+/ })
for (const btn of featureRemoveButtons) {
const aria = btn.getAttribute('aria-label') || ''
const title = btn.getAttribute('title') || ''
expect(title).toBe(aria)
}
// Add Testimonials section and select
fireEvent.click(screen.getByTestId('card-testimonials'))
fireEvent.click(await screen.findByLabelText('Select 1'))
// Ensure at least one item to expose remove button
fireEvent.click(screen.getByRole('button', { name: /Add Testimonial/i }))
// Validate Testimonials remove buttons
const testimonialRemoveButtons = screen.getAllByRole('button', { name: /Remove Testimonial \d+/ })
for (const btn of testimonialRemoveButtons) {
const aria = btn.getAttribute('aria-label') || ''
const title = btn.getAttribute('title') || ''
expect(title).toBe(aria)
}
})
})
+28
View File
@@ -0,0 +1,28 @@
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import React from 'react'
import BuilderClientPage from '@/components/BuilderClientPage'
import I18nProvider from '@/components/I18nProvider'
import en from '@/messages/en.json'
describe('Buttons naming/aria-label consistency (smoke)', () => {
it('renders primary action buttons with stable accessible names', () => {
render(
<I18nProvider locale="en" messages={en as unknown as Record<string, string>}>
<BuilderClientPage />
</I18nProvider>
)
// Sidebar/toolbar actions
expect(screen.getByRole('button', { name: 'Add Hero' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add FAQ' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add CTA' })).toBeInTheDocument()
// Export button
expect(screen.getByRole('button', { name: 'Export ZIP' })).toBeInTheDocument()
// Preview viewport toggle buttons have explicit aria-labels as names
expect(screen.getByRole('button', { name: 'Desktop' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Mobile' })).toBeInTheDocument()
})
})
+42
View File
@@ -0,0 +1,42 @@
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import React from 'react'
import BuilderClientPage from '@/components/BuilderClientPage'
import I18nProvider from '@/components/I18nProvider'
import en from '@/messages/en.json'
import ko from '@/messages/ko.json'
describe('i18n regression - form add actions (en/ko)', () => {
function renderWith(locale: 'en' | 'ko') {
const messages = locale === 'en' ? en : ko
return render(
<I18nProvider locale={locale} messages={messages as unknown as Record<string, string>}>
<BuilderClientPage />
</I18nProvider>
)
}
it('en labels exist for form field add buttons', () => {
renderWith('en')
expect(screen.getByRole('button', { name: 'Add Text' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add Email' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add Tel' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add Date' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add Textarea' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add Checkbox' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add Select' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add Radio' })).toBeInTheDocument()
})
it('ko labels exist for form field add buttons', () => {
renderWith('ko')
expect(screen.getByRole('button', { name: '텍스트 필드 추가' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '이메일 필드 추가' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '전화번호 필드 추가' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '날짜 필드 추가' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '텍스트영역 추가' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '체크박스 추가' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '셀렉트 추가' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '라디오 추가' })).toBeInTheDocument()
})
})
+32
View File
@@ -0,0 +1,32 @@
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import React from 'react'
import BuilderClientPage from '@/components/BuilderClientPage'
import I18nProvider from '@/components/I18nProvider'
import en from '@/messages/en.json'
import ko from '@/messages/ko.json'
describe('i18n regression - misc labels (title/preview/sections)', () => {
function renderWith(locale: 'en' | 'ko') {
const messages = locale === 'en' ? en : ko
return render(
<I18nProvider locale={locale} messages={messages as unknown as Record<string, string>}>
<BuilderClientPage />
</I18nProvider>
)
}
it('en: builder title, preview text, sections heading present', () => {
renderWith('en')
expect(screen.getByRole('heading', { name: 'Landing Page Builder' })).toBeInTheDocument()
expect(screen.getByText('Preview (static)')).toBeInTheDocument()
expect(screen.getByText('Sections')).toBeInTheDocument()
})
it('ko: builder title, preview text, sections heading present', () => {
renderWith('ko')
expect(screen.getByRole('heading', { name: '랜딩 페이지 빌더' })).toBeInTheDocument()
expect(screen.getByText('미리보기(정적)')).toBeInTheDocument()
expect(screen.getByText('섹션')).toBeInTheDocument()
})
})
+62
View File
@@ -0,0 +1,62 @@
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import React from 'react'
import BuilderClientPage from '@/components/BuilderClientPage'
import I18nProvider from '@/components/I18nProvider'
import en from '@/messages/en.json'
import ko from '@/messages/ko.json'
function renderWith(locale: 'en' | 'ko') {
const messages = locale === 'en' ? en : ko
return render(
<I18nProvider locale={locale} messages={messages as unknown as Record<string, string>}>
<BuilderClientPage />
</I18nProvider>
)
}
describe('i18n regression - key presence on main UI', () => {
it('en: shows expected labels and buttons', () => {
renderWith('en')
expect(screen.getByRole('heading', { name: 'Landing Page Builder' })).toBeInTheDocument()
expect(screen.getByLabelText('Title')).toBeInTheDocument()
expect(screen.getByLabelText('Locale')).toBeInTheDocument()
expect(screen.getByLabelText('Primary Color')).toBeInTheDocument()
expect(screen.getByLabelText('Font Family')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Export ZIP' })).toBeInTheDocument()
// form actions
expect(screen.getByRole('button', { name: 'Add Text' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add Email' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add Tel' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add Date' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add Textarea' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add Checkbox' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add Select' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add Radio' })).toBeInTheDocument()
// preview
expect(screen.getByRole('button', { name: 'Desktop' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Mobile' })).toBeInTheDocument()
})
it('ko: shows expected labels and buttons', () => {
renderWith('ko')
expect(screen.getByRole('heading', { name: '랜딩 페이지 빌더' })).toBeInTheDocument()
expect(screen.getByLabelText('제목')).toBeInTheDocument()
expect(screen.getByLabelText('로케일')).toBeInTheDocument()
expect(screen.getByLabelText('대표 색상')).toBeInTheDocument()
expect(screen.getByLabelText('폰트')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'ZIP 내보내기' })).toBeInTheDocument()
// form actions
expect(screen.getByRole('button', { name: '텍스트 필드 추가' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '이메일 필드 추가' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '전화번호 필드 추가' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '날짜 필드 추가' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '텍스트영역 추가' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '체크박스 추가' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '셀렉트 추가' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '라디오 추가' })).toBeInTheDocument()
// preview
expect(screen.getByRole('button', { name: 'Desktop' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Mobile' })).toBeInTheDocument()
})
})
+32
View File
@@ -0,0 +1,32 @@
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import React from 'react'
import BuilderClientPage from '@/components/BuilderClientPage'
import I18nProvider from '@/components/I18nProvider'
import en from '@/messages/en.json'
import ko from '@/messages/ko.json'
describe('i18n regression - sidebar add actions (en/ko)', () => {
function renderWith(locale: 'en' | 'ko') {
const messages = locale === 'en' ? en : ko
return render(
<I18nProvider locale={locale} messages={messages as unknown as Record<string, string>}>
<BuilderClientPage />
</I18nProvider>
)
}
it('en labels exist', () => {
renderWith('en')
expect(screen.getByRole('button', { name: 'Add Hero' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add FAQ' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add CTA' })).toBeInTheDocument()
})
it('ko labels exist', () => {
renderWith('ko')
expect(screen.getByRole('button', { name: '히어로 추가' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'FAQ 추가' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'CTA 추가' })).toBeInTheDocument()
})
})
@@ -0,0 +1,31 @@
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import React from 'react'
import BuilderClientPage from '@/components/BuilderClientPage'
import I18nProvider from '@/components/I18nProvider'
import en from '@/messages/en.json'
describe('Preview viewport toggle ARIA regression', () => {
it('Desktop/Mobile buttons expose stable ARIA attributes', () => {
render(
<I18nProvider locale="en" messages={en as unknown as Record<string, string>}>
<BuilderClientPage />
</I18nProvider>
)
const desktop = screen.getByRole('button', { name: 'Desktop' })
const mobile = screen.getByRole('button', { name: 'Mobile' })
// describedby shared help
expect(desktop).toHaveAttribute('aria-describedby', 'preview-toggle-help')
expect(mobile).toHaveAttribute('aria-describedby', 'preview-toggle-help')
// controls preview container
expect(desktop).toHaveAttribute('aria-controls', 'preview-container')
expect(mobile).toHaveAttribute('aria-controls', 'preview-container')
// default pressed state: Desktop true, Mobile false
expect(desktop).toHaveAttribute('aria-pressed', 'true')
expect(mobile).toHaveAttribute('aria-pressed', 'false')
})
})
+8
View File
@@ -0,0 +1,8 @@
// Inspector a11y helper: verify input ↔ helptext wiring via aria-describedby
import { expect } from 'vitest'
export function assertDescribedBy(input: HTMLElement, helpId: string) {
expect(input).toHaveAttribute('aria-describedby', helpId)
const help = (input.ownerDocument || document).getElementById(helpId)
expect(help).not.toBeNull()
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest'
import { render, fireEvent } from '@testing-library/react'
import { Canvas } from '@/components/wysiwyg/Canvas'
import type { Frame } from '@/lib/wysiwyg/schema'
function makeFrame(): Frame {
return {
id: 'f1',
width: 400,
height: 300,
background: '#ffffff',
layers: [
{
id: 'l1',
name: 'L1',
visible: true,
locked: false,
objects: [
{ id: 'o1', type: 'image', x: 101, y: 100, width: 50, height: 40, rotate: 0, zIndex: 0, props: { src: 'x', alt: '' } },
],
},
],
}
}
describe('WYSIWYG Canvas mouse drag with 8px snap', () => {
it('dragging object snaps to nearest 8px', () => {
const frame = makeFrame()
const { getByTestId } = render(<Canvas frame={frame} />)
const canvas = getByTestId('wysiwyg-canvas')
const obj = getByTestId('obj-o1')
// start drag
fireEvent.mouseDown(obj, { clientX: 200, clientY: 150 })
// move by +5,+5 (should snap from 101->104, 100->104)
fireEvent.mouseMove(canvas, { clientX: 205, clientY: 155 })
fireEvent.mouseUp(canvas)
expect(obj).toHaveStyle({ left: '104px', top: '104px' })
})
})
+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest'
import { render, fireEvent } from '@testing-library/react'
import { Canvas } from '@/components/wysiwyg/Canvas'
import type { Frame } from '@/lib/wysiwyg/schema'
function makeFrame(): Frame {
return {
id: 'f1',
width: 400,
height: 300,
background: '#ffffff',
layers: [
{
id: 'l1',
name: 'L1',
visible: true,
locked: false,
objects: [
{ id: 'o1', type: 'image', x: 101, y: 100, width: 50, height: 40, rotate: 0, zIndex: 0, props: { src: 'x', alt: '' } },
],
},
],
}
}
describe('WYSIWYG Canvas keyboard nudge with 8px snap', () => {
it('ArrowRight nudges and snaps x to nearest 8px grid', () => {
const frame = makeFrame()
const { getByTestId } = render(<Canvas frame={frame} />)
const canvas = getByTestId('wysiwyg-canvas')
fireEvent.keyDown(canvas, { key: 'ArrowRight' })
const obj = getByTestId('obj-o1')
expect(obj).toHaveStyle({ left: '104px' }) // 101 + 1 => 102 -> snap8 => 104
})
it('ArrowUp nudges and snaps y to nearest 8px grid', () => {
const frame = makeFrame()
const { getByTestId } = render(<Canvas frame={frame} />)
const canvas = getByTestId('wysiwyg-canvas')
fireEvent.keyDown(canvas, { key: 'ArrowUp' })
const obj = getByTestId('obj-o1')
expect(obj).toHaveStyle({ top: '96px' }) // 100 - 1 => 99 -> snap8 => 96
})
})
+42
View File
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest'
import { render, fireEvent } from '@testing-library/react'
import { Canvas } from '@/components/wysiwyg/Canvas'
import type { Frame } from '@/lib/wysiwyg/schema'
function makeFrame(): Frame {
return {
id: 'f1',
width: 400,
height: 300,
background: '#ffffff',
layers: [
{
id: 'l1',
name: 'L1',
visible: true,
locked: false,
objects: [
{ id: 'o1', type: 'image', x: 100, y: 100, width: 50, height: 40, rotate: 0, zIndex: 0, props: { src: 'x', alt: '' } },
],
},
],
}
}
describe('WYSIWYG Canvas resize with 8px snap', () => {
it('dragging bottom-right handle resizes and snaps to nearest 8px', () => {
const frame = makeFrame()
const { getByTestId } = render(<Canvas frame={frame} />)
const handle = getByTestId('resize-handle-o1')
const canvas = getByTestId('wysiwyg-canvas')
// start resize
fireEvent.mouseDown(handle, { clientX: 150, clientY: 140 })
// move by +7,+9 → width 50-> 57 -> snap8 => 56, height 40->49 -> snap8 => 48
fireEvent.mouseMove(canvas, { clientX: 157, clientY: 149 })
fireEvent.mouseUp(canvas)
const obj = getByTestId('obj-o1')
expect(obj).toHaveStyle({ width: '56px', height: '48px' })
})
})
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest'
import { render, fireEvent } from '@testing-library/react'
import { Canvas } from '@/components/wysiwyg/Canvas'
import type { Frame } from '@/lib/wysiwyg/schema'
function makeFrame(): Frame {
return {
id: 'f1',
width: 400,
height: 300,
background: '#ffffff',
layers: [
{
id: 'l1',
name: 'L1',
visible: true,
locked: false,
objects: [
{ id: 'o1', type: 'image', x: 100, y: 100, width: 50, height: 40, rotate: 0, zIndex: 0, props: { src: 'x', alt: '' } },
],
},
],
}
}
describe('WYSIWYG Canvas rotate with snap', () => {
it('rotates with 15deg snap by default', () => {
const frame = makeFrame()
const { getByTestId } = render(<Canvas frame={frame} />)
const handle = getByTestId('rotate-handle-o1')
const canvas = getByTestId('wysiwyg-canvas')
// start rotate at 0deg
fireEvent.mouseDown(handle, { clientX: 100, clientY: 100 })
// move +16px on x → +16deg → snap15 => 15deg
fireEvent.mouseMove(canvas, { clientX: 116, clientY: 100 })
fireEvent.mouseUp(canvas)
const obj = getByTestId('obj-o1')
expect(obj).toHaveStyle({ transform: 'rotate(15deg)' })
})
it('rotates with 1deg step while holding Shift', () => {
const frame = makeFrame()
const { getByTestId } = render(<Canvas frame={frame} />)
const handle = getByTestId('rotate-handle-o1')
const canvas = getByTestId('wysiwyg-canvas')
fireEvent.mouseDown(handle, { clientX: 100, clientY: 100 })
// move +7px with Shift → +7deg, no snap
fireEvent.mouseMove(canvas, { clientX: 107, clientY: 100, shiftKey: true })
fireEvent.mouseUp(canvas)
const obj = getByTestId('obj-o1')
expect(obj).toHaveStyle({ transform: 'rotate(7deg)' })
})
})
+140
View File
@@ -0,0 +1,140 @@
import React, { useCallback, useMemo, useRef, useState } from 'react'
import type { Frame, WObject } from '@/lib/wysiwyg/schema'
function snap8(v: number) {
return Math.round(v / 8) * 8
}
export type CanvasProps = {
frame: Frame
onChange?: (next: Frame) => void
}
export function Canvas({ frame, onChange }: CanvasProps) {
const [model, setModel] = useState<Frame>(frame)
const [selectedId, setSelectedId] = useState<string | null>(model.layers[0]?.objects[0]?.id ?? null)
const draggingRef = useRef<{ id: string; startX: number; startY: number; origX: number; origY: number } | null>(null)
const resizingRef = useRef<{ id: string; startX: number; startY: number; origW: number; origH: number } | null>(null)
const rotatingRef = useRef<{ id: string; startX: number; startY: number; origDeg: number } | null>(null)
const selected = useMemo(() => {
for (const layer of model.layers) {
const obj = layer.objects.find((o) => o.id === selectedId)
if (obj) return obj
}
return null
}, [model, selectedId])
const updateObject = useCallback((id: string, updater: (o: WObject) => WObject) => {
setModel((prev) => {
const next: Frame = { ...prev, layers: prev.layers.map((ly) => ({ ...ly, objects: ly.objects.map((o) => (o.id === id ? updater(o) : o)) })) }
onChange?.(next)
return next
})
}, [onChange])
const onKeyDown = useCallback((e: React.KeyboardEvent) => {
if (!selected) return
let dx = 0, dy = 0
if (e.key === 'ArrowLeft') dx = -1
else if (e.key === 'ArrowRight') dx = 1
else if (e.key === 'ArrowUp') dy = -1
else if (e.key === 'ArrowDown') dy = 1
else return
e.preventDefault()
updateObject(selected.id, (o) => {
const nx = snap8(o.x + dx)
const ny = snap8(o.y + dy)
return { ...o, x: nx, y: ny }
})
}, [selected, updateObject])
const onMouseDownObj = useCallback((e: React.MouseEvent, o: WObject) => {
setSelectedId(o.id)
draggingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origX: o.x, origY: o.y }
}, [])
const onMouseMoveCanvas = useCallback((e: React.MouseEvent) => {
const rot = rotatingRef.current
if (rot) {
const dx = e.clientX - rot.startX
let next = rot.origDeg + dx
if (!e.shiftKey) {
next = Math.round(next / 15) * 15
}
updateObject(rot.id, (o) => ({ ...o, rotate: next }))
return
}
const r = resizingRef.current
if (r) {
const dx = e.clientX - r.startX
const dy = e.clientY - r.startY
const nw = Math.max(8, snap8(r.origW + dx))
const nh = Math.max(8, snap8(r.origH + dy))
updateObject(r.id, (o) => ({ ...o, width: nw, height: nh }))
return
}
const d = draggingRef.current
if (d) {
const dx = e.clientX - d.startX
const dy = e.clientY - d.startY
const nx = snap8(d.origX + dx)
const ny = snap8(d.origY + dy)
updateObject(d.id, (o) => ({ ...o, x: nx, y: ny }))
}
}, [updateObject])
const onMouseUpCanvas = useCallback(() => {
draggingRef.current = null
resizingRef.current = null
rotatingRef.current = null
}, [])
const onMouseDownResize = useCallback((e: React.MouseEvent, o: WObject) => {
e.stopPropagation()
resizingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origW: o.width, origH: o.height }
}, [])
const onMouseDownRotate = useCallback((e: React.MouseEvent, o: WObject) => {
e.stopPropagation()
rotatingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origDeg: o.rotate }
}, [])
return (
<div
role="application"
aria-label="WYSIWYG Canvas"
tabIndex={0}
onKeyDown={onKeyDown}
onMouseMove={onMouseMoveCanvas}
onMouseUp={onMouseUpCanvas}
style={{ position: 'relative', width: model.width, height: model.height, outline: '0' }}
data-testid="wysiwyg-canvas"
>
{model.layers.map((layer) => layer.visible !== false && layer.objects.map((o) => (
<div
key={o.id}
data-testid={`obj-${o.id}`}
aria-label={`object ${o.id}`}
onMouseDown={(e) => onMouseDownObj(e, o)}
style={{ position: 'absolute', left: o.x, top: o.y, width: o.width, height: o.height, transform: `rotate(${o.rotate}deg)`, outline: selectedId===o.id? '2px solid #0ea5e9':'none' }}
>
{selectedId === o.id && (
<div
data-testid={`resize-handle-${o.id}`}
onMouseDown={(e) => onMouseDownResize(e, o)}
style={{ position: 'absolute', right: 0, bottom: 0, width: 10, height: 10, background: '#0ea5e9', cursor: 'nwse-resize' }}
/>
)}
{selectedId === o.id && (
<div
data-testid={`rotate-handle-${o.id}`}
onMouseDown={(e) => onMouseDownRotate(e, o)}
style={{ position: 'absolute', left: '50%', top: -16, width: 10, height: 10, marginLeft: -5, background: '#0ea5e9', borderRadius: 5, cursor: 'grab' }}
/>
)}
</div>
)))}
</div>
)
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect, vi } from 'vitest'
import { render, fireEvent } from '@testing-library/react'
import { LayersPanel } from '@/components/wysiwyg/LayersPanel'
import type { Frame } from '@/lib/wysiwyg/schema'
function makeFrame(): Frame {
return {
id: 'f1', width: 400, height: 300, background: '#ffffff',
layers: [
{ id: 'l1', name: 'Base', visible: true, locked: false, objects: [] },
{ id: 'l2', name: 'Text', visible: true, locked: false, objects: [] },
],
}
}
describe('LayersPanel', () => {
it('reorders layers with up/down buttons', () => {
const frame = makeFrame()
const onChange = vi.fn()
const { getByTestId, getAllByTestId } = render(<LayersPanel frame={frame} onChange={onChange} />)
const items = getAllByTestId('layer-item')
expect(items[0]).toHaveTextContent('Base')
expect(items[1]).toHaveTextContent('Text')
fireEvent.click(getByTestId('layer-down-l1'))
expect(onChange).toHaveBeenCalled()
const next = (onChange.mock.lastCall?.[0]) as Frame
expect(next.layers[0].id).toBe('l2')
expect(next.layers[1].id).toBe('l1')
})
it('toggles lock and visibility', () => {
const frame = makeFrame()
const { getByTestId } = render(<LayersPanel frame={frame} onChange={() => {}} />)
fireEvent.click(getByTestId('layer-lock-l2'))
fireEvent.click(getByTestId('layer-hide-l2'))
// no crash; states toggled reflected in aria-pressed
expect(getByTestId('layer-lock-l2')).toHaveAttribute('aria-pressed', 'true')
expect(getByTestId('layer-hide-l2')).toHaveAttribute('aria-pressed', 'true')
})
})
+65
View File
@@ -0,0 +1,65 @@
import React, { useCallback, useState } from 'react'
import type { Frame } from '@/lib/wysiwyg/schema'
export type LayersPanelProps = {
frame: Frame
onChange?: (next: Frame) => void
}
export function LayersPanel({ frame, onChange }: LayersPanelProps) {
const [model, setModel] = useState<Frame>(frame)
const move = useCallback((id: string, dir: 1 | -1) => {
const idx = model.layers.findIndex((l) => l.id === id)
if (idx < 0) return
const j = idx + dir
if (j < 0 || j >= model.layers.length) return
const nextLayers = model.layers.slice()
const [it] = nextLayers.splice(idx, 1)
nextLayers.splice(j, 0, it)
const next = { ...model, layers: nextLayers }
setModel(next)
onChange?.(next)
}, [model, onChange])
const toggle = useCallback((id: string, key: 'locked' | 'visible') => {
const nextLayers = model.layers.map((l) => l.id === id ? { ...l, [key]: key === 'visible' ? !l.visible : !l.locked } : l)
const next = { ...model, layers: nextLayers }
setModel(next)
onChange?.(next)
}, [model, onChange])
return (
<div aria-label="Layers Panel">
{model.layers.map((l) => (
<div key={l.id} data-testid="layer-item" style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span>{l.name}</span>
<button type="button" data-testid={`layer-up-${l.id}`} onClick={() => move(l.id, -1)} aria-label={`move up ${l.name}`}>
</button>
<button type="button" data-testid={`layer-down-${l.id}`} onClick={() => move(l.id, 1)} aria-label={`move down ${l.name}`}>
</button>
<button
type="button"
data-testid={`layer-lock-${l.id}`}
aria-pressed={l.locked ? 'true' : 'false'}
onClick={() => toggle(l.id, 'locked')}
aria-label={`lock ${l.name}`}
>
🔒
</button>
<button
type="button"
data-testid={`layer-hide-${l.id}`}
aria-pressed={l.visible ? 'false' : 'true'}
onClick={() => toggle(l.id, 'visible')}
aria-label={`toggle ${l.name} visibility`}
>
👁
</button>
</div>
))}
</div>
)
}
+32
View File
@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest'
import { render, fireEvent } from '@testing-library/react'
import { PropsPanel } from '@/components/wysiwyg/PropsPanel'
import type { WObject } from '@/lib/wysiwyg/schema'
describe('PropsPanel', () => {
it('edits position/size/rotate numeric fields', () => {
const obj: WObject = { id: 'o1', type: 'image', x: 10, y: 20, width: 100, height: 80, rotate: 0, zIndex: 0, props: { src: 'a', alt: '' } }
const calls: WObject[] = []
const { getByLabelText } = render(<PropsPanel object={obj} onChange={(n: WObject) => calls.push(n)} />)
fireEvent.change(getByLabelText('X'), { target: { value: '12' } })
fireEvent.change(getByLabelText('Y'), { target: { value: '30' } })
fireEvent.change(getByLabelText('Width'), { target: { value: '120' } })
fireEvent.change(getByLabelText('Height'), { target: { value: '88' } })
fireEvent.change(getByLabelText('Rotate'), { target: { value: '15' } })
const last = calls.at(-1)!
expect(last.x).toBe(12)
expect(last.y).toBe(30)
expect(last.width).toBe(120)
expect(last.height).toBe(88)
expect(last.rotate).toBe(15)
})
it('edits image src/alt', () => {
const obj: WObject = { id: 'o2', type: 'image', x: 0, y: 0, width: 10, height: 10, rotate: 0, zIndex: 0, props: { src: 'x', alt: 'a' } }
const calls: WObject[] = []
const { getByLabelText } = render(<PropsPanel object={obj} onChange={(n: WObject) => calls.push(n)} />)
fireEvent.change(getByLabelText('Src'), { target: { value: 'https://example.com/img.jpg' } })
fireEvent.change(getByLabelText('Alt'), { target: { value: 'Example' } })
expect(calls.at(-1)?.props).toEqual({ src: 'https://example.com/img.jpg', alt: 'Example' })
})
})
+75
View File
@@ -0,0 +1,75 @@
import React, { useState } from 'react'
import type { WObject } from '@/lib/wysiwyg/schema'
export type PropsPanelProps = {
object: WObject
onChange?: (next: WObject) => void
}
function num(v: string, fallback: number) {
const n = Number(v)
return Number.isFinite(n) ? n : fallback
}
function isImage(o: WObject): o is Extract<WObject, { type: 'image' }> {
return o.type === 'image'
}
export function PropsPanel({ object, onChange }: PropsPanelProps) {
const [model, setModel] = useState<WObject>(object)
const emit = (next: WObject) => {
setModel(next)
onChange?.(next)
}
const update = (patch: Partial<Pick<WObject, 'x' | 'y' | 'width' | 'height' | 'rotate'>>): void => {
const next: WObject = { ...model, ...patch }
emit(next)
}
const updateImageProps = (patch: Partial<{ src: string; alt?: string }>): void => {
if (!isImage(model)) return
const next: WObject = { ...model, props: { ...model.props, ...patch } }
emit(next)
}
return (
<div aria-label="Properties Panel">
<div>
<label>
X
<input aria-label="X" type="number" value={model.x} onChange={(e) => update({ x: num(e.target.value, model.x) })} />
</label>
<label>
Y
<input aria-label="Y" type="number" value={model.y} onChange={(e) => update({ y: num(e.target.value, model.y) })} />
</label>
<label>
Width
<input aria-label="Width" type="number" value={model.width} onChange={(e) => update({ width: num(e.target.value, model.width) })} />
</label>
<label>
Height
<input aria-label="Height" type="number" value={model.height} onChange={(e) => update({ height: num(e.target.value, model.height) })} />
</label>
<label>
Rotate
<input aria-label="Rotate" type="number" value={model.rotate} onChange={(e) => update({ rotate: num(e.target.value, model.rotate) })} />
</label>
</div>
{isImage(model) && (
<div>
<label>
Src
<input aria-label="Src" type="text" value={model.props.src} onChange={(e) => updateImageProps({ src: e.target.value })} />
</label>
<label>
Alt
<input aria-label="Alt" type="text" value={model.props.alt || ''} onChange={(e) => updateImageProps({ alt: e.target.value })} />
</label>
</div>
)}
</div>
)
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest'
import { AssetManager } from '@/lib/assets/assetManager'
function u8(n: number): Uint8Array { const a = new Uint8Array(n); for (let i=0;i<n;i++) a[i]=i&0xff; return a }
describe('AssetManager pathStrategy grouped', () => {
it('writes assets under assets/images and assets/fonts when grouped', async () => {
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','woff2'], pathStrategy: 'grouped' })
const img = await am.add({ name: 'hero.png', type: 'image/png', bytes: u8(10) })
const font = await am.add({ name: 'main.woff2', type: 'font/woff2', bytes: u8(12) })
expect(img.path).toMatch(/^assets\/images\/[0-9a-f]{8}\.png$/)
expect(font.path).toMatch(/^assets\/fonts\/[0-9a-f]{8}\.woff2$/)
const zip = am.toZipStructure()
const keys = Object.keys(zip)
expect(keys.some(k => /^assets\/images\//.test(k))).toBe(true)
expect(keys.some(k => /^assets\/fonts\//.test(k))).toBe(true)
})
it('rewriteUrl marks referenced originals and returns grouped path', async () => {
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png'], pathStrategy: 'grouped' })
const img = await am.add({ name: 'hero.png', type: 'image/png', bytes: u8(10) })
const rewritten = am.rewriteUrl('local:hero.png')
expect(rewritten).toBe(img.path)
expect(am.referencedList()).toContain('hero.png')
})
})
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest'
import { AssetManager } from '@/lib/assets/assetManager'
function buf(bytes: number[]): Uint8Array {
return new Uint8Array(bytes)
}
describe('AssetManager', () => {
it('업로드 이미지를 해시하여 assets/<hash>.<ext>로 관리하고 중복을 제거한다', async () => {
const am = new AssetManager({
maxBytes: 10 * 1024 * 1024,
allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'],
})
const a1 = await am.add({ name: 'hero.png', type: 'image/png', bytes: buf([1,2,3,4]) })
const a2 = await am.add({ name: 'another.png', type: 'image/png', bytes: buf([1,2,3,4]) })
const a3 = await am.add({ name: 'logo.svg', type: 'image/svg+xml', bytes: buf([60,115,118,103,62]) })
expect(a1.path).toMatch(/^assets\/[a-f0-9]{8}\.png$/)
expect(a2.path).toBe(a1.path) // 동일 바이트 -> 동일 해시 -> 중복 제거
expect(a3.path).toMatch(/^assets\/[a-f0-9]{8}\.svg$/)
const list = am.list()
expect(list.length).toBe(2)
const zipStruct = am.toZipStructure()
const keys = Object.keys(zipStruct)
expect(keys).toContain(a1.path)
expect(keys).toContain(a3.path)
expect(zipStruct[a1.path]).toBeInstanceOf(Uint8Array)
})
it('허용되지 않은 확장자/크기는 거절한다', async () => {
const am = new AssetManager({ maxBytes: 5, allowedExts: ['png'] })
await expect(
am.add({ name: 'bad.gif', type: 'image/gif', bytes: buf([1]) })
).rejects.toThrow(/extension/i)
await expect(
am.add({ name: 'big.png', type: 'image/png', bytes: buf([1,2,3,4,5,6]) })
).rejects.toThrow(/size/i)
})
it('rewriteUrl: http/https는 그대로, local:<name>은 assets 경로로 치환한다', async () => {
const am = new AssetManager({ maxBytes: 1024, allowedExts: ['png'] })
const external = am.rewriteUrl('https://cdn.example.com/x.png')
expect(external).toBe('https://cdn.example.com/x.png')
const ref = await am.add({ name: 'pic.png', type: 'image/png', bytes: buf([9,9,9]) })
const rewritten = am.rewriteUrl(`local:${ref.originalName}`)
expect(rewritten).toBe(ref.path)
})
})
+117
View File
@@ -0,0 +1,117 @@
export type AssetInput = {
name: string
type: string
bytes: Uint8Array
}
export type AssetRef = {
originalName: string
mimeType: string
bytes: Uint8Array
hash8: string
ext: string
path: string // assets/<hash8>.<ext>
}
export type AssetManagerOptions = {
maxBytes: number
allowedExts: string[] // e.g., ['png','jpg','jpeg','webp','svg']
pathStrategy?: 'flat' | 'grouped'
}
function extOf(name: string): string {
const i = name.lastIndexOf('.')
return i >= 0 ? name.slice(i + 1).toLowerCase() : ''
}
// Simple FNV-1a 32-bit hash -> 8-hex chars
function hash8(bytes: Uint8Array): string {
let h = 0x811c9dc5 >>> 0
for (let i = 0; i < bytes.length; i++) {
h ^= bytes[i]
h = Math.imul(h, 0x01000193) >>> 0
}
return ('00000000' + h.toString(16)).slice(-8)
}
export class AssetManager {
private readonly maxBytes: number
private readonly allowed: Set<string>
private readonly pathStrategy: 'flat' | 'grouped'
private byHash = new Map<string, AssetRef>()
private byOriginal = new Map<string, string>() // originalName -> path
private referenced = new Set<string>() // originalName referenced via rewrite
constructor(opts: AssetManagerOptions) {
this.maxBytes = opts.maxBytes
this.allowed = new Set(opts.allowedExts.map((e) => e.toLowerCase()))
this.pathStrategy = opts.pathStrategy ?? 'flat'
}
async add(file: AssetInput): Promise<AssetRef> {
const ext = extOf(file.name)
if (!this.allowed.has(ext)) {
throw new Error('Invalid extension')
}
if (file.bytes.length > this.maxBytes) {
throw new Error('File size exceeded')
}
const h = hash8(file.bytes)
const existing = this.byHash.get(h)
if (existing) {
// map original name for rewrite convenience
if (!this.byOriginal.has(file.name)) this.byOriginal.set(file.name, existing.path)
return existing
}
const path = this.makePath(h, ext, file.type)
const ref: AssetRef = {
originalName: file.name,
mimeType: file.type,
bytes: file.bytes,
hash8: h,
ext,
path,
}
this.byHash.set(h, ref)
if (!this.byOriginal.has(file.name)) this.byOriginal.set(file.name, path)
return ref
}
list(): AssetRef[] {
return Array.from(this.byHash.values())
}
toZipStructure(): Record<string, Uint8Array> {
const out: Record<string, Uint8Array> = {}
for (const ref of this.byHash.values()) {
out[ref.path] = ref.bytes
}
return out
}
rewriteUrl(urlOrLocalRef: string): string {
if (/^https?:\/\//i.test(urlOrLocalRef)) return urlOrLocalRef
if (urlOrLocalRef.startsWith('local:')) {
const key = urlOrLocalRef.slice('local:'.length)
const p = this.byOriginal.get(key)
if (p) this.referenced.add(key)
return p ?? urlOrLocalRef
}
return urlOrLocalRef
}
referencedList(): string[] {
return Array.from(this.referenced)
}
private makePath(hash8: string, ext: string, mime: string): string {
if (this.pathStrategy === 'grouped') {
let group = 'other'
if (mime.startsWith('image/')) group = 'images'
else if (mime.startsWith('font/')) group = 'fonts'
return `assets/${group}/${hash8}.${ext}`
}
return `assets/${hash8}.${ext}`
}
}
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
function makePage(): Page {
return pageSchema.parse({
title: 'T',
locale: 'en',
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
sections: [
{ type: 'hero', props: { heading: 'Welcome', subheading: '', imageUrl: 'https://example.com/hero.png' } },
{ type: 'faq', props: { items: [{ q: 'Q1', a: 'A1' }] } },
{ type: 'cta', props: { text: 'Go', href: '#' } },
] as Page['sections'],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
seo: { title: 'SEO', description: 'Desc' },
})
}
describe('Exporter a11y/structure - main contains sections and form', () => {
test('main landmark wraps sections and form markup', () => {
const out = exportPage(makePage())
expect(out.html).toContain('<main id="main" role="main">')
// sections inside main
expect(out.html).toMatch(/<main[\s\S]*<section class="hero"[\s\S]*<section class="faq"[\s\S]*<section class="cta"[\s\S]*<\/main>/)
// form inside main
expect(out.html).toMatch(/<main[\s\S]*<form method="post"[\s\S]*<\/main>/)
})
})
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
function makePage(overrides: Partial<Page> = {}): Page {
const base: Page = {
title: 'T',
locale: 'en',
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
sections: [
{ type: 'hero', props: { heading: 'Welcome', subheading: 'Sub', imageUrl: 'https://example.com/img.png' } },
{ type: 'faq', props: { items: [{ q: 'Q1', a: 'A1' }] } },
{ type: 'features', props: { items: ['One', 'Two'] } },
{ type: 'testimonials', props: { items: [{ author: 'Jane', quote: 'Great' }] } },
{ type: 'cta', props: { text: 'Go', href: '#' } },
] as Page['sections'],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
seo: { title: 'SEO', description: 'Desc' },
}
return pageSchema.parse({ ...base, ...overrides })
}
describe('Exporter a11y/responsive enhancements', () => {
test('includes skip link and main landmark', () => {
const out = exportPage(makePage())
expect(out.html).toContain('<a href="#main" class="skip-link">')
expect(out.html).toContain('<main id="main" role="main">')
})
test('sections use aria-labelledby with h2 ids', () => {
const out = exportPage(makePage())
expect(out.html).toContain('<section class="faq" aria-labelledby="faq-heading">')
expect(out.html).toContain('<h2 id="faq-heading">FAQ</h2>')
expect(out.html).toContain('<section class="features" aria-labelledby="features-heading">')
expect(out.html).toContain('<h2 id="features-heading">Features</h2>')
expect(out.html).toContain('<section class="testimonials" aria-labelledby="testimonials-heading">')
expect(out.html).toContain('<h2 id="testimonials-heading">Testimonials</h2>')
})
test('hero image alt derives from heading', () => {
const out = exportPage(makePage())
expect(out.html).toContain('<h1 id="hero-heading">Welcome</h1>')
expect(out.html).toMatch(/<img[^>]*src="https:\/\/example\.com\/img\.png"[^>]*alt="Welcome"[^>]*\/>/)
})
test('responsive and a11y CSS tokens exist', () => {
const out = exportPage(makePage())
expect(out.css).toMatch(/@media \(max-width: 640px\)/)
expect(out.css).toMatch(/@media \(prefers-reduced-motion: reduce\)/)
expect(out.css).toMatch(/a\.skip-link:focus/)
expect(out.css).toMatch(/outline: 3px solid/)
})
})
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest'
import { pageSchema, type Page } from '@/lib/schema/page'
import { exportPage } from '@/lib/exporter/html'
describe('Analytics snippet injection', () => {
const base = {
title: 'Analytics Test',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 'Analytics Test', description: 'desc' },
}
it('GA4 measurement ID가 있으면 GA4 스니펫을 삽입한다', () => {
const page: Page = pageSchema.parse({
...base,
analytics: { ga4MeasurementId: 'G-ABC123', metaPixelId: '' },
})
const out = exportPage(page)
expect(out.html).toContain('https://www.googletagmanager.com/gtag/js?id=G-ABC123')
expect(out.html).toContain("gtag('config', 'G-ABC123')")
})
it('Meta Pixel ID가 있으면 Pixel 스니펫을 삽입한다', () => {
const page: Page = pageSchema.parse({
...base,
analytics: { ga4MeasurementId: '', metaPixelId: '1234567890' },
})
const out = exportPage(page)
expect(out.html).toContain('fbq(')
expect(out.html).toContain("fbq('init', '1234567890')")
})
it('customHeadHtml이 있으면 head에 그대로 삽입한다', () => {
const page: Page = pageSchema.parse({
...base,
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '<meta name="robots" content="noindex">' },
})
const out = exportPage(page)
expect(out.html).toContain('<meta name="robots" content="noindex">')
})
})
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest'
import { AssetManager } from '@/lib/assets/assetManager'
import { buildAssetsIndex } from '@/lib/exporter/assets.index'
import { buildAssetsMetadata } from '@/lib/exporter/assets.meta'
function u8(n: number): Uint8Array { const a = new Uint8Array(n); for (let i=0;i<n;i++) a[i]=i&0xff; return a }
type IndexShape = { images: string[]; fonts: string[]; other: string[] }
describe('assets.index.json ↔ assets.json consistency', () => {
it.each([
{ mode: 'flat' as const },
{ mode: 'grouped' as const },
])('ensures index paths exist in assets.json zipPath with correct group ($mode)', async ({ mode }) => {
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','woff2','txt','jpg'], pathStrategy: mode })
await am.add({ name: 'hero.png', type: 'image/png', bytes: u8(10) })
await am.add({ name: 'thumb.jpg', type: 'image/jpeg', bytes: u8(11) })
await am.add({ name: 'main.woff2', type: 'font/woff2', bytes: u8(12) })
await am.add({ name: 'readme.txt', type: 'text/plain', bytes: u8(1) })
const indexBytes = buildAssetsIndex(am)
const metaBytes = buildAssetsMetadata(am)
const index = JSON.parse(new TextDecoder().decode(indexBytes)) as IndexShape
const meta = JSON.parse(new TextDecoder().decode(metaBytes)) as { assets: Array<{ zipPath: string; group: 'images'|'fonts'|'other' }> }
const metaByPath = new Map(meta.assets.map(a => [a.zipPath, a.group]))
for (const p of index.images) {
expect(metaByPath.has(p)).toBe(true)
expect(metaByPath.get(p)).toBe('images')
}
for (const p of index.fonts) {
expect(metaByPath.has(p)).toBe(true)
expect(metaByPath.get(p)).toBe('fonts')
}
for (const p of index.other) {
expect(metaByPath.has(p)).toBe(true)
expect(metaByPath.get(p)).toBe('other')
}
// Optional: ensure no extra paths exist in meta that are missing in index groups
const indexSet = new Set([...index.images, ...index.fonts, ...index.other])
for (const a of meta.assets) {
expect(indexSet.has(a.zipPath)).toBe(true)
}
})
})
+56
View File
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest'
import { AssetManager } from '@/lib/assets/assetManager'
import { buildAssetsIndex } from '@/lib/exporter/assets.index'
function u8(n: number): Uint8Array { const a = new Uint8Array(n); for (let i=0;i<n;i++) a[i]=i&0xff; return a }
describe('Assets index builder', () => {
it('groups assets by mime into images/fonts/other and lists zipPath', async () => {
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','woff2','txt'] })
await am.add({ name: 'a.png', type: 'image/png', bytes: u8(4) })
await am.add({ name: 'b.woff2', type: 'font/woff2', bytes: u8(6) })
await am.add({ name: 'c.txt', type: 'text/plain', bytes: u8(2) })
const bytes = buildAssetsIndex(am)
const obj = JSON.parse(new TextDecoder().decode(bytes)) as { images: string[]; fonts: string[]; other: string[] }
expect(obj.images.length).toBe(1)
expect(obj.fonts.length).toBe(1)
expect(obj.other.length).toBe(1)
expect(obj.images[0]).toMatch(/^assets\/[0-9a-f]{8}\.png$/)
expect(obj.fonts[0]).toMatch(/^assets\/[0-9a-f]{8}\.woff2$/)
expect(obj.other[0]).toMatch(/^assets\/[0-9a-f]{8}\.txt$/)
})
it('sorts each group array by path (ascending)', async () => {
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','txt','woff2'] })
// Add in shuffled order so that sort is required
await am.add({ name: 'z.png', type: 'image/png', bytes: u8(3) })
await am.add({ name: 'a.png', type: 'image/png', bytes: u8(5) })
await am.add({ name: 'm.txt', type: 'text/plain', bytes: u8(2) })
await am.add({ name: 'b.txt', type: 'text/plain', bytes: u8(4) })
await am.add({ name: 'f.woff2', type: 'font/woff2', bytes: u8(6) })
await am.add({ name: 'e.woff2', type: 'font/woff2', bytes: u8(7) })
const bytes = buildAssetsIndex(am)
const obj = JSON.parse(new TextDecoder().decode(bytes)) as { images: string[]; fonts: string[]; other: string[] }
const imgs = [...obj.images]
const fonts = [...obj.fonts]
const others = [...obj.other]
const sortedImgs = [...imgs].sort()
const sortedFonts = [...fonts].sort()
const sortedOthers = [...others].sort()
expect(imgs).toEqual(sortedImgs)
expect(fonts).toEqual(sortedFonts)
expect(others).toEqual(sortedOthers)
})
it('respects grouped pathStrategy and outputs grouped paths in index', async () => {
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','woff2','txt'], pathStrategy: 'grouped' })
await am.add({ name: 'hero.png', type: 'image/png', bytes: u8(10) })
await am.add({ name: 'main.woff2', type: 'font/woff2', bytes: u8(12) })
await am.add({ name: 'readme.txt', type: 'text/plain', bytes: u8(1) })
const bytes = buildAssetsIndex(am)
const obj = JSON.parse(new TextDecoder().decode(bytes)) as { images: string[]; fonts: string[]; other: string[] }
expect(obj.images[0]).toMatch(/^assets\/images\/[0-9a-f]{8}\.png$/)
expect(obj.fonts[0]).toMatch(/^assets\/fonts\/[0-9a-f]{8}\.woff2$/)
expect(obj.other[0]).toMatch(/^assets\/other\/[0-9a-f]{8}\.txt$/)
})
})
+26
View File
@@ -0,0 +1,26 @@
import type { AssetManager } from '@/lib/assets/assetManager'
export type AssetsIndex = {
images: string[]
fonts: string[]
other: string[]
}
export function buildAssetsIndex(am: AssetManager): Uint8Array {
// 그룹별 경로 수집
const images: string[] = []
const fonts: string[] = []
const other: string[] = []
for (const ref of am.list()) {
if (ref.mimeType.startsWith('image/')) images.push(ref.path)
else if (ref.mimeType.startsWith('font/')) fonts.push(ref.path)
else other.push(ref.path)
}
// 경로 사전순 정렬로 일정한 출력 보장
images.sort()
fonts.sort()
other.sort()
const obj: AssetsIndex = { images, fonts, other }
const json = JSON.stringify(obj, null, 2)
return new TextEncoder().encode(json)
}
@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest'
import { AssetManager } from '@/lib/assets/assetManager'
import { buildAssetsIntegrityIndex } from '@/lib/exporter/assets.integrity.index'
import { buildAssetsMetadata } from '@/lib/exporter/assets.meta'
function u8(n: number): Uint8Array { const a = new Uint8Array(n); for (let i=0;i<n;i++) a[i]=i&0xff; return a }
describe('assets.integrity.index.json', () => {
it('emits path->integrity mapping consistent with assets.json and deterministically across modes and insertion order', async () => {
for (const mode of ['flat','grouped'] as const) {
// case1: insertion order A,B,C
const am1 = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','woff2','txt'], pathStrategy: mode })
await am1.add({ name: 'a.png', type: 'image/png', bytes: u8(3) })
await am1.add({ name: 'b.woff2', type: 'font/woff2', bytes: u8(4) })
await am1.add({ name: 'c.txt', type: 'text/plain', bytes: u8(5) })
const idx1 = JSON.parse(new TextDecoder().decode(buildAssetsIntegrityIndex(am1))) as { entries: Array<{path:string; integrity:string}>; bundleHash: string }
const meta1 = JSON.parse(new TextDecoder().decode(buildAssetsMetadata(am1))) as { assets: Array<{ zipPath: string; integrity: string }> }
const metaMap1 = new Map(meta1.assets.map(a => [a.zipPath, a.integrity]))
const paths1 = idx1.entries.map(e => e.path)
const sorted1 = [...paths1].sort()
expect(paths1).toEqual(sorted1)
for (const e of idx1.entries) expect(metaMap1.get(e.path)).toBe(e.integrity)
// case2: insertion order shuffled C,A,B -> determinism of output
const am2 = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','woff2','txt'], pathStrategy: mode })
await am2.add({ name: 'c.txt', type: 'text/plain', bytes: u8(5) })
await am2.add({ name: 'a.png', type: 'image/png', bytes: u8(3) })
await am2.add({ name: 'b.woff2', type: 'font/woff2', bytes: u8(4) })
const idx2 = JSON.parse(new TextDecoder().decode(buildAssetsIntegrityIndex(am2))) as { entries: Array<{path:string; integrity:string}>; bundleHash: string }
// same entries set and order (sorted by path) -> equality
expect(idx2.entries.map(e => e.path)).toEqual(idx1.entries.map(e => e.path))
expect(idx2.entries.map(e => e.integrity)).toEqual(idx1.entries.map(e => e.integrity))
// bundle hash depends on entries -> equal as well
expect(idx2.bundleHash).toBe(idx1.bundleHash)
}
})
})
+99
View File
@@ -0,0 +1,99 @@
import type { AssetManager } from '@/lib/assets/assetManager'
export type AssetsIntegrityIndex = {
entries: Array<{ path: string; integrity: string; size: number }>
bundleHash: string
}
// Minimal synchronous SHA-256 implementation returning base64 string.
function sha256Base64(bytes: Uint8Array): string {
const K = new Uint32Array([
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
])
const H = new Uint32Array([
0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19
])
const l = bytes.length
const bitLenHi = Math.floor((l >>> 29))
const bitLenLo = (l << 3) >>> 0
const withOne = l + 1
const padLen = ((withOne % 64) <= 56) ? (56 - (withOne % 64)) : (56 + 64 - (withOne % 64))
const totalLen = withOne + padLen + 8
const m = new Uint8Array(totalLen)
m.set(bytes)
m[l] = 0x80
m[totalLen - 8] = (bitLenHi >>> 24) & 0xff
m[totalLen - 7] = (bitLenHi >>> 16) & 0xff
m[totalLen - 6] = (bitLenHi >>> 8) & 0xff
m[totalLen - 5] = (bitLenHi >>> 0) & 0xff
m[totalLen - 4] = (bitLenLo >>> 24) & 0xff
m[totalLen - 3] = (bitLenLo >>> 16) & 0xff
m[totalLen - 2] = (bitLenLo >>> 8) & 0xff
m[totalLen - 1] = (bitLenLo >>> 0) & 0xff
const W = new Uint32Array(64)
for (let i = 0; i < totalLen; i += 64) {
for (let t = 0; t < 16; t++) {
const j = i + t * 4
W[t] = (m[j] << 24) | (m[j + 1] << 16) | (m[j + 2] << 8) | (m[j + 3])
}
for (let t = 16; t < 64; t++) {
const s0 = (rotr(W[t - 15], 7) ^ rotr(W[t - 15], 18) ^ (W[t - 15] >>> 3)) >>> 0
const s1 = (rotr(W[t - 2], 17) ^ rotr(W[t - 2], 19) ^ (W[t - 2] >>> 10)) >>> 0
W[t] = (W[t - 16] + s0 + W[t - 7] + s1) >>> 0
}
let a = H[0], b = H[1], c = H[2], d = H[3], e = H[4], f = H[5], g = H[6], h = H[7]
for (let t = 0; t < 64; t++) {
const S1 = (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) >>> 0
const ch = ((e & f) ^ (~e & g)) >>> 0
const temp1 = (h + S1 + ch + K[t] + W[t]) >>> 0
const S0 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) >>> 0
const maj = ((a & b) ^ (a & c) ^ (b & c)) >>> 0
const temp2 = (S0 + maj) >>> 0
h = g; g = f; f = e; e = (d + temp1) >>> 0; d = c; c = b; b = a; a = (temp1 + temp2) >>> 0
}
H[0] = (H[0] + a) >>> 0
H[1] = (H[1] + b) >>> 0
H[2] = (H[2] + c) >>> 0
H[3] = (H[3] + d) >>> 0
H[4] = (H[4] + e) >>> 0
H[5] = (H[5] + f) >>> 0
H[6] = (H[6] + g) >>> 0
H[7] = (H[7] + h) >>> 0
}
const out = new Uint8Array(32)
for (let i = 0; i < 8; i++) {
out[i*4+0]=(H[i]>>>24)&0xff; out[i*4+1]=(H[i]>>>16)&0xff; out[i*4+2]=(H[i]>>>8)&0xff; out[i*4+3]=(H[i]>>>0)&0xff
}
if (typeof Buffer !== 'undefined') return Buffer.from(out).toString('base64')
let s = ''; for (let i=0;i<out.length;i++) s += String.fromCharCode(out[i])
const btoaFn = (globalThis as unknown as { btoa?: (s: string) => string }).btoa
if (!btoaFn) throw new Error('btoa not available')
return btoaFn(s)
function rotr(x: number, n: number) { return (x >>> n) | (x << (32 - n)) }
}
export function buildAssetsIntegrityIndex(am: AssetManager): Uint8Array {
// 1) Collect entries: path + sha256 integrity
const entries = am.list().map(ref => ({
path: ref.path,
integrity: `sha256-${sha256Base64(ref.bytes)}`,
size: ref.bytes.length,
}))
// 2) Sort entries by path for deterministic output
entries.sort((a, b) => a.path.localeCompare(b.path))
// 3) Compute bundleHash over sorted lines: path + integrity joined by \n
const lines: string[] = []
for (const e of entries) { lines.push(e.path); lines.push(e.integrity) }
const joined = new TextEncoder().encode(lines.join('\n'))
const bundleHash = `sha256-${sha256Base64(joined)}`
const obj: AssetsIntegrityIndex = { entries, bundleHash }
const json = JSON.stringify(obj, null, 2)
return new TextEncoder().encode(json)
}
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest'
import { AssetManager } from '@/lib/assets/assetManager'
import { buildAssetsMetadata, type AssetMetaItem } from '@/lib/exporter/assets.meta'
import crypto from 'node:crypto'
function u8pattern(n: number): Uint8Array {
const arr = new Uint8Array(n)
for (let i = 0; i < n; i++) arr[i] = (i * 131 + 7) & 0xff
return arr
}
function sha256B64(bytes: Uint8Array): string {
const h = crypto.createHash('sha256')
h.update(Buffer.from(bytes))
return h.digest('base64')
}
describe('assets.json integrity = real SHA-256(base64)', () => {
it('integrity equals sha256-<base64(sha256(bytes))>', async () => {
const am = new AssetManager({ maxBytes: 1024 * 1024, allowedExts: ['png'] })
const bytes = u8pattern(64)
await am.add({ name: 'hero.png', type: 'image/png', bytes })
const out = buildAssetsMetadata(am)
const json = new TextDecoder().decode(out)
const obj = JSON.parse(json) as { assets: AssetMetaItem[] }
expect(obj.assets.length).toBe(1)
const a = obj.assets[0]
const expected = 'sha256-' + sha256B64(bytes)
expect(a.integrity).toBe(expected)
})
})
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest'
import { AssetManager } from '@/lib/assets/assetManager'
import { buildAssetsMetadata } from '@/lib/exporter/assets.meta'
import type { AssetMetaItem } from '@/lib/exporter/assets.meta'
function u8(n: number): Uint8Array {
const arr = new Uint8Array(n)
for (let i = 0; i < n; i++) arr[i] = (i * 31) & 0xff
return arr
}
describe('Assets metadata builder', () => {
it('emits assets.json with originalName/mime/size/hash/ext/zipPath', async () => {
const am = new AssetManager({ maxBytes: 1024 * 1024, allowedExts: ['png'] })
const bytes = u8(10)
await am.add({ name: 'hero.png', type: 'image/png', bytes })
const metaBytes = buildAssetsMetadata(am)
const json = new TextDecoder().decode(metaBytes)
const obj = JSON.parse(json) as { assets: AssetMetaItem[] }
expect(Array.isArray(obj.assets)).toBe(true)
expect(obj.assets.length).toBe(1)
const a = obj.assets[0]
expect(a.originalName).toBe('hero.png')
expect(a.mimeType).toBe('image/png')
expect(a.size).toBe(bytes.byteLength)
expect(a.ext).toBe('png')
expect(a.hash8).toMatch(/^[0-9a-f]{8}$/)
expect(a.zipPath).toMatch(/^assets\/[0-9a-f]{8}\.png$/)
})
})
+135
View File
@@ -0,0 +1,135 @@
import type { AssetManager } from '@/lib/assets/assetManager'
export type AssetMetaItem = {
originalName: string
mimeType: string
size: number
hash8: string
ext: string
zipPath: string
integrity: string
group: 'images' | 'fonts' | 'other'
referencedAt: string[]
}
// Minimal synchronous SHA-256 implementation returning base64 string.
// Derived from the SHA-256 specification. Suitable for small payloads (asset bytes).
function sha256Base64(bytes: Uint8Array): string {
const K = new Uint32Array([
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
])
const H = new Uint32Array([
0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19
])
const l = bytes.length
const bitLenHi = Math.floor((l >>> 29))
const bitLenLo = (l << 3) >>> 0
// Pre-processing (padding)
const withOne = l + 1
// pad to congruent 56 mod 64
const padLen = ((withOne % 64) <= 56) ? (56 - (withOne % 64)) : (56 + 64 - (withOne % 64))
const totalLen = withOne + padLen + 8
const m = new Uint8Array(totalLen)
m.set(bytes)
m[l] = 0x80
// append 64-bit big-endian length
m[totalLen - 8] = (bitLenHi >>> 24) & 0xff
m[totalLen - 7] = (bitLenHi >>> 16) & 0xff
m[totalLen - 6] = (bitLenHi >>> 8) & 0xff
m[totalLen - 5] = (bitLenHi >>> 0) & 0xff
m[totalLen - 4] = (bitLenLo >>> 24) & 0xff
m[totalLen - 3] = (bitLenLo >>> 16) & 0xff
m[totalLen - 2] = (bitLenLo >>> 8) & 0xff
m[totalLen - 1] = (bitLenLo >>> 0) & 0xff
const W = new Uint32Array(64)
for (let i = 0; i < totalLen; i += 64) {
for (let t = 0; t < 16; t++) {
const j = i + t * 4
W[t] = (m[j] << 24) | (m[j + 1] << 16) | (m[j + 2] << 8) | (m[j + 3])
}
for (let t = 16; t < 64; t++) {
const s0 = (rotr(W[t - 15], 7) ^ rotr(W[t - 15], 18) ^ (W[t - 15] >>> 3)) >>> 0
const s1 = (rotr(W[t - 2], 17) ^ rotr(W[t - 2], 19) ^ (W[t - 2] >>> 10)) >>> 0
W[t] = (W[t - 16] + s0 + W[t - 7] + s1) >>> 0
}
let a = H[0], b = H[1], c = H[2], d = H[3], e = H[4], f = H[5], g = H[6], h = H[7]
for (let t = 0; t < 64; t++) {
const S1 = (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) >>> 0
const ch = ((e & f) ^ (~e & g)) >>> 0
const temp1 = (h + S1 + ch + K[t] + W[t]) >>> 0
const S0 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) >>> 0
const maj = ((a & b) ^ (a & c) ^ (b & c)) >>> 0
const temp2 = (S0 + maj) >>> 0
h = g
g = f
f = e
e = (d + temp1) >>> 0
d = c
c = b
b = a
a = (temp1 + temp2) >>> 0
}
H[0] = (H[0] + a) >>> 0
H[1] = (H[1] + b) >>> 0
H[2] = (H[2] + c) >>> 0
H[3] = (H[3] + d) >>> 0
H[4] = (H[4] + e) >>> 0
H[5] = (H[5] + f) >>> 0
H[6] = (H[6] + g) >>> 0
H[7] = (H[7] + h) >>> 0
}
const out = new Uint8Array(32)
for (let i = 0; i < 8; i++) {
out[i * 4 + 0] = (H[i] >>> 24) & 0xff
out[i * 4 + 1] = (H[i] >>> 16) & 0xff
out[i * 4 + 2] = (H[i] >>> 8) & 0xff
out[i * 4 + 3] = (H[i] >>> 0) & 0xff
}
// base64 encode
let b64 = ''
if (typeof Buffer !== 'undefined') {
b64 = Buffer.from(out).toString('base64')
} else {
let s = ''
for (let i = 0; i < out.length; i++) s += String.fromCharCode(out[i])
// btoa is available in browsers
b64 = btoa(s)
}
return b64
function rotr(x: number, n: number) { return (x >>> n) | (x << (32 - n)) }
}
export function buildAssetsMetadata(am: AssetManager): Uint8Array {
const list = am.list()
const referenced = new Set(am.referencedList())
const items: AssetMetaItem[] = list.map((ref) => {
// Compute real SHA-256(base64) integrity synchronously for browser/Node
const b64 = sha256Base64(ref.bytes)
const group: 'images' | 'fonts' | 'other' = ref.mimeType.startsWith('image/')
? 'images'
: ref.mimeType.startsWith('font/')
? 'fonts'
: 'other'
const referencedAt = referenced.has(ref.originalName) ? [`local:${ref.originalName}`] : []
return {
originalName: ref.originalName,
mimeType: ref.mimeType,
size: ref.bytes.byteLength,
hash8: ref.hash8,
ext: ref.ext,
zipPath: ref.path,
integrity: `sha256-${b64}`,
group,
referencedAt,
}
})
const json = JSON.stringify({ assets: items }, null, 2)
return new TextEncoder().encode(json)
}
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { exportPage } from '@/lib/exporter/html'
import { pageSchema, type Page } from '@/lib/schema/page'
import { AssetManager } from '@/lib/assets/assetManager'
function makePage(): Page {
return pageSchema.parse({
title: 'Path Strategy',
locale: 'en',
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
sections: [ { type: 'hero', props: { heading: 'Hello', subheading: 'World', imageUrl: 'local:hero.png' } } ],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 't', description: 'd' },
})
}
describe('자산 경로 구조 옵션', () => {
it('grouped 전략일 때 이미지가 assets/images/ 경로로 렌더된다', async () => {
const page = makePage()
const am = new AssetManager({ maxBytes: 1024 * 1024, allowedExts: ['png'], pathStrategy: 'grouped' })
// add hero image asset
const bytes = new Uint8Array([1,2,3,4,5])
await am.add({ name: 'hero.png', type: 'image/png', bytes })
const out = exportPage(page, { assetManager: am })
expect(out.html).toMatch(/assets\/images\/[a-f0-9]{8}\.png/i)
})
})
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest'
import { AssetManager } from '@/lib/assets/assetManager'
import { buildAssetsMetadata, type AssetMetaItem } from '@/lib/exporter/assets.meta'
function u8(n: number): Uint8Array { const a = new Uint8Array(n); for (let i=0;i<n;i++) a[i]=i&0xff; return a }
describe('referencedAt matrix across modes and multiple assets', () => {
it.each([
{ mode: 'flat' as const },
{ mode: 'grouped' as const },
])('tracks only referenced originals once per asset ($mode)', async ({ mode }) => {
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','woff2'], pathStrategy: mode })
await am.add({ name: 'hero.png', type: 'image/png', bytes: u8(10) })
await am.add({ name: 'logo.png', type: 'image/png', bytes: u8(12) })
await am.add({ name: 'main.woff2', type: 'font/woff2', bytes: u8(14) })
// reference some assets multiple times; leave logo.png unreferenced
am.rewriteUrl('local:hero.png')
am.rewriteUrl('local:hero.png')
am.rewriteUrl('local:main.woff2')
const metaBytes = buildAssetsMetadata(am)
const obj = JSON.parse(new TextDecoder().decode(metaBytes)) as { assets: AssetMetaItem[] }
const byName = new Map(obj.assets.map(a => [a.originalName, a]))
const hero = byName.get('hero.png')!
const logo = byName.get('logo.png')!
const font = byName.get('main.woff2')!
expect(Array.isArray(hero.referencedAt)).toBe(true)
expect(hero.referencedAt).toContain('local:hero.png')
expect(hero.referencedAt.length).toBe(1)
expect(Array.isArray(font.referencedAt)).toBe(true)
expect(font.referencedAt).toContain('local:main.woff2')
expect(font.referencedAt.length).toBe(1)
expect(Array.isArray(logo.referencedAt)).toBe(true)
expect(logo.referencedAt.length).toBe(0)
})
})
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest'
import { AssetManager } from '@/lib/assets/assetManager'
import { buildAssetsMetadata, type AssetMetaItem } from '@/lib/exporter/assets.meta'
function u8(n: number): Uint8Array {
const arr = new Uint8Array(n)
for (let i = 0; i < n; i++) arr[i] = (i * 17) & 0xff
return arr
}
describe('referencedAt is populated when rewriteUrl is used', () => {
it('marks originalName as referenced via local:<name>', async () => {
const am = new AssetManager({ maxBytes: 1024 * 1024, allowedExts: ['png'] })
await am.add({ name: 'hero.png', type: 'image/png', bytes: u8(16) })
// use rewriteUrl to simulate page referencing the local asset
const rewritten = am.rewriteUrl('local:hero.png')
expect(rewritten).toMatch(/assets\//)
const metaBytes = buildAssetsMetadata(am)
const obj = JSON.parse(new TextDecoder().decode(metaBytes)) as { assets: AssetMetaItem[] }
expect(obj.assets.length).toBe(1)
const a = obj.assets[0]
expect(Array.isArray(a.referencedAt)).toBe(true)
expect(a.referencedAt).toContain('local:hero.png')
})
})
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
const makePage = (): Page =>
pageSchema.parse({
title: 'T',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 'T', description: 'D' },
})
describe('Exporter CSS contrast/font tokens', () => {
test('contains base text color and responsive font adjustments', () => {
const out = exportPage(makePage())
// base body text color token present
expect(out.css).toMatch(/body\{[^}]*color:\s*#0f172a/i)
// focus outline thickness and offset present
expect(out.css).toMatch(/outline:\s*3px\s*solid/i)
expect(out.css).toMatch(/outline-offset:\s*2px/i)
// responsive font size change for h1 in mobile media query
expect(out.css).toMatch(/@media \(max-width: 640px\)[\s\S]*h1\{[^}]*font-size:\s*1\.5rem/i)
})
})
+37
View File
@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest'
import { exportPage } from '@/lib/exporter/html'
import { pageSchema, type Page } from '@/lib/schema/page'
function makePage(): Page {
return pageSchema.parse({
title: 'CSS Tokens Expand',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 'SEO', description: 'Desc' },
})
}
describe('Exporter CSS tokens - expanded regression', () => {
it('includes root var, skip-link base/focus, focus outline, reduced-motion, body font', () => {
const out = exportPage(makePage())
const css = out.css
// :root primary color var
expect(css).toMatch(/:root\{\s*--primary:\s*#0ea5e9\s*;\s*\}/)
// body font family includes configured font first
expect(css).toMatch(/body\{[^}]*font-family:\s*Inter,\s*system-ui[\s\S]*\}/)
// skip-link base and focus tokens
expect(css).toMatch(/a\.skip-link\{[\s\S]*left:-9999px[\s\S]*\}/)
expect(css).toMatch(/a\.skip-link:focus\{[\s\S]*left:1rem[\s\S]*background:[^;]+;[\s\S]*\}/)
// focus-visible/outline tokens (any focusable)
expect(css).toMatch(/button:focus, a:focus, input:focus, select:focus, textarea:focus\{[\s\S]*outline:\s*3px solid\s*#2563eb[\s\S]*outline-offset:\s*2px[\s\S]*\}/)
// reduced-motion detailed rule
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)\{\s*\*\{\s*transition:\s*none !important;\s*animation:\s*none !important\s*\}\s*\}/)
})
})
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
function makePage(): Page {
return pageSchema.parse({
title: 'T',
locale: 'en',
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
sections: [],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
seo: { title: 'SEO', description: 'Desc' },
})
}
describe('Exporter CSS tokens', () => {
test('includes focus outline, skip-link, and responsive MQ tokens', () => {
const out = exportPage(makePage())
expect(out.css).toMatch(/a\.skip-link:focus/)
expect(out.css).toMatch(/focus-visible|outline: 3px solid|outline-offset/)
expect(out.css).toMatch(/@media \(max-width: 640px\)/)
expect(out.css).toMatch(/@media \(prefers-reduced-motion: reduce\)/)
})
})
+32
View File
@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest'
import { exportPage } from '@/lib/exporter/html'
import { pageSchema, type Page } from '@/lib/schema/page'
function makePage(overrides: Partial<Page> = {}): Page {
const base: Page = {
title: 'CTA Edge',
locale: 'en',
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
sections: [
{ type: 'cta', props: { text: ' ', href: '#' } },
{ type: 'features', props: { items: ['One'] } },
] as Page['sections'],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
seo: { title: 't', description: 'd' },
}
return pageSchema.parse({ ...base, ...overrides })
}
describe('Exporter - CTA edge cases', () => {
it('drops CTA section when text is whitespace-only', () => {
const out = exportPage(makePage())
expect(out.html).not.toContain('<section class="cta"')
expect(out.html).not.toContain('<a class="btn-cta"')
})
it('renders CTA when text is valid after trimming', () => {
const out = exportPage(makePage({ sections: [ { type: 'cta', props: { text: ' Buy Now ', href: '#' } } ] as Page['sections'] }))
expect(out.html).toContain('<a class="btn-cta" href="#">Buy Now</a>')
})
})
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest'
import { exportPage } from '@/lib/exporter/html'
import { pageSchema, type Page } from '@/lib/schema/page'
function makePage(): Page {
return pageSchema.parse({
title: 'Dark Contrast Hover',
locale: 'en',
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
sections: [
{ type: 'hero', props: { heading: 'Welcome', subheading: 'Sub', imageUrl: 'https://img.example/h.png' } },
{ type: 'cta', props: { text: 'Call', href: '#' } },
],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 't', description: 'd' },
})
}
describe('Exporter - dark mode hover/cta tokens', () => {
it('adds link hover underline and ensures btn-cta readable in dark', () => {
const out = exportPage(makePage())
const css = out.css
// underline on hover for links in dark for clearer affordance
expect(css).toMatch(/@media \(prefers-color-scheme: dark\)[\s\S]*a:hover\{[^}]*text-decoration:underline/i)
// btn-cta has readable foreground/background in dark
expect(css).toMatch(/@media \(prefers-color-scheme: dark\)[\s\S]*\.btn-cta\{[^}]*background:[^;]+;[^}]*color:#fff/i)
})
})
+37
View File
@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest'
import { exportPage } from '@/lib/exporter/html'
import { pageSchema, type Page } from '@/lib/schema/page'
function makePage(): Page {
return pageSchema.parse({
title: 'Dark Contrast',
locale: 'en',
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
sections: [
{ type: 'hero', props: { heading: 'Welcome', subheading: 'Sub', imageUrl: 'https://img.example/h.png' } },
{ type: 'faq', props: { items: [{ q: 'Q', a: 'A' }] } },
{ type: 'cta', props: { text: 'Go', href: '#' } },
],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 't', description: 'd' },
})
}
describe('Exporter - dark mode contrast tokens', () => {
it('includes dark mode tokens that improve contrast for links/buttons/faq text', () => {
const out = exportPage(makePage())
const css = out.css
// dark mode block exists
expect(css).toMatch(/@media \(prefers-color-scheme: dark\)/)
// link color token in dark
expect(css).toMatch(/@media \(prefers-color-scheme: dark\)[\s\S]*a\{[^}]*color:#93c5fd/i)
// button keeps readable foreground/background in dark
expect(css).toMatch(/@media \(prefers-color-scheme: dark\)[\s\S]*button\{[^}]*background:[^;]+;[^}]*color:#fff/i)
// faq answer text brighter in dark
expect(css).toMatch(/@media \(prefers-color-scheme: dark\)[\s\S]*\.faq-a\{[^}]*color:#cbd5e1/i)
})
})
+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest'
import { buildExtrasForPage } from '@/lib/exporter/extras'
import { pageSchema, type Page } from '@/lib/schema/page'
function makePage(): Page {
return pageSchema.parse({
title: 'My Product',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [ { type: 'hero', props: { heading: 'Hello', subheading: '', imageUrl: 'https://example.com/hero.png' } } ],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 't', description: 'd' },
})
}
function toStr(bytes: Uint8Array) {
return new TextDecoder().decode(bytes)
}
describe('extras 자동 보강', () => {
it('manifest: 잘못된 theme_color는 무시하고 기본(primaryColor) 사용', () => {
const page = makePage()
const out = buildExtrasForPage(page, { manifest: { theme_color: 'not-a-hex' } })
const manifest = JSON.parse(toStr(out['site.webmanifest']))
expect(manifest.theme_color).toBe('#0ea5e9')
})
it('manifest: short_name은 12자 이내로 자동 절단', () => {
const page = makePage()
const out = buildExtrasForPage(page, { manifest: { name: 'Super Long Product Name' } })
const manifest = JSON.parse(toStr(out['site.webmanifest']))
expect(manifest.short_name.length).toBeLessThanOrEqual(12)
})
it('robots: 주어진 plain 경로 목록을 자동으로 User-agent 헤더와 Disallow 라인으로 보강', () => {
const page = makePage()
const robotsPlain = "/admin\n/private"
const out = buildExtrasForPage(page, { robotsText: robotsPlain })
const robots = toStr(out['robots.txt'])
expect(robots).toMatch(/^User-agent: \*/)
expect(robots).toMatch(/Disallow: \/admin/)
expect(robots).toMatch(/Disallow: \/private/)
})
})
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest'
import { pageSchema, type Page } from '@/lib/schema/page'
import { buildExtrasForPage } from '@/lib/exporter/extras'
function makePage(): Page {
return pageSchema.parse({
title: 'Overrides',
locale: 'en',
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
sections: [ { type: 'hero', props: { heading: 'Hello', subheading: 'World', imageUrl: 'https://img/h.png' } } ],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 't', description: 'd' },
})
}
describe('extras 오버라이드', () => {
it('robots.txt Disallow 오버라이드가 반영된다', () => {
const page = makePage()
const extras = buildExtrasForPage(page, { robotsText: 'User-agent: *\nDisallow: /admin\nDisallow: /private' })
const robots = Buffer.from(extras['robots.txt']).toString('utf8')
expect(robots).toContain('User-agent: *')
expect(robots).toContain('Disallow: /admin')
expect(robots).toContain('Disallow: /private')
})
it('manifest 필드 오버라이드(name/short_name/start_url/display/theme_color)가 반영된다', () => {
const page = makePage()
const extras = buildExtrasForPage(page, {
manifest: {
name: 'My App',
short_name: 'MyApp',
start_url: '/start',
display: 'minimal-ui',
theme_color: '#123abc',
},
})
const manifestStr = Buffer.from(extras['site.webmanifest']).toString('utf8')
const manifest = JSON.parse(manifestStr)
expect(manifest.name).toBe('My App')
expect(manifest.short_name).toBe('MyApp')
expect(manifest.start_url).toBe('/start')
expect(manifest.display).toBe('minimal-ui')
expect(manifest.theme_color).toBe('#123abc')
})
})
+95
View File
@@ -0,0 +1,95 @@
import type { Page } from '@/lib/schema/page'
export type ExtrasOverrides = {
robotsText?: string
manifest?: Partial<{
name: string
short_name: string
start_url: string
display: string
theme_color: string
}>
sheetsMode?: 'auto' | 'include' | 'exclude'
}
function toBytes(s: string): Uint8Array {
return new TextEncoder().encode(s)
}
function buildRobots(base?: string): string {
if (base && base.trim().length > 0) {
const s = base.trim()
if (/^User-agent:/i.test(s)) return s
const lines = s.split(/\n+/).map((l) => l.trim()).filter((l) => l.length > 0)
if (lines.length === 0) return 'User-agent: *\n'
const disallowLines = lines.map((l) => (l.startsWith('Disallow:') ? l : `Disallow: ${l}`))
return `User-agent: *\n${disallowLines.join('\n')}`
}
return 'User-agent: *\n'
}
function deriveManifest(page: Page) {
const name = page.title || 'Landing'
const short_name = name.length > 12 ? name.slice(0, 12) : name
const start_url = '/'
const display = 'standalone'
const theme_color = page.theme?.primaryColor || '#0ea5e9'
return { name, short_name, start_url, display, theme_color }
}
export function buildExtrasForPage(page: Page, overrides?: ExtrasOverrides): Record<string, Uint8Array> {
const out: Record<string, Uint8Array> = {}
// robots.txt
const robotsText = buildRobots(overrides?.robotsText)
out['robots.txt'] = toBytes(robotsText)
// site.webmanifest (auto-enrich and safe-merge)
const manifestBase = deriveManifest(page)
const ov = overrides?.manifest || {}
const name = ov.name && ov.name.trim().length > 0 ? ov.name : manifestBase.name
const short_name = ov.short_name && ov.short_name.trim().length > 0
? ov.short_name
: (name.length > 12 ? name.slice(0, 12) : name)
const start_url = ov.start_url && ov.start_url.trim().length > 0 ? ov.start_url : manifestBase.start_url
const display = ov.display && ov.display.trim().length > 0 ? ov.display : manifestBase.display
const hexOk = ov.theme_color ? /^#([0-9a-fA-F]{3}){1,2}$/.test(ov.theme_color) : false
const theme_color = hexOk ? (ov.theme_color as string) : manifestBase.theme_color
const manifestMerged = { name, short_name, start_url, display, theme_color }
const manifestJson = JSON.stringify(manifestMerged, null, 2)
out['site.webmanifest'] = toBytes(manifestJson)
// Google Sheets 템플릿: 기본 auto. overrides.sheetsMode로 강제 포함/제외 가능
const mode = overrides?.sheetsMode ?? 'auto'
const action = page.form?.actionUrl?.trim()
const hasAction = !!(action && action.length > 0)
const shouldInclude = mode === 'include' || (mode === 'auto' && !hasAction)
if (shouldInclude) {
const code = `/**
* Google Apps Script Web App for form submissions
* - Deploy as Web App and set URL as form action if needed.
* @tool: landing-builder
* @version: 1.0.0
*/
function doPost(e){
try {
// TODO: map incoming fields to sheet columns
// Example:
// const payload = JSON.parse(e.postData.contents || '{}');
// const row = [payload.name, payload.email, new Date()];
// SpreadsheetApp.openById('YOUR_SHEET_ID').getSheetByName('Sheet1').appendRow(row);
return ContentService.createTextOutput('OK')
} catch (e) {
return ContentService
.createTextOutput('ERROR: ' + (e && (e as any).message ? (e as any).message : 'unknown'))
.setMimeType(ContentService.MimeType.TEXT)
}
}
`
const readme = `Google Sheets Apps Script Template\n\n1) Apps Script에서 새 프로젝트를 만들고 아래 Code.gs를 붙여넣으세요.\n2) Deploy as Web App로 배포 후 URL을 복사해 사용하세요.\n3) Map fields to columns: 제출 payload의 필드를 시트 컬럼에 매핑하세요.`
out['sheets/Code.gs'] = toBytes(code)
out['sheets/README.txt'] = toBytes(readme)
}
return out
}
@@ -0,0 +1,28 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
function makePage(): Page {
return pageSchema.parse({
title: 'T',
locale: 'en',
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
sections: [
{ type: 'features', props: { items: ['One', 'Two'] } },
{ type: 'testimonials', props: { items: [{ author: 'Jane', quote: 'Great' }] } },
] as Page['sections'],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
seo: { title: 'SEO', description: 'Desc' },
})
}
describe('Exporter - Features/Testimonials aria-labelledby', () => {
test('has aria-labelledby and matching heading ids', () => {
const out = exportPage(makePage())
expect(out.html).toContain('<section class="features" aria-labelledby="features-heading">')
expect(out.html).toContain('<h2 id="features-heading">Features</h2>')
expect(out.html).toContain('<section class="testimonials" aria-labelledby="testimonials-heading">')
expect(out.html).toContain('<h2 id="testimonials-heading">Testimonials</h2>')
})
})
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, test } from 'vitest'
import { exportPage } from './html'
import { pageSchema, type Page } from '@/lib/schema/page'
const makePage = (): Page =>
pageSchema.parse({
title: 'Focus Tokens',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 'SEO', description: 'Desc' },
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
})
describe('CSS focus-visible tokens smoke', () => {
test('outline uses 3px solid and hex color token', () => {
const { css } = exportPage(makePage())
expect(css).toMatch(/outline:\s*3px\s*solid/i)
// check presence of our hex color used for outline (example: #2563eb)
expect(css).toMatch(/outline:\s*3px\s*solid\s*#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})/)
// ensure focus-visible styles exist on common interactive elements
expect(css).toMatch(/button:focus|:focus-visible|a:focus|input:focus|textarea:focus|select:focus/)
})
})
+29
View File
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest'
import { exportPage } from '@/lib/exporter/html'
import { pageSchema, type Page } from '@/lib/schema/page'
function makePage(): Page {
return pageSchema.parse({
title: 'Font Opt',
locale: 'en',
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
sections: [ { type: 'hero', props: { heading: 'Hello', subheading: 'World', imageUrl: 'https://img/h.png' } } ],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 't', description: 'd' },
})
}
describe('폰트 최적화', () => {
it('head에 Google Fonts preconnect 링크가 포함된다', () => {
const page = makePage()
const out = exportPage(page)
expect(out.html).toContain('<link rel="preconnect" href="https://fonts.googleapis.com"')
expect(out.html).toContain('<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin')
})
it('CSS에 system-ui 폴백이 포함된다', () => {
const page = makePage()
const out = exportPage(page)
expect(out.css).toMatch(/font-family:[^;]*system-ui/i)
})
})
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest'
import { exportPage, type ExportOptions } from '@/lib/exporter/html'
import { pageSchema, type Page } from '@/lib/schema/page'
function makePage(fontFamily = 'Inter'): Page {
return pageSchema.parse({
title: 'Font Preload',
locale: 'en',
theme: { primaryColor: '#2563eb', fontFamily },
sections: [ { type: 'hero', props: { heading: 'Hello', subheading: 'World', imageUrl: 'https://img/h.png' } } ],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 't', description: 'd' },
})
}
describe('폰트 preload 옵션', () => {
it('옵션이 true이면 Google Fonts stylesheet preload/link를 head에 포함한다', () => {
const page = makePage('Inter')
const opts: ExportOptions = { fontPreload: true }
const out = exportPage(page, opts)
expect(out.html).toContain('rel="preload"')
expect(out.html).toContain('as="style"')
expect(out.html).toContain('fonts.googleapis.com/css2')
expect(out.html).toContain('family=Inter')
// onload print hack 포함
expect(out.html).toMatch(/rel=\"stylesheet\"[^>]+media=\"print\"[^>]+onload=\"this.media='all'\"/)
})
it('옵션이 false이면 preload/link를 포함하지 않는다', () => {
const page = makePage('Inter')
const opts: ExportOptions = { fontPreload: false }
const out = exportPage(page, opts)
expect(out.html).not.toMatch(/fonts.googleapis.com\/css2/)
})
})
+75
View File
@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest'
import { pageSchema, type Page } from '@/lib/schema/page'
import { exportPage } from '@/lib/exporter/html'
const makePage = (): Page =>
pageSchema.parse({
title: 'Form Help A11y',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [],
form: {
actionUrl: '',
fields: [
{ type: 'text', name: 'name', label: 'Name', required: true, help: 'Your full name' },
{ type: 'email', name: 'email', label: 'Email', required: true },
{ type: 'select', name: 'plan', label: 'Plan', required: false, options: ['A','B'], help: 'Choose your plan' },
{ type: 'radio', name: 'size', label: 'Size', required: false, options: ['S','M','L'], help: 'Pick one size' },
{ type: 'checkbox', name: 'agree', label: 'Agree', required: true, help: 'You must agree to proceed' },
{ type: 'textarea', name: 'msg', label: 'Message', required: false },
],
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
},
seo: { title: 'Form Help A11y', description: 'desc' },
})
/**
* Requirements (a11y):
* - Each control has an id `field-<name>` and its label has htmlFor pointing to it.
* - If field.help exists:
* - Render help element with id `field-<name>-help` containing help text.
* - Control has `aria-describedby` referencing the help id.
* - For radio group, set aria-describedby on the fieldset instead of individual radios.
*/
describe('Exporter - form helptext and aria-describedby', () => {
it('renders id/htmlFor and help + aria-describedby wiring', () => {
const page = makePage()
const out = exportPage(page)
const html = out.html
// text with help
expect(html).toContain('id="field-name"')
expect(html).toContain('<label for="field-name">Name')
expect(html).toContain('id="field-name-help"')
expect(html).toContain('Your full name')
expect(html).toContain('aria-describedby="field-name-help"')
// email without help → no aria-describedby
expect(html).toContain('id="field-email"')
expect(html).toContain('<label for="field-email">Email')
expect(html).not.toContain('field-email-help')
// select with help
expect(html).toContain('id="field-plan"')
expect(html).toContain('<label for="field-plan">Plan')
expect(html).toContain('id="field-plan-help"')
expect(html).toContain('Choose your plan')
// radio group help → fieldset has aria-describedby
expect(html).toContain('<fieldset')
expect(html).toContain('aria-describedby="field-size-help"')
expect(html).toContain('id="field-size-help"')
expect(html).toContain('Pick one size')
// checkbox with help
expect(html).toContain('id="field-agree"')
expect(html).toContain('<label for="field-agree">')
expect(html).toContain('id="field-agree-help"')
expect(html).toContain('You must agree to proceed')
// textarea without help
expect(html).toContain('id="field-msg"')
expect(html).toContain('<label for="field-msg">Message')
})
})
+66
View File
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest'
import { pageSchema, type Page } from '@/lib/schema/page'
import { exportPage } from '@/lib/exporter/html'
function makePage(fields: Page['form']['fields']): Page {
return pageSchema.parse({
title: 'Matrix',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [],
form: {
actionUrl: '',
fields,
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
},
seo: { title: 'SEO', description: 'Desc' },
})
}
describe('Exporter - form helptext matrix (required/maxLength/pattern/help)', () => {
it('text: required + maxLength + help → has required, maxlength, help id and aria-describedby', () => {
const page = makePage([
{ type: 'text', name: 'nick', label: 'Nickname', required: true, maxLength: 10, help: 'Public name' },
])
const html = exportPage(page).html
expect(html).toContain('id="field-nick"')
expect(html).toMatch(/name="nick"[^"]*[^>]*required/)
expect(html).toMatch(/name="nick"[^"]*[^>]*maxlength="10"/)
expect(html).toContain('id="field-nick-help"')
expect(html).toContain('aria-describedby="field-nick-help"')
})
it('tel: pattern only, no help → has pattern, no aria-describedby', () => {
const page = makePage([
{ type: 'tel', name: 'phone', label: 'Phone', required: false, pattern: '^\\+?[0-9\\- ]{7,}$' },
])
const html = exportPage(page).html
expect(html).toMatch(/name="phone"[^"]*[^>]*pattern="\^\\\+\?\[0-9\\\- \]\{7,\}\$"/)
expect(html).not.toContain('field-phone-help')
// ensure no false aria-describedby
expect(html).not.toMatch(/name="phone"[^"]*[^>]*aria-describedby=/)
})
it('select: help present → select has aria-describedby and help element exists', () => {
const page = makePage([
{ type: 'select', name: 'plan', label: 'Plan', required: false, options: ['Free','Pro'], help: 'Choose one' },
])
const html = exportPage(page).html
expect(html).toContain('id="field-plan"')
expect(html).toContain('aria-describedby="field-plan-help"')
expect(html).toContain('id="field-plan-help"')
})
it('radio: help present → fieldset has aria-describedby; radios do not individually', () => {
const page = makePage([
{ type: 'radio', name: 'size', label: 'Size', required: false, options: ['S','M'], help: 'Pick one' },
])
const html = exportPage(page).html
// fieldset wiring
expect(html).toContain('<fieldset')
expect(html).toContain('aria-describedby="field-size-help"')
expect(html).toContain('id="field-size-help"')
// radios present
expect(html).toContain('type="radio"')
})
})
@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest'
import { pageSchema, type Page } from '@/lib/schema/page'
import { exportPage } from '@/lib/exporter/html'
const makePage = (): Page =>
pageSchema.parse({
title: 'Form Help Snapshot',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [],
form: {
actionUrl: '',
fields: [
{ type: 'text', name: 'name', label: 'Name', required: true, help: 'Your full name' },
{ type: 'email', name: 'email', label: 'Email', required: true },
{ type: 'radio', name: 'size', label: 'Size', options: ['S','M'], help: 'Pick one size' },
{ type: 'select', name: 'plan', label: 'Plan', options: ['Free','Pro'], help: 'Choose plan' },
{ type: 'checkbox', name: 'agree', label: 'Agree', required: true, help: 'Must agree' },
],
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
},
seo: { title: 'SEO', description: 'Desc' },
})
function extractForm(html: string): string {
const m = html.match(/<form[\s\S]*?<\/form>/)
return m ? m[0]
// normalize whitespace for snapshot stability
.replace(/\s+\n/g, '\n')
.replace(/\n\s+/g, '\n')
.trim()
: ''
}
describe('Exporter - form helptext snapshot', () => {
it('contains stable wiring for ids/labels/help/aria', () => {
const out = exportPage(makePage())
const form = extractForm(out.html)
// name with help
expect(form).toContain('id="field-name"')
expect(form).toContain('<label for="field-name">Name')
expect(form).toContain('id="field-name-help"')
expect(form).toContain('aria-describedby="field-name-help"')
// email present (no help)
expect(form).toContain('id="field-email"')
expect(form).toContain('<label for="field-email">Email')
expect(form).not.toContain('field-email-help')
// radio group help on fieldset
expect(form).toContain('<fieldset')
expect(form).toContain('aria-describedby="field-size-help"')
expect(form).toContain('id="field-size-help"')
expect(form).toContain('type="radio"')
// select with help
expect(form).toContain('id="field-plan"')
expect(form).toContain('aria-describedby="field-plan-help"')
expect(form).toContain('id="field-plan-help"')
// checkbox with help
expect(form).toContain('id="field-agree"')
expect(form).toContain('aria-describedby="field-agree-help"')
expect(form).toContain('id="field-agree-help"')
})
})

Some files were not shown because too many files have changed in this diff Show More