Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98d33fc0d0 | |||
| 1737ae172e | |||
| 5dce80e56d | |||
| e32d59ca44 | |||
| 86155c9fd3 | |||
| ba2ba588f7 | |||
| 396d0ac7a0 | |||
| 19a39230be | |||
| c519055e90 | |||
| 7928f9b6c5 |
@@ -43,6 +43,33 @@ jobs:
|
||||
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:
|
||||
|
||||
+68
-6
@@ -95,7 +95,15 @@
|
||||
- Exporter CSS 토큰 회귀 확대(:root --primary, body font, skip-link base/focus, focus outline, reduced-motion)
|
||||
- 컴포넌트 i18n 회귀 스냅샷(en/ko 주요 라벨/버튼 키 존재)
|
||||
- 섹션 a11y 자동 검증 템플릿(testUtils/sectionA11y) 도입 및 템플릿 테스트 추가
|
||||
- 신규 섹션 추가: Benefits
|
||||
- 이미지 최적화 옵션 1차(TDD): hero 이미지에 `fetchpriority`/`srcset`/`sizes` 조건부 렌더링
|
||||
- 테스트: `lib/exporter/image.responsive.options.test.ts` (속성 존재 검증)
|
||||
- 구현: `lib/exporter/html.ts`(`ExportOptions`에 옵션 추가 및 `<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/리스트 아이템 검증)
|
||||
@@ -353,7 +361,7 @@
|
||||
- [ci] auto pipeline 최종 재검증 최종 ping (2025-11-14 22:26:59)
|
||||
|
||||
|
||||
## Export ZIP 무결성 강화(Option2) 진행 현황
|
||||
### Export ZIP 무결성 강화(Option2) 진행 현황
|
||||
|
||||
### 목표
|
||||
- Export ZIP 무결성 강화: 자산 무결성 지표와 번들 검증 체계 구축
|
||||
@@ -369,8 +377,22 @@
|
||||
- `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 계획 및 상태
|
||||
- 테스트 선작성 → 최소 구현 → 리팩터/문서화
|
||||
@@ -383,10 +405,16 @@
|
||||
- Node/브라우저 호환 btoa/atob 제거, 안전한 변환 로직 적용
|
||||
|
||||
### 다음 작업
|
||||
1) PR 생성(템플릿/라벨): `feat/export-bundle-integrity` → `main`
|
||||
2) CI 무결성 검증 스텝 추가: `verifyIntegrity`로 빌드 산출물 검사
|
||||
3) PR 라벨 자동화 및 조건부 머지(Gitea Actions/웹훅)
|
||||
4) README에 사용 가이드 보완(verifyIntegrity 활용 예시)
|
||||
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 오픈 시: 라벨 자동 지정, 테스트/빌드, 무결성 검증 스텝 실행
|
||||
@@ -395,3 +423,37 @@
|
||||
### 커밋/푸쉬 원칙
|
||||
- 기능 단위 커밋은 한국어 메시지
|
||||
- 대범위 브랜치 완료 전에 푸쉬 금지(현 단계는 완료되어 푸쉬 수행)
|
||||
|
||||
## 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)
|
||||
|
||||
@@ -63,6 +63,13 @@ The Builder can export a production-ready ZIP that contains:
|
||||
- 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
|
||||
|
||||
@@ -73,6 +80,26 @@ The Builder can export a production-ready ZIP that contains:
|
||||
### 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/...`
|
||||
@@ -97,15 +124,31 @@ Simple index to quickly find assets by group:
|
||||
|
||||
### assets.integrity.index.json
|
||||
|
||||
- entries: array of `{ path, integrity }`
|
||||
- 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:
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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)' })
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AssetManager } from '@/lib/assets/assetManager'
|
||||
|
||||
export type AssetsIntegrityIndex = {
|
||||
entries: Array<{ path: string; integrity: string }>
|
||||
entries: Array<{ path: string; integrity: string; size: number }>
|
||||
bundleHash: string
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ export function buildAssetsIntegrityIndex(am: AssetManager): Uint8Array {
|
||||
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))
|
||||
|
||||
+15
-3
@@ -68,13 +68,25 @@ export function buildExtrasForPage(page: Page, overrides?: ExtrasOverrides): Rec
|
||||
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){
|
||||
// TODO: handle submission to Google Sheets
|
||||
return ContentService.createTextOutput('OK')
|
||||
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) 배포 > 웹 앱으로 배포 후 URL을 복사해 사용하세요.`
|
||||
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)
|
||||
}
|
||||
|
||||
+17
-1
@@ -9,6 +9,11 @@ export type ExportOptions = {
|
||||
robotsMeta?: string
|
||||
imageLoading?: 'lazy' | 'eager'
|
||||
imageDecoding?: 'async' | 'sync'
|
||||
imageFetchPriority?: 'high' | 'low'
|
||||
imageSrcset?: string
|
||||
imageSizes?: string
|
||||
imageAutoSizes?: boolean
|
||||
imageSizesPreset?: 'mobile-first' | 'desktop-fixed'
|
||||
}
|
||||
|
||||
function renderFaq(section: { props: { items: Array<{ q: string; a: string }> } }) {
|
||||
@@ -125,6 +130,17 @@ function renderHero(section: { props: { heading: string; subheading?: string; im
|
||||
const imageUrl = am ? am.rewriteUrl(imageUrlRaw) : imageUrlRaw
|
||||
const loading = opts?.imageLoading ?? 'lazy'
|
||||
const decoding = opts?.imageDecoding ?? 'async'
|
||||
const fetchpriority = opts?.imageFetchPriority ? ` fetchpriority="${escapeHtml(opts.imageFetchPriority)}"` : ''
|
||||
const srcset = opts?.imageSrcset ? ` srcset="${escapeHtml(opts.imageSrcset)}"` : ''
|
||||
const defaultSizes = '(max-width: 640px) 100vw, 600px'
|
||||
const presetSizes = (() => {
|
||||
if (!opts?.imageSizesPreset) return ''
|
||||
if (opts.imageSizesPreset === 'mobile-first') return '(max-width: 640px) 100vw, 800px'
|
||||
if (opts.imageSizesPreset === 'desktop-fixed') return '1200px'
|
||||
return ''
|
||||
})()
|
||||
const sizesValue = opts?.imageSizes ?? (presetSizes || (opts?.imageSrcset && opts?.imageAutoSizes ? defaultSizes : ''))
|
||||
const sizes = sizesValue ? ` sizes="${escapeHtml(sizesValue)}"` : ''
|
||||
return `
|
||||
<section class="hero" aria-labelledby="hero-heading">
|
||||
<div class="container">
|
||||
@@ -133,7 +149,7 @@ function renderHero(section: { props: { heading: string; subheading?: string; im
|
||||
${subheading.length > 0 ? `<p>${escapeHtml(subheading)}</p>` : ''}
|
||||
</div>
|
||||
<div class="media">
|
||||
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(heading)}" loading="${escapeHtml(loading)}" decoding="${escapeHtml(decoding)}" width="1200" height="675" />
|
||||
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(heading)}" loading="${escapeHtml(loading)}" decoding="${escapeHtml(decoding)}"${fetchpriority}${srcset}${sizes} width="1200" height="675" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportPage } from '@/lib/exporter/html'
|
||||
import type { ExportOptions } from '@/lib/exporter/html'
|
||||
import type { Page } from '@/lib/schema/page'
|
||||
|
||||
const basePage: Page = {
|
||||
title: 'Img Test',
|
||||
locale: 'en',
|
||||
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||
sections: [
|
||||
{ type: 'hero', props: { heading: 'Hero', subheading: '', imageUrl: 'https://example.com/hero.jpg' } },
|
||||
],
|
||||
form: {
|
||||
actionUrl: '',
|
||||
fields: [],
|
||||
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||
},
|
||||
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||
seo: { title: 'T', description: 'D' },
|
||||
}
|
||||
|
||||
describe('image responsive/export options', () => {
|
||||
it('applies fetchpriority when provided', () => {
|
||||
const options: ExportOptions = { imageLoading: 'eager', imageDecoding: 'async', imageFetchPriority: 'high' }
|
||||
const { html } = exportPage(basePage, options)
|
||||
expect(html).toMatch(/<img[^>]*fetchpriority="high"/)
|
||||
})
|
||||
|
||||
it('applies srcset and sizes when provided', () => {
|
||||
const options: ExportOptions = {
|
||||
imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x',
|
||||
imageSizes: '(max-width: 640px) 100vw, 600px',
|
||||
}
|
||||
const { html } = exportPage(basePage, options)
|
||||
expect(html).toMatch(/<img[^>]*srcset="https:\/\/example.com\/hero.jpg 1x, https:\/\/example.com\/hero@2x.jpg 2x"/)
|
||||
expect(html).toMatch(/<img[^>]*sizes="\(max-width: 640px\) 100vw, 600px"/)
|
||||
})
|
||||
|
||||
it('applies default sizes when srcset is provided and imageAutoSizes is true', () => {
|
||||
const options: ExportOptions = {
|
||||
imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x',
|
||||
imageAutoSizes: true,
|
||||
}
|
||||
const { html } = exportPage(basePage, options)
|
||||
expect(html).toMatch(/<img[^>]*srcset="https:\/\/example.com\/hero.jpg 1x, https:\/\/example.com\/hero@2x.jpg 2x"/)
|
||||
expect(html).toMatch(/<img[^>]*sizes="\(max-width: 640px\) 100vw, 600px"/)
|
||||
})
|
||||
|
||||
it('does not include sizes when srcset is provided but imageAutoSizes is false and sizes not provided', () => {
|
||||
const options: ExportOptions = {
|
||||
imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x',
|
||||
imageAutoSizes: false,
|
||||
}
|
||||
const { html } = exportPage(basePage, options)
|
||||
expect(html).toMatch(/<img[^>]*srcset="https:\/\/example.com\/hero.jpg 1x, https:\/\/example.com\/hero@2x.jpg 2x"/)
|
||||
expect(html).not.toMatch(/<img[^>]*sizes="/)
|
||||
})
|
||||
|
||||
it('applies preset sizes when imageSizesPreset is set (mobile-first)', () => {
|
||||
const options: ExportOptions = {
|
||||
imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x',
|
||||
imageSizesPreset: 'mobile-first',
|
||||
}
|
||||
const { html } = exportPage(basePage, options)
|
||||
expect(html).toMatch(/<img[^>]*sizes="\(max-width: 640px\) 100vw, 800px"/)
|
||||
})
|
||||
|
||||
it('applies desktop-fixed preset when selected', () => {
|
||||
const options: ExportOptions = {
|
||||
imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x',
|
||||
imageSizesPreset: 'desktop-fixed',
|
||||
}
|
||||
const { html } = exportPage(basePage, options)
|
||||
expect(html).toMatch(/<img[^>]*sizes="1200px"/)
|
||||
})
|
||||
|
||||
it('explicit imageSizes overrides presets and auto', () => {
|
||||
const options: ExportOptions = {
|
||||
imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x',
|
||||
imageAutoSizes: true,
|
||||
imageSizesPreset: 'desktop-fixed',
|
||||
imageSizes: '(max-width: 768px) 100vw, 700px',
|
||||
}
|
||||
const { html } = exportPage(basePage, options)
|
||||
expect(html).toMatch(/<img[^>]*sizes="\(max-width: 768px\) 100vw, 700px"/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildExtrasForPage } from '@/lib/exporter/extras'
|
||||
import type { Page } from '@/lib/schema/page'
|
||||
|
||||
function td(b: Uint8Array){ return new TextDecoder().decode(b) }
|
||||
|
||||
const basePage: Page = {
|
||||
title: 'Test',
|
||||
seo: { title: 'T', description: 'D' },
|
||||
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||
sections: [],
|
||||
form: {
|
||||
fields: [],
|
||||
actionUrl: '',
|
||||
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||
},
|
||||
locale: 'en',
|
||||
}
|
||||
|
||||
describe('Sheets template enhance', () => {
|
||||
it('includes version/meta and mapping/error guide in Code.gs and README', () => {
|
||||
const extras = buildExtrasForPage(basePage, { sheetsMode: 'include' })
|
||||
const code = td(extras['sheets/Code.gs'])
|
||||
const readme = td(extras['sheets/README.txt'])
|
||||
|
||||
// version/meta header
|
||||
expect(code).toMatch(/@version:\s*\d+\.\d+\.\d+/)
|
||||
expect(code).toMatch(/@tool:\s*landing-builder/i)
|
||||
|
||||
// field mapping and error handling guides
|
||||
expect(code).toMatch(/TODO:\s*map incoming fields to sheet columns/i)
|
||||
expect(code).toMatch(/try\s*\{/)
|
||||
expect(code).toMatch(/catch\s*\(e\)/)
|
||||
|
||||
// README includes setup and mapping hint
|
||||
expect(readme).toMatch(/Apps Script/i)
|
||||
expect(readme).toMatch(/Deploy as Web App/i)
|
||||
expect(readme).toMatch(/Map fields to columns/i)
|
||||
})
|
||||
|
||||
it('respects exclude mode', () => {
|
||||
const extras = buildExtrasForPage(basePage, { sheetsMode: 'exclude' })
|
||||
expect(Object.keys(extras).some(k => k.startsWith('sheets/'))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { verifyCli } from '@/lib/exporter/verify.cli'
|
||||
|
||||
// helpers to build base64 from string
|
||||
function b64(s: string) {
|
||||
if (typeof Buffer !== 'undefined') return Buffer.from(s, 'utf-8').toString('base64')
|
||||
throw new Error('Buffer not available')
|
||||
}
|
||||
|
||||
describe('verify.cli', () => {
|
||||
const EMPTY_SHA256_B64 = '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='
|
||||
|
||||
it('returns 0 and logs summary when ok', async () => {
|
||||
// empty entries integrity index; bundleHash = sha256("") in base64
|
||||
const extras = {
|
||||
'assets.integrity.index.json': b64(JSON.stringify({ entries: [], bundleHash: 'sha256-' + EMPTY_SHA256_B64 }))
|
||||
}
|
||||
const assets = {}
|
||||
const extrasJson = JSON.stringify(extras)
|
||||
const assetsJson = JSON.stringify(assets)
|
||||
|
||||
const paths: Record<string, string> = {
|
||||
'/tmp/extras.ok.json': extrasJson,
|
||||
'/tmp/assets.ok.json': assetsJson,
|
||||
}
|
||||
const logs: string[] = []
|
||||
const readText = async (p: string) => {
|
||||
const v = paths[p]
|
||||
if (v == null) throw new Error('no such file: ' + p)
|
||||
return v
|
||||
}
|
||||
const code = await verifyCli(['--extras', '/tmp/extras.ok.json', '--assets', '/tmp/assets.ok.json'], readText, (s: string) => logs.push(s))
|
||||
expect(code).toBe(0)
|
||||
expect(logs.join('\n')).toMatch(/"ok": true/)
|
||||
expect(logs.join('\n')).toMatch(/"total": 0/)
|
||||
})
|
||||
|
||||
it('returns 1 and logs errors when invalid JSON', async () => {
|
||||
const paths: Record<string, string> = {
|
||||
'/tmp/extras.bad.json': JSON.stringify({ 'assets.integrity.index.json': 'not-base64' }),
|
||||
'/tmp/assets.empty.json': JSON.stringify({}),
|
||||
}
|
||||
const logs: string[] = []
|
||||
const readText = async (p: string) => {
|
||||
const v = paths[p]
|
||||
if (v == null) throw new Error('no such file: ' + p)
|
||||
return v
|
||||
}
|
||||
const code = await verifyCli(['--extras', '/tmp/extras.bad.json', '--assets', '/tmp/assets.empty.json'], readText, (s: string) => logs.push(s))
|
||||
expect(code).toBe(1)
|
||||
expect(logs.join('\n')).toMatch(/"ok": false/)
|
||||
expect(logs.join('\n')).toMatch(/Invalid assets\.integrity\.index\.json JSON|Missing assets\.integrity\.index\.json/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { verifyIntegrity } from '@/lib/exporter/verify'
|
||||
|
||||
export type ReadText = (path: string) => Promise<string>
|
||||
export type Logger = (line: string) => void
|
||||
|
||||
// base64 -> Uint8Array
|
||||
function fromBase64(b64: string): Uint8Array {
|
||||
if (typeof Buffer !== 'undefined') return new Uint8Array(Buffer.from(b64, 'base64'))
|
||||
throw new Error('Buffer not available')
|
||||
}
|
||||
|
||||
// parse args like: [--extras, path, --assets, path]
|
||||
function parseArgs(argv: string[]): { extras?: string; assets?: string } {
|
||||
const out: { extras?: string; assets?: string } = {}
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i]
|
||||
if (a === '--extras') out.extras = argv[++i]
|
||||
else if (a === '--assets') out.assets = argv[++i]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export async function verifyCli(argv: string[], readText: ReadText, log: Logger): Promise<number> {
|
||||
try {
|
||||
const { extras: extrasPath, assets: assetsPath } = parseArgs(argv)
|
||||
if (!extrasPath || !assetsPath) {
|
||||
log('Usage: verify --extras <extras.json> --assets <assets.json>')
|
||||
return 2
|
||||
}
|
||||
const extrasJson = await readText(extrasPath)
|
||||
const assetsJson = await readText(assetsPath)
|
||||
let extrasMap: Record<string, string>
|
||||
let assetsMap: Record<string, string>
|
||||
try {
|
||||
extrasMap = JSON.parse(extrasJson)
|
||||
assetsMap = JSON.parse(assetsJson)
|
||||
} catch (e) {
|
||||
log('Invalid JSON input files')
|
||||
return 1
|
||||
}
|
||||
// decode base64 into Uint8Array maps
|
||||
const extrasBytes: Record<string, Uint8Array> = {}
|
||||
for (const [k, v] of Object.entries(extrasMap)) {
|
||||
try {
|
||||
extrasBytes[k] = fromBase64(v)
|
||||
} catch {
|
||||
// mark invalid; let verifyIntegrity handle missing/invalid as possible
|
||||
log(`Failed to decode base64 for extras entry: ${k}`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
const assetsBytes: Record<string, Uint8Array> = {}
|
||||
for (const [k, v] of Object.entries(assetsMap)) {
|
||||
try {
|
||||
assetsBytes[k] = fromBase64(v)
|
||||
} catch {
|
||||
log(`Failed to decode base64 for asset entry: ${k}`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
const result = verifyIntegrity(extrasBytes, assetsBytes)
|
||||
log(JSON.stringify(result, null, 2))
|
||||
return result.ok ? 0 : 1
|
||||
} catch (e) {
|
||||
log(String(e instanceof Error ? e.message : e))
|
||||
return 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { AssetManager } from '@/lib/assets/assetManager'
|
||||
import { buildAssetsIntegrityIndex } from '@/lib/exporter/assets.integrity.index'
|
||||
import { verifyIntegrity } from '@/lib/exporter/verify'
|
||||
|
||||
function u8(n: number): Uint8Array { const a = new Uint8Array(n); for (let i=0;i<n;i++) a[i]=i&0xff; return a }
|
||||
|
||||
function td(b: Uint8Array){ return new TextDecoder().decode(b) }
|
||||
function te(s: string){ return new TextEncoder().encode(s) }
|
||||
|
||||
describe('verifyIntegrity', () => {
|
||||
it('returns ok=true for valid assets and integrity index', async () => {
|
||||
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','txt'] })
|
||||
const a = await am.add({ name: 'a.png', type: 'image/png', bytes: u8(5) })
|
||||
const b = await am.add({ name: 'b.txt', type: 'text/plain', bytes: u8(7) })
|
||||
const assets = am.toZipStructure()
|
||||
const idx = buildAssetsIntegrityIndex(am)
|
||||
const extras: Record<string, Uint8Array> = { 'assets.integrity.index.json': idx }
|
||||
|
||||
const res = verifyIntegrity(extras, assets)
|
||||
expect(res.ok).toBe(true)
|
||||
expect(res.errors.length).toBe(0)
|
||||
// summary
|
||||
const parsedIdx = JSON.parse(td(idx)) as { entries: Array<{ path: string }> }
|
||||
expect(res.summary.total).toBe(parsedIdx.entries.length)
|
||||
expect(res.summary.missing).toBe(0)
|
||||
expect(res.summary.integrityMismatches).toBe(0)
|
||||
expect(res.summary.sizeMismatches).toBe(0)
|
||||
expect(res.summary.bundleHashMismatch).toBe(false)
|
||||
// sanity: index contains both paths
|
||||
const parsed = JSON.parse(td(idx)) as { entries: Array<{ path: string }> }
|
||||
const paths = parsed.entries.map(e => e.path)
|
||||
expect(paths).toContain(a.path)
|
||||
expect(paths).toContain(b.path)
|
||||
})
|
||||
|
||||
it('detects byte tampering in an asset', async () => {
|
||||
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','txt'] })
|
||||
await am.add({ name: 'a.png', type: 'image/png', bytes: u8(5) })
|
||||
await am.add({ name: 'b.txt', type: 'text/plain', bytes: u8(7) })
|
||||
const assets = am.toZipStructure()
|
||||
const idx = buildAssetsIntegrityIndex(am)
|
||||
// tamper one asset byte
|
||||
const somePath = Object.keys(assets)[0]
|
||||
const tampered = assets[somePath].slice()
|
||||
tampered[0] = (tampered[0] ^ 0xff) & 0xff
|
||||
assets[somePath] = tampered
|
||||
|
||||
const res = verifyIntegrity({ 'assets.integrity.index.json': idx }, assets)
|
||||
expect(res.ok).toBe(false)
|
||||
expect(res.errors.some((e: string) => e.includes(somePath) && e.toLowerCase().includes('mismatch'))).toBe(true)
|
||||
expect(res.summary.integrityMismatches).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('detects missing entry or missing asset referenced by index', async () => {
|
||||
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','txt'] })
|
||||
await am.add({ name: 'a.png', type: 'image/png', bytes: u8(5) })
|
||||
await am.add({ name: 'b.txt', type: 'text/plain', bytes: u8(7) })
|
||||
const assets = am.toZipStructure()
|
||||
const idxRaw = buildAssetsIntegrityIndex(am)
|
||||
const obj = JSON.parse(td(idxRaw)) as { entries: Array<{ path: string; integrity: string }>, bundleHash: string }
|
||||
// remove one entry
|
||||
const removed = obj.entries.shift()!
|
||||
const idx2 = te(JSON.stringify(obj))
|
||||
|
||||
const res1 = verifyIntegrity({ 'assets.integrity.index.json': idx2 }, assets)
|
||||
expect(res1.ok).toBe(false)
|
||||
expect(res1.errors.some((e: string) => e.toLowerCase().includes('bundlehash'))).toBe(true)
|
||||
expect(res1.summary.bundleHashMismatch).toBe(true)
|
||||
|
||||
// reference a missing path in index
|
||||
obj.entries.unshift({ path: 'assets/missing.bin', integrity: 'sha256-AAAA' })
|
||||
const idx3 = te(JSON.stringify(obj))
|
||||
const res2 = verifyIntegrity({ 'assets.integrity.index.json': idx3 }, assets)
|
||||
expect(res2.ok).toBe(false)
|
||||
expect(res2.errors.some((e: string) => e.includes('assets/missing.bin') && e.toLowerCase().includes('missing'))).toBe(true)
|
||||
expect(res2.summary.missing).toBeGreaterThanOrEqual(1)
|
||||
// keep lints happy
|
||||
expect(removed).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { generateVerifyInputs } from '@/lib/exporter/verify.serialize'
|
||||
|
||||
// helper to b64
|
||||
function toB64(u8: Uint8Array) {
|
||||
if (typeof Buffer !== 'undefined') return Buffer.from(u8).toString('base64')
|
||||
throw new Error('Buffer not available')
|
||||
}
|
||||
|
||||
describe('verify.serialize (from folder maps)', () => {
|
||||
it('collects assets.integrity.index.json into extras and assets/* into assets map', async () => {
|
||||
// virtual FS
|
||||
const files: Record<string, Uint8Array> = {
|
||||
'/artifacts/export/assets.integrity.index.json': new TextEncoder().encode(JSON.stringify({ entries: [], bundleHash: 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' })),
|
||||
'/artifacts/export/index.html': new TextEncoder().encode('<!doctype html>'),
|
||||
'/artifacts/export/assets/img/a.png': new Uint8Array([1,2,3]),
|
||||
'/artifacts/export/assets/fonts/f.woff2': new Uint8Array([4,5,6,7]),
|
||||
}
|
||||
const listDir = async (dir: string): Promise<string[]> => {
|
||||
// return child paths (recursive listing simulated by filtering)
|
||||
return Object.keys(files).filter((p) => p.startsWith(dir)).map((p) => p)
|
||||
}
|
||||
const readFile = async (p: string) => {
|
||||
const v = files[p]
|
||||
if (!v) throw new Error('not found: ' + p)
|
||||
return v
|
||||
}
|
||||
const { extras, assets } = await generateVerifyInputs('/artifacts/export', listDir, readFile)
|
||||
// extras must have only the integrity index
|
||||
expect(Object.keys(extras)).toEqual(['assets.integrity.index.json'])
|
||||
expect(extras['assets.integrity.index.json']).toBe(toB64(files['/artifacts/export/assets.integrity.index.json']))
|
||||
// assets must include assets/* files only
|
||||
expect(Object.keys(assets).sort()).toEqual(['assets/fonts/f.woff2','assets/img/a.png'])
|
||||
expect(assets['assets/img/a.png']).toBe(toB64(files['/artifacts/export/assets/img/a.png']))
|
||||
})
|
||||
|
||||
it('ignores non-asset files and missing integrity index', async () => {
|
||||
const files: Record<string, Uint8Array> = {
|
||||
'/artifacts/export/index.html': new TextEncoder().encode('<!doctype html>'),
|
||||
'/artifacts/export/assets/img/a.png': new Uint8Array([9,9]),
|
||||
}
|
||||
const listDir = async (dir: string) => Object.keys(files).filter((p) => p.startsWith(dir))
|
||||
const readFile = async (p: string) => files[p]!
|
||||
const { extras, assets } = await generateVerifyInputs('/artifacts/export', listDir, readFile)
|
||||
expect(Object.keys(extras)).toEqual([])
|
||||
expect(Object.keys(assets)).toEqual(['assets/img/a.png'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
export type ListDir = (dir: string) => Promise<string[]>
|
||||
export type ReadFile = (path: string) => Promise<Uint8Array>
|
||||
|
||||
function toBase64(u8: Uint8Array): string {
|
||||
if (typeof Buffer !== 'undefined') return Buffer.from(u8).toString('base64')
|
||||
throw new Error('Buffer not available')
|
||||
}
|
||||
|
||||
// Generate inputs for verify CLI from a folder tree.
|
||||
// - root: export folder (contains assets.integrity.index.json and assets/*)
|
||||
// - listDir: returns list of file paths under root (can be recursive listing)
|
||||
// - readFile: reads a file into bytes
|
||||
export async function generateVerifyInputs(
|
||||
root: string,
|
||||
listDir: ListDir,
|
||||
readFile: ReadFile
|
||||
): Promise<{ extras: Record<string, string>; assets: Record<string, string> }> {
|
||||
const all = await listDir(root)
|
||||
const norm = (p: string) => p.replace(/\\/g, '/')
|
||||
const rootNorm = norm(root).replace(/\/$/, '')
|
||||
const rel = (p: string) => {
|
||||
const pn = norm(p)
|
||||
return pn.startsWith(rootNorm + '/') ? pn.slice(rootNorm.length + 1) : pn
|
||||
}
|
||||
|
||||
const extras: Record<string, string> = {}
|
||||
const assets: Record<string, string> = {}
|
||||
|
||||
// assets.integrity.index.json at root
|
||||
const integrityPath = all.find((p) => rel(p) === 'assets.integrity.index.json')
|
||||
if (integrityPath) {
|
||||
const bytes = await readFile(integrityPath)
|
||||
extras['assets.integrity.index.json'] = toBase64(bytes)
|
||||
}
|
||||
|
||||
// collect assets/**/*
|
||||
for (const abs of all) {
|
||||
const r = rel(abs)
|
||||
if (!r.startsWith('assets/')) continue
|
||||
// skip directories: best-effort by checking trailing slash
|
||||
if (r.endsWith('/')) continue
|
||||
const bytes = await readFile(abs)
|
||||
assets[r] = toBase64(bytes)
|
||||
}
|
||||
|
||||
return { extras, assets }
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
export type VerifyResult = {
|
||||
ok: boolean;
|
||||
errors: string[];
|
||||
summary: {
|
||||
total: number;
|
||||
missing: number;
|
||||
integrityMismatches: number;
|
||||
sizeMismatches: number;
|
||||
bundleHashMismatch: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// 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 verifyIntegrity(
|
||||
extras: Record<string, Uint8Array>,
|
||||
assets: Record<string, Uint8Array>
|
||||
): VerifyResult {
|
||||
const errors: string[] = []
|
||||
const key = 'assets.integrity.index.json'
|
||||
if (!Object.prototype.hasOwnProperty.call(extras, key)) {
|
||||
return { ok: false, errors: ['Missing assets.integrity.index.json in extras'], summary: { total: 0, missing: 0, integrityMismatches: 0, sizeMismatches: 0, bundleHashMismatch: false } }
|
||||
}
|
||||
let parsed: { entries: Array<{ path: string; integrity: string; size?: number }>; bundleHash: string }
|
||||
try {
|
||||
parsed = JSON.parse(new TextDecoder().decode(extras[key]))
|
||||
} catch {
|
||||
return { ok: false, errors: ['Invalid assets.integrity.index.json JSON'], summary: { total: 0, missing: 0, integrityMismatches: 0, sizeMismatches: 0, bundleHashMismatch: false } }
|
||||
}
|
||||
// verify each entry exists and integrity matches
|
||||
const recomputedLines: string[] = []
|
||||
let missing = 0
|
||||
let integrityMismatches = 0
|
||||
let sizeMismatches = 0
|
||||
for (const e of parsed.entries) {
|
||||
const bytes = assets[e.path]
|
||||
if (!bytes) {
|
||||
errors.push(`Missing asset for path in integrity index: ${e.path}`)
|
||||
missing++
|
||||
continue
|
||||
}
|
||||
// optional size check when present in index
|
||||
if (typeof e.size === 'number' && e.size !== bytes.length) {
|
||||
errors.push(`Size mismatch for ${e.path}: expected ${e.size}, got ${bytes.length}`)
|
||||
sizeMismatches++
|
||||
}
|
||||
const b64 = sha256Base64(bytes)
|
||||
const integ = `sha256-${b64}`
|
||||
if (integ !== e.integrity) {
|
||||
errors.push(`Integrity mismatch for ${e.path}`)
|
||||
integrityMismatches++
|
||||
}
|
||||
recomputedLines.push(e.path)
|
||||
recomputedLines.push(integ)
|
||||
}
|
||||
// recompute bundle hash
|
||||
const joined = new TextEncoder().encode(recomputedLines.join('\n'))
|
||||
const bundle = `sha256-${sha256Base64(joined)}`
|
||||
const bundleHashMismatch = bundle !== parsed.bundleHash
|
||||
if (bundleHashMismatch) {
|
||||
errors.push('BundleHash mismatch')
|
||||
}
|
||||
return {
|
||||
ok: errors.length === 0,
|
||||
errors,
|
||||
summary: {
|
||||
total: parsed.entries.length,
|
||||
missing,
|
||||
integrityMismatches,
|
||||
sizeMismatches,
|
||||
bundleHashMismatch,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildZip } from '@/lib/exporter/zip'
|
||||
|
||||
function u8(s: string): Uint8Array {
|
||||
return new TextEncoder().encode(s)
|
||||
}
|
||||
|
||||
describe('buildZip path guard', () => {
|
||||
it('rejects dangerous asset paths with .. segments', async () => {
|
||||
await expect(
|
||||
buildZip({ html: '', css: '', js: '', assets: { '../evil.txt': u8('x') } })
|
||||
).rejects.toThrow(/invalid asset path/i)
|
||||
})
|
||||
|
||||
it('rejects dangerous extras with absolute path and backslashes', async () => {
|
||||
await expect(
|
||||
buildZip({ html: '', css: '', js: '', extras: { '/abs.txt': u8('x') } })
|
||||
).rejects.toThrow(/invalid extra path/i)
|
||||
|
||||
await expect(
|
||||
buildZip({ html: '', css: '', js: '', extras: { 'assets\\x.bin': u8('x') } })
|
||||
).rejects.toThrow(/invalid extra path/i)
|
||||
})
|
||||
|
||||
it('accepts safe relative paths under assets/', async () => {
|
||||
const zip = await buildZip({ html: '', css: '', js: '', assets: { 'assets/a.bin': u8('ok') } })
|
||||
expect(zip.byteLength).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -14,8 +14,19 @@ export async function buildZip(input: BuildZipInput): Promise<Uint8Array> {
|
||||
zip.file('index.html', input.html)
|
||||
zip.file('styles.css', input.css)
|
||||
zip.file('app.js', input.js)
|
||||
|
||||
// guard: basic path sanitizer to avoid directory traversal or absolute paths
|
||||
const isSafePath = (p: string) => {
|
||||
if (!p || typeof p !== 'string') return false
|
||||
if (p.startsWith('/')) return false
|
||||
if (p.includes('..')) return false
|
||||
if (p.includes('\\')) return false
|
||||
return true
|
||||
}
|
||||
|
||||
if (input.assets) {
|
||||
for (const [path, bytes] of Object.entries(input.assets)) {
|
||||
if (!isSafePath(path)) throw new Error(`Invalid asset path: ${path}`)
|
||||
// In Node/Vitest, use Buffer for robust binary handling
|
||||
const buf = Buffer.from(bytes)
|
||||
zip.file(path, buf as unknown as Buffer)
|
||||
@@ -33,6 +44,7 @@ export async function buildZip(input: BuildZipInput): Promise<Uint8Array> {
|
||||
}
|
||||
}
|
||||
for (const [path, bytes] of Object.entries(input.extras)) {
|
||||
if (!isSafePath(path)) throw new Error(`Invalid extra path: ${path}`)
|
||||
const buf = Buffer.from(bytes)
|
||||
zip.file(path, buf as unknown as Buffer)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportFrame } from '@/lib/wysiwyg/export'
|
||||
import type { Frame } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function makeFrame(): Frame {
|
||||
return {
|
||||
id: 'f1', width: 320, height: 200, background: '#ffffff',
|
||||
layers: [
|
||||
{ id: 'l1', name: 'Base', visible: true, locked: false, objects: [
|
||||
{ id: 'o1', type: 'image', x: 16, y: 24, width: 80, height: 60, rotate: 15, zIndex: 0, props: { src: 'https://example.com/a.jpg', alt: 'A' } },
|
||||
]},
|
||||
{ id: 'l2', name: 'Text', visible: true, locked: false, objects: [
|
||||
{ id: 't1', type: 'text', x: 24, y: 120, width: 200, height: 32, rotate: 0, zIndex: 1, props: { text: 'Hello', fontSize: 18, color: '#111111' } },
|
||||
]},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('wysiwyg export (absolute positioned HTML/CSS + scale wrapper)', () => {
|
||||
it('renders wrapper with fixed width/height and absolutely positioned children', () => {
|
||||
const frame = makeFrame()
|
||||
const out = exportFrame(frame)
|
||||
expect(out.html).toMatch(/<div class=\"frame\"/)
|
||||
expect(out.css).toMatch(/\.frame\s*{[^}]*position:relative/)
|
||||
expect(out.css).toMatch(/width:320px/)
|
||||
expect(out.css).toMatch(/height:200px/)
|
||||
// image object
|
||||
expect(out.html).toMatch(/<img[^>]*src=\"https:\/\/example.com\/a.jpg\"/)
|
||||
expect(out.css).toMatch(/#o1\s*{[^}]*left:16px;[^}]*top:24px;[^}]*width:80px;[^}]*height:60px;[^}]*transform:rotate\(15deg\)/)
|
||||
// text object
|
||||
expect(out.html).toMatch(/<p id=\"t1\"/)
|
||||
expect(out.html).toMatch(/Hello/)
|
||||
expect(out.css).toMatch(/#t1\s*{[^}]*left:24px;[^}]*top:120px;[^}]*width:200px;[^}]*height:32px/)
|
||||
})
|
||||
|
||||
it('includes scale wrapper token for responsive fit', () => {
|
||||
const frame = makeFrame()
|
||||
const out = exportFrame(frame)
|
||||
expect(out.css).toMatch(/\.frame-scale\s*{[^}]*transform-origin:0 0/)
|
||||
expect(out.html).toMatch(/class=\"frame-scale\"/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Frame, WObject } from '@/lib/wysiwyg/schema'
|
||||
|
||||
export type Exported = { html: string; css: string; js: string }
|
||||
|
||||
function esc(s: string) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function isImage(o: WObject): o is Extract<WObject, { type: 'image' }> { return o.type === 'image' }
|
||||
function isText(o: WObject): o is Extract<WObject, { type: 'text' }> { return o.type === 'text' }
|
||||
function isButton(o: WObject): o is Extract<WObject, { type: 'button' }> { return o.type === 'button' }
|
||||
|
||||
function renderObject(o: WObject): { html: string; css: string } {
|
||||
const baseCss = `#${o.id}{position:absolute;left:${o.x}px;top:${o.y}px;width:${o.width}px;height:${o.height}px;transform:rotate(${o.rotate}deg)}`
|
||||
if (isImage(o)) {
|
||||
const html = `<img id="${o.id}" src="${esc(o.props.src)}" alt="${esc(o.props.alt || '')}" />`
|
||||
return { html, css: baseCss }
|
||||
}
|
||||
if (isText(o)) {
|
||||
const html = `<p id="${o.id}">${esc(o.props.text)}</p>`
|
||||
return { html, css: baseCss + `;font-size:${o.props.fontSize}px;color:${o.props.color}` }
|
||||
}
|
||||
// button minimal
|
||||
const label = isButton(o) ? o.props.label : 'Button'
|
||||
const href = isButton(o) ? (o.props.href || '#') : '#'
|
||||
const html = `<a id="${o.id}" href="${esc(href)}">${esc(label)}</a>`
|
||||
return { html, css: baseCss }
|
||||
}
|
||||
|
||||
export function exportFrame(frame: Frame): Exported {
|
||||
const piecesHtml: string[] = []
|
||||
const piecesCss: string[] = []
|
||||
for (const layer of frame.layers) {
|
||||
if (!layer.visible) continue
|
||||
for (const o of layer.objects) {
|
||||
const r = renderObject(o)
|
||||
piecesHtml.push(r.html)
|
||||
piecesCss.push(r.css)
|
||||
}
|
||||
}
|
||||
const html = `<div class="frame-scale"><div class="frame">${piecesHtml.join('')}</div></div>`
|
||||
const css = [
|
||||
`.frame{position:relative;width:${frame.width}px;height:${frame.height}px;background:${frame.background}}`,
|
||||
`.frame-scale{transform-origin:0 0}`,
|
||||
...piecesCss,
|
||||
].join('\n')
|
||||
return { html, css, js: '' }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { frameSchema, objectSchema, layerSchema, type Frame, type WObject } from '@/lib/wysiwyg/schema'
|
||||
|
||||
function validText(id: string): WObject {
|
||||
return {
|
||||
id,
|
||||
type: 'text',
|
||||
x: 100,
|
||||
y: 120,
|
||||
width: 300,
|
||||
height: 40,
|
||||
rotate: 0,
|
||||
zIndex: 1,
|
||||
props: { text: 'Hello', fontSize: 18, color: '#111111' },
|
||||
}
|
||||
}
|
||||
|
||||
function validImage(id: string): WObject {
|
||||
return {
|
||||
id,
|
||||
type: 'image',
|
||||
x: 50,
|
||||
y: 60,
|
||||
width: 240,
|
||||
height: 180,
|
||||
rotate: 0,
|
||||
zIndex: 0,
|
||||
props: { src: 'https://example.com/a.jpg', alt: 'A' },
|
||||
}
|
||||
}
|
||||
|
||||
describe('wysiwyg.schema', () => {
|
||||
it('validates a Frame with layers and objects', () => {
|
||||
const frame: Frame = {
|
||||
id: 'frame-1',
|
||||
width: 1200,
|
||||
height: 800,
|
||||
background: '#ffffff',
|
||||
layers: [
|
||||
{ id: 'layer-1', name: 'Base', visible: true, locked: false, objects: [validImage('img-1')] },
|
||||
{ id: 'layer-2', name: 'Text', visible: true, locked: false, objects: [validText('txt-1')] },
|
||||
],
|
||||
}
|
||||
const parsed = frameSchema.parse(frame)
|
||||
expect(parsed.layers.length).toBe(2)
|
||||
expect(parsed.layers[1].objects[0].type).toBe('text')
|
||||
})
|
||||
|
||||
it('rejects invalid color and negative sizes', () => {
|
||||
const bad: Frame = {
|
||||
id: 'f', width: -10, height: 0, background: 'blue', layers: [],
|
||||
} as unknown as Frame
|
||||
expect(() => frameSchema.parse(bad)).toThrow()
|
||||
})
|
||||
|
||||
it('objectSchema enforces type-specific props', () => {
|
||||
const ok = objectSchema.parse(validText('t1'))
|
||||
expect(ok.props.text).toBe('Hello')
|
||||
|
||||
const bad = { ...validText('t2'), props: { src: 'x' } }
|
||||
expect(() => objectSchema.parse(bad as unknown as WObject)).toThrow()
|
||||
})
|
||||
|
||||
it('layerSchema enforces ordering integers and object bounds', () => {
|
||||
const good = layerSchema.parse({ id: 'l1', name: 'L', visible: true, locked: false, objects: [validImage('i1')] })
|
||||
expect(good.objects[0].zIndex).toBe(0)
|
||||
|
||||
const badObj = { ...validImage('i2'), width: -1 }
|
||||
expect(() => objectSchema.parse(badObj as unknown as WObject)).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const colorHex = z.string().regex(/^#([0-9a-fA-F]{3}){1,2}$/)
|
||||
|
||||
const baseObject = z.object({
|
||||
id: z.string().min(1),
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
width: z.number().positive(),
|
||||
height: z.number().positive(),
|
||||
rotate: z.number(),
|
||||
zIndex: z.number().int().nonnegative(),
|
||||
})
|
||||
|
||||
const textObject = baseObject.extend({
|
||||
type: z.literal('text'),
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
fontSize: z.number().positive(),
|
||||
color: colorHex,
|
||||
}),
|
||||
})
|
||||
|
||||
const imageObject = baseObject.extend({
|
||||
type: z.literal('image'),
|
||||
props: z.object({
|
||||
src: z.string().min(1),
|
||||
alt: z.string().optional().default(''),
|
||||
}),
|
||||
})
|
||||
|
||||
const buttonObject = baseObject.extend({
|
||||
type: z.literal('button'),
|
||||
props: z.object({
|
||||
label: z.string().min(1),
|
||||
href: z.string().optional().default(''),
|
||||
color: colorHex.optional(),
|
||||
background: colorHex.optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
export const objectSchema = z.discriminatedUnion('type', [textObject, imageObject, buttonObject])
|
||||
export type WObject = z.infer<typeof objectSchema>
|
||||
|
||||
export const layerSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
visible: z.boolean(),
|
||||
locked: z.boolean(),
|
||||
objects: z.array(objectSchema),
|
||||
})
|
||||
|
||||
export const frameSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
width: z.number().positive(),
|
||||
height: z.number().positive(),
|
||||
background: colorHex,
|
||||
layers: z.array(layerSchema),
|
||||
})
|
||||
export type Frame = z.infer<typeof frameSchema>
|
||||
+3
-1
@@ -10,7 +10,9 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest -w",
|
||||
"coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test"
|
||||
"test:e2e": "playwright test",
|
||||
"verify:make-json": "node scripts/make-verify-json.js --root ./artifacts/export --extras ./artifacts/extras.json --assets ./artifacts/assets.json",
|
||||
"verify:run": "node scripts/verify.js --extras ./artifacts/extras.json --assets ./artifacts/assets.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
Generate verify inputs from an export folder
|
||||
Usage: node scripts/make-verify-json.js --root <export_folder> --extras <out_extras.json> --assets <out_assets.json>
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs/promises');
|
||||
const path = require('path');
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { root: '', extras: '', assets: '' };
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--root') out.root = argv[++i];
|
||||
else if (a === '--extras') out.extras = argv[++i];
|
||||
else if (a === '--assets') out.assets = argv[++i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toB64(buf) { return Buffer.from(buf).toString('base64'); }
|
||||
|
||||
async function readFileB64(p) { const b = await fsp.readFile(p); return toB64(b); }
|
||||
|
||||
async function walk(dir) {
|
||||
const out = [];
|
||||
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
||||
for (const ent of entries) {
|
||||
const abs = path.join(dir, ent.name);
|
||||
if (ent.isDirectory()) {
|
||||
const sub = await walk(abs);
|
||||
out.push(...sub);
|
||||
} else if (ent.isFile()) {
|
||||
out.push(abs);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const { root, extras, assets } = parseArgs(process.argv);
|
||||
if (!root || !extras || !assets) {
|
||||
console.log('Usage: node scripts/make-verify-json.js --root <export_folder> --extras <out_extras.json> --assets <out_assets.json>');
|
||||
process.exit(2);
|
||||
}
|
||||
const rootAbs = path.resolve(process.cwd(), root);
|
||||
const files = await walk(rootAbs);
|
||||
const norm = (p) => p.split(path.sep).join('/');
|
||||
const rootNorm = norm(rootAbs).replace(/\/$/, '');
|
||||
const rel = (p) => {
|
||||
const pn = norm(p);
|
||||
return pn.startsWith(rootNorm + '/') ? pn.slice(rootNorm.length + 1) : pn;
|
||||
};
|
||||
|
||||
const extrasMap = {};
|
||||
const assetsMap = {};
|
||||
|
||||
// integrity index
|
||||
const idx = files.find((p) => rel(p) === 'assets.integrity.index.json');
|
||||
if (idx) {
|
||||
extrasMap['assets.integrity.index.json'] = await readFileB64(idx);
|
||||
}
|
||||
|
||||
// assets/**/*
|
||||
for (const abs of files) {
|
||||
const r = rel(abs);
|
||||
if (!r.startsWith('assets/')) continue;
|
||||
const b64 = await readFileB64(abs);
|
||||
assetsMap[r] = b64;
|
||||
}
|
||||
|
||||
await fsp.mkdir(path.dirname(path.resolve(process.cwd(), extras)), { recursive: true });
|
||||
await fsp.mkdir(path.dirname(path.resolve(process.cwd(), assets)), { recursive: true });
|
||||
await fsp.writeFile(extras, JSON.stringify(extrasMap));
|
||||
await fsp.writeFile(assets, JSON.stringify(assetsMap));
|
||||
console.log(`Wrote ${extras} and ${assets}`);
|
||||
} catch (e) {
|
||||
console.error(e && e.stack || String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
Verify bundle integrity using extras/assets JSON maps.
|
||||
Usage: node scripts/verify.js --extras <extras.json> --assets <assets.json>
|
||||
*/
|
||||
const fs = require('fs/promises');
|
||||
const crypto = require('crypto');
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { extras: '', assets: '' };
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--extras') out.extras = argv[++i];
|
||||
else if (a === '--assets') out.assets = argv[++i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function fromB64(b64) { return Buffer.from(b64, 'base64'); }
|
||||
function sha256b64(buf) { return crypto.createHash('sha256').update(buf).digest('base64'); }
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const { extras, assets } = parseArgs(process.argv);
|
||||
if (!extras || !assets) {
|
||||
console.log('Usage: node scripts/verify.js --extras <extras.json> --assets <assets.json>');
|
||||
process.exit(2);
|
||||
}
|
||||
const extrasJson = await fs.readFile(extras, 'utf-8');
|
||||
const assetsJson = await fs.readFile(assets, 'utf-8');
|
||||
const extrasMap = JSON.parse(extrasJson);
|
||||
const assetsMap = JSON.parse(assetsJson);
|
||||
|
||||
const result = { ok: true, errors: [], summary: { total: 0, missing: 0, integrityMismatches: 0, sizeMismatches: 0, bundleHashMismatch: false } };
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(extrasMap, 'assets.integrity.index.json')) {
|
||||
result.ok = false; result.errors.push('Missing assets.integrity.index.json in extras');
|
||||
console.log(JSON.stringify(result, null, 2)); process.exit(1);
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fromB64(extrasMap['assets.integrity.index.json']).toString('utf-8'));
|
||||
} catch {
|
||||
result.ok = false; result.errors.push('Invalid assets.integrity.index.json JSON');
|
||||
console.log(JSON.stringify(result, null, 2)); process.exit(1);
|
||||
}
|
||||
|
||||
const recomputedLines = [];
|
||||
let missing = 0, integrityMismatches = 0, sizeMismatches = 0;
|
||||
for (const e of parsed.entries) {
|
||||
const b64 = assetsMap[e.path];
|
||||
if (!b64) { result.errors.push(`Missing asset for path in integrity index: ${e.path}`); missing++; continue; }
|
||||
const buf = fromB64(b64);
|
||||
if (typeof e.size === 'number' && e.size !== buf.length) {
|
||||
result.errors.push(`Size mismatch for ${e.path}: expected ${e.size}, got ${buf.length}`); sizeMismatches++; }
|
||||
const integ = 'sha256-' + sha256b64(buf);
|
||||
if (integ !== e.integrity) { result.errors.push(`Integrity mismatch for ${e.path}`); integrityMismatches++; }
|
||||
recomputedLines.push(e.path); recomputedLines.push(integ);
|
||||
}
|
||||
const bundle = 'sha256-' + sha256b64(Buffer.from(recomputedLines.join('\n'), 'utf-8'));
|
||||
const bundleHashMismatch = bundle !== parsed.bundleHash;
|
||||
if (bundleHashMismatch) result.errors.push('BundleHash mismatch');
|
||||
|
||||
result.summary.total = parsed.entries.length;
|
||||
result.summary.missing = missing;
|
||||
result.summary.integrityMismatches = integrityMismatches;
|
||||
result.summary.sizeMismatches = sizeMismatches;
|
||||
result.summary.bundleHashMismatch = bundleHashMismatch;
|
||||
result.ok = result.errors.length === 0;
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
} catch (e) {
|
||||
console.error(e && e.stack || String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,42 @@
|
||||
# 메인플랜
|
||||
|
||||
## 목표
|
||||
- Export ZIP 무결성 강화(Option2): 자산 무결성 지표와 번들 검증 체계 구축
|
||||
- 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(정상/변조/누락/번들해시 불일치) 통과
|
||||
- Export Options(폰트 프리커넥트/프리로드, 이미지 로딩/디코딩, robots 메타) UI/문서화
|
||||
- 브랜치 푸쉬 완료: `feat/export-bundle-integrity`
|
||||
|
||||
## TDD 계획 및 상태
|
||||
- 테스트 선작성 → 최소 구현 → 리팩터/문서화
|
||||
- 현재 테스트: 110 files / 173 tests 모두 통과
|
||||
- 무결성 인덱스/검증에 대한 정상/오류 케이스 커버 완료
|
||||
|
||||
## 오류/디버깅 로그 요약
|
||||
- 그룹 경로(other) 정규식 기대값 수정으로 테스트 안정화
|
||||
- SHA-256 재계산 불일치 회피: 테스트에서 중복 해시 구현 제거, 산출물 기준 비교
|
||||
- Node/브라우저 호환 btoa/atob 제거, 안전한 변환 로직 적용
|
||||
|
||||
## 다음 작업
|
||||
1) PR 생성(템플릿/라벨): `feat/export-bundle-integrity` → `main`
|
||||
2) CI 무결성 검증 스텝 추가: `verifyIntegrity`로 빌드 산출물 검사
|
||||
3) PR 라벨 자동화 및 조건부 머지(Gitea Actions/웹훅)
|
||||
4) README에 사용 가이드 보완(verifyIntegrity 활용 예시)
|
||||
|
||||
## CI/CD 계획(초안)
|
||||
- PR 오픈 시: 라벨 자동 지정, 테스트/빌드, 무결성 검증 스텝 실행
|
||||
- main 머지 조건: 테스트/빌드 통과 + 무결성 검증 통과 + 최소 라벨 셋 충족
|
||||
|
||||
## 커밋/푸쉬 원칙
|
||||
- 기능 단위 커밋은 한국어 메시지
|
||||
- 대범위 브랜치 완료 전에 푸쉬 금지(현 단계는 완료되어 푸쉬 수행)
|
||||
Reference in New Issue
Block a user