Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91dd9b161a |
@@ -1,37 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
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
|
|
||||||
+12
-21
@@ -34,15 +34,6 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CI: true
|
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: Auto-merge PR on green
|
- name: Auto-merge PR on green
|
||||||
if: ${{ github.event_name == 'pull_request' }}
|
if: ${{ github.event_name == 'pull_request' }}
|
||||||
env:
|
env:
|
||||||
@@ -60,23 +51,23 @@ jobs:
|
|||||||
LABELS_JSON=$(curl -sS -X GET \
|
LABELS_JSON=$(curl -sS -X GET \
|
||||||
-H "Authorization: token ${CI_TOKEN}" \
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}")
|
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}")
|
||||||
echo "labels payload (first 200 chars):"; echo "$LABELS_JSON" | cut -c1-200 || true
|
echo "Labels payload: $LABELS_JSON" | sed 's/.\{200\}$/.../'
|
||||||
if ! echo "$LABELS_JSON" | grep -qi '"name":"automerge"'; then
|
if ! echo "$LABELS_JSON" | grep -qi '"name":"automerge"'; then
|
||||||
echo "Label 'automerge' not present; skipping merge"; exit 0; fi
|
echo "Label 'automerge' not present; skipping merge"; exit 0; fi
|
||||||
echo "Label 'automerge' present; proceeding to merge"
|
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"
|
echo "Merging PR #$PR_NUMBER for $OWNER/$NAME"
|
||||||
HTTP_CODE=$(curl -sS -o /tmp/merge_resp.json -w "%{http_code}" -X POST \
|
RESP=$(curl -sS -X POST \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "Authorization: token ${CI_TOKEN}" \
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/merge" \
|
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/merge" \
|
||||||
-d '{"Do":"merge","MergeMessage":"auto-merge: CI green"}' || true)
|
-d '{"Do":"merge","MergeMessage":"auto-merge: CI green"}' || true)
|
||||||
echo "merge http: $HTTP_CODE"
|
echo "API response: $RESP"
|
||||||
echo "merge resp:"; cat /tmp/merge_resp.json || 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
|
||||||
|
|||||||
@@ -39,4 +39,3 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
MAIN_PLAN.md
|
|
||||||
|
|||||||
@@ -34,69 +34,3 @@ 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
|
|
||||||
|
|
||||||
### 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>`
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
- 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
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
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"')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
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"')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -6,9 +6,6 @@ import { pageSchema, type Page } from '@/lib/schema/page'
|
|||||||
import { exportPage } from '@/lib/exporter/html'
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
import { AssetManager } from '@/lib/assets/assetManager'
|
import { AssetManager } from '@/lib/assets/assetManager'
|
||||||
import { buildZip } from '@/lib/exporter/zip'
|
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 type { Section } from '@/lib/state/store'
|
import type { Section } from '@/lib/state/store'
|
||||||
import { createFormStore, type FormState } from '@/lib/state/formStore'
|
import { createFormStore, type FormState } from '@/lib/state/formStore'
|
||||||
import FormBuilderPanel from '@/components/FormBuilderPanel'
|
import FormBuilderPanel from '@/components/FormBuilderPanel'
|
||||||
@@ -50,21 +47,6 @@ export default function BuilderClientPage() {
|
|||||||
const [ogImage, setOgImage] = useState<string>('')
|
const [ogImage, setOgImage] = useState<string>('')
|
||||||
const [ga4MeasurementId, setGa4MeasurementId] = useState<string>('')
|
const [ga4MeasurementId, setGa4MeasurementId] = useState<string>('')
|
||||||
const [metaPixelId, setMetaPixelId] = 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'>(() => {
|
const [viewport, setViewport] = useState<'desktop' | 'mobile'>(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
try {
|
try {
|
||||||
@@ -183,22 +165,9 @@ export default function BuilderClientPage() {
|
|||||||
try {
|
try {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const w = window as unknown as { __exportPreview?: () => { html: string; css: string; js: string } }
|
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 = () => {
|
w.__exportPreview = () => {
|
||||||
try {
|
try {
|
||||||
// Export preview with current optimization options
|
return exportPage(pageSchema.parse(pageData), { assetManager: am ?? undefined })
|
||||||
return exportPage(pageSchema.parse(pageData), { assetManager: buildEffectiveAm(), fontPreconnect, fontPreload, imageLoading, imageDecoding, robotsMeta: robotsMeta.trim() || undefined })
|
|
||||||
} catch {
|
} catch {
|
||||||
const fixed: Page = {
|
const fixed: Page = {
|
||||||
...pageData,
|
...pageData,
|
||||||
@@ -208,41 +177,16 @@ export default function BuilderClientPage() {
|
|||||||
spamProtection: pageData.form?.spamProtection ?? { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
spamProtection: pageData.form?.spamProtection ?? { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return exportPage(pageSchema.parse(fixed), { assetManager: buildEffectiveAm(), fontPreconnect, fontPreload, imageLoading, imageDecoding, robotsMeta: robotsMeta.trim() || undefined })
|
return exportPage(pageSchema.parse(fixed), { assetManager: am ?? undefined })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [pageData, am, fontPreconnect, fontPreload, imageLoading, imageDecoding, robotsMeta, assetPathStrategy])
|
}, [pageData, am])
|
||||||
|
|
||||||
const doExport = useCallback(async () => {
|
const doExport = useCallback(async () => {
|
||||||
let valid = pageData as Page
|
const valid = pageSchema.parse(pageData)
|
||||||
try {
|
const exported = exportPage(valid, { assetManager: am ?? undefined })
|
||||||
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 {
|
try {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const w = window as unknown as { __captureExport?: boolean; __lastExport?: { html: string; css: string; js: string } }
|
const w = window as unknown as { __captureExport?: boolean; __lastExport?: { html: string; css: string; js: string } }
|
||||||
@@ -251,45 +195,10 @@ export default function BuilderClientPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
const effectiveAm = (() => {
|
const assets = am ? am.toZipStructure() : {}
|
||||||
const base = am ?? undefined
|
const zipBytes = await buildZip({ html: exported.html, css: exported.css, js: exported.js, assets })
|
||||||
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) } : {}),
|
|
||||||
}
|
|
||||||
const zipBytes = await buildZip({ html: exported.html, css: exported.css, js: exported.js, assets, extras })
|
|
||||||
download('landing.zip', zipBytes)
|
download('landing.zip', zipBytes)
|
||||||
}, [pageData, am, manifestName, manifestShortName, manifestStartUrl, manifestDisplay, manifestThemeColor, robotsDisallow, sheetsMode, fontPreconnect, fontPreload, imageLoading, imageDecoding, robotsMeta, assetPathStrategy])
|
}, [pageData, am])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
@@ -352,168 +261,6 @@ export default function BuilderClientPage() {
|
|||||||
onSelect={(i) => setSelectedIndex(i)}
|
onSelect={(i) => setSelectedIndex(i)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
}
|
}
|
||||||
right={
|
right={
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
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
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
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/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -16,7 +16,6 @@ export type AssetRef = {
|
|||||||
export type AssetManagerOptions = {
|
export type AssetManagerOptions = {
|
||||||
maxBytes: number
|
maxBytes: number
|
||||||
allowedExts: string[] // e.g., ['png','jpg','jpeg','webp','svg']
|
allowedExts: string[] // e.g., ['png','jpg','jpeg','webp','svg']
|
||||||
pathStrategy?: 'flat' | 'grouped'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extOf(name: string): string {
|
function extOf(name: string): string {
|
||||||
@@ -37,15 +36,12 @@ function hash8(bytes: Uint8Array): string {
|
|||||||
export class AssetManager {
|
export class AssetManager {
|
||||||
private readonly maxBytes: number
|
private readonly maxBytes: number
|
||||||
private readonly allowed: Set<string>
|
private readonly allowed: Set<string>
|
||||||
private readonly pathStrategy: 'flat' | 'grouped'
|
|
||||||
private byHash = new Map<string, AssetRef>()
|
private byHash = new Map<string, AssetRef>()
|
||||||
private byOriginal = new Map<string, string>() // originalName -> path
|
private byOriginal = new Map<string, string>() // originalName -> path
|
||||||
private referenced = new Set<string>() // originalName referenced via rewrite
|
|
||||||
|
|
||||||
constructor(opts: AssetManagerOptions) {
|
constructor(opts: AssetManagerOptions) {
|
||||||
this.maxBytes = opts.maxBytes
|
this.maxBytes = opts.maxBytes
|
||||||
this.allowed = new Set(opts.allowedExts.map((e) => e.toLowerCase()))
|
this.allowed = new Set(opts.allowedExts.map((e) => e.toLowerCase()))
|
||||||
this.pathStrategy = opts.pathStrategy ?? 'flat'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async add(file: AssetInput): Promise<AssetRef> {
|
async add(file: AssetInput): Promise<AssetRef> {
|
||||||
@@ -64,7 +60,7 @@ export class AssetManager {
|
|||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = this.makePath(h, ext, file.type)
|
const path = `assets/${h}.${ext}`
|
||||||
const ref: AssetRef = {
|
const ref: AssetRef = {
|
||||||
originalName: file.name,
|
originalName: file.name,
|
||||||
mimeType: file.type,
|
mimeType: file.type,
|
||||||
@@ -95,23 +91,8 @@ export class AssetManager {
|
|||||||
if (urlOrLocalRef.startsWith('local:')) {
|
if (urlOrLocalRef.startsWith('local:')) {
|
||||||
const key = urlOrLocalRef.slice('local:'.length)
|
const key = urlOrLocalRef.slice('local:'.length)
|
||||||
const p = this.byOriginal.get(key)
|
const p = this.byOriginal.get(key)
|
||||||
if (p) this.referenced.add(key)
|
|
||||||
return p ?? urlOrLocalRef
|
return p ?? urlOrLocalRef
|
||||||
}
|
}
|
||||||
return 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}`
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
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$/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
const obj: AssetsIndex = { images, fonts, other }
|
|
||||||
const json = JSON.stringify(obj, null, 2)
|
|
||||||
return new TextEncoder().encode(json)
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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$/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildAssetsMetadata(am: AssetManager): Uint8Array {
|
|
||||||
const list = am.list()
|
|
||||||
const referenced = new Set(am.referencedList())
|
|
||||||
const items: AssetMetaItem[] = list.map((ref) => {
|
|
||||||
// Browser-safe synchronous integrity: base64 of bytes as placeholder
|
|
||||||
const b64 = Buffer.from(ref.bytes).toString('base64')
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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>')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
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.
|
|
||||||
*/
|
|
||||||
function doPost(e){
|
|
||||||
// TODO: handle submission to Google Sheets
|
|
||||||
return ContentService.createTextOutput('OK')
|
|
||||||
}
|
|
||||||
`
|
|
||||||
const readme = `Google Sheets Apps Script Template\n\n1) Apps Script에서 새 프로젝트를 만들고 아래 Code.gs를 붙여넣으세요.\n2) 배포 > 웹 앱으로 배포 후 URL을 복사해 사용하세요.`
|
|
||||||
out['sheets/Code.gs'] = toBytes(code)
|
|
||||||
out['sheets/README.txt'] = toBytes(readme)
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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: 'Hero Edge',
|
|
||||||
locale: 'en',
|
|
||||||
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
|
|
||||||
sections: [
|
|
||||||
{ type: 'hero', props: { heading: 'Welcome', subheading: ' ', imageUrl: 'https://img.example/h.png' } },
|
|
||||||
] 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 - hero edge cases', () => {
|
|
||||||
it('omits subheading when whitespace-only', () => {
|
|
||||||
const out = exportPage(makePage())
|
|
||||||
expect(out.html).toContain('<h1 id="hero-heading">Welcome</h1>')
|
|
||||||
expect(out.html).not.toMatch(/<div class="text">[\s\S]*<p>\s*<\/p>/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('trims subheading content', () => {
|
|
||||||
const out = exportPage(makePage({ sections: [ { type: 'hero', props: { heading: 'Welcome', subheading: ' Hello world ', imageUrl: 'https://img.example/h.png' } } ] as Page['sections'] }))
|
|
||||||
expect(out.html).toContain('<p>Hello world</p>')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
+20
-59
@@ -2,22 +2,10 @@ import type { Page, FormField } from '@/lib/schema/page'
|
|||||||
import type { AssetManager } from '@/lib/assets/assetManager'
|
import type { AssetManager } from '@/lib/assets/assetManager'
|
||||||
|
|
||||||
export type Exported = { html: string; css: string; js: string }
|
export type Exported = { html: string; css: string; js: string }
|
||||||
export type ExportOptions = {
|
export type ExportOptions = { assetManager?: AssetManager }
|
||||||
assetManager?: AssetManager
|
|
||||||
fontPreconnect?: boolean
|
|
||||||
fontPreload?: boolean
|
|
||||||
robotsMeta?: string
|
|
||||||
imageLoading?: 'lazy' | 'eager'
|
|
||||||
imageDecoding?: 'async' | 'sync'
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFaq(section: { props: { items: Array<{ q: string; a: string }> } }) {
|
function renderFaq(section: { props: { items: Array<{ q: string; a: string }> } }) {
|
||||||
const itemsRaw: Array<{ q: string; a: string }> = Array.isArray(section.props.items) ? section.props.items : []
|
const items: Array<{ q: string; a: string }> = Array.isArray(section.props.items) ? section.props.items : []
|
||||||
const isBlank = (s: string | undefined | null) => !s || s.trim().length === 0
|
|
||||||
const items = itemsRaw
|
|
||||||
.map((it) => ({ q: (it.q || '').trim(), a: (it.a || '').trim() }))
|
|
||||||
// drop when both question and answer are blank
|
|
||||||
.filter((it) => !(isBlank(it.q) && isBlank(it.a)))
|
|
||||||
return `
|
return `
|
||||||
<section class="faq" aria-labelledby="faq-heading">
|
<section class="faq" aria-labelledby="faq-heading">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -39,9 +27,7 @@ function renderFaq(section: { props: { items: Array<{ q: string; a: string }> }
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderFeatures(section: { props: { items: string[] } }) {
|
function renderFeatures(section: { props: { items: string[] } }) {
|
||||||
const items: string[] = (Array.isArray(section.props?.items) ? section.props.items : [])
|
const items: string[] = Array.isArray(section.props?.items) ? section.props.items : []
|
||||||
.map((s) => String(s ?? '').trim())
|
|
||||||
.filter((s) => s.length > 0)
|
|
||||||
return `
|
return `
|
||||||
<section class="features" aria-labelledby="features-heading">
|
<section class="features" aria-labelledby="features-heading">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -55,9 +41,7 @@ function renderFeatures(section: { props: { items: string[] } }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderBenefits(section: { props: { items: string[] } }) {
|
function renderBenefits(section: { props: { items: string[] } }) {
|
||||||
const items: string[] = (Array.isArray(section.props?.items) ? section.props.items : [])
|
const items: string[] = Array.isArray(section.props?.items) ? section.props.items : []
|
||||||
.map((s) => String(s ?? '').trim())
|
|
||||||
.filter((s) => s.length > 0)
|
|
||||||
return `
|
return `
|
||||||
<section class="benefits" aria-labelledby="benefits-heading">
|
<section class="benefits" aria-labelledby="benefits-heading">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -71,12 +55,9 @@ function renderBenefits(section: { props: { items: string[] } }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderTestimonials(section: { props: { items: Array<{ author: string; quote: string }> } }) {
|
function renderTestimonials(section: { props: { items: Array<{ author: string; quote: string }> } }) {
|
||||||
const items: Array<{ author: string; quote: string }> = (Array.isArray(section.props?.items)
|
const items: Array<{ author: string; quote: string }> = Array.isArray(section.props?.items)
|
||||||
? section.props.items
|
? section.props.items
|
||||||
: [])
|
: []
|
||||||
.map((it) => ({ author: (it.author || '').trim(), quote: (it.quote || '').trim() }))
|
|
||||||
// drop when both are blank
|
|
||||||
.filter((it) => !(it.author.length === 0 && it.quote.length === 0))
|
|
||||||
return `
|
return `
|
||||||
<section class="testimonials" aria-labelledby="testimonials-heading">
|
<section class="testimonials" aria-labelledby="testimonials-heading">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -85,8 +66,8 @@ function renderTestimonials(section: { props: { items: Array<{ author: string; q
|
|||||||
.map(
|
.map(
|
||||||
(it) => `
|
(it) => `
|
||||||
<figure class="testimonial">
|
<figure class="testimonial">
|
||||||
<blockquote>“${escapeHtml(it.quote)}”</blockquote>
|
<blockquote>“${escapeHtml(it.quote || '')}”</blockquote>
|
||||||
${it.author.length > 0 ? `<figcaption>— ${escapeHtml(it.author)}</figcaption>` : ''}
|
<figcaption>— ${escapeHtml(it.author || '')}</figcaption>
|
||||||
</figure>`
|
</figure>`
|
||||||
)
|
)
|
||||||
.join('')}
|
.join('')}
|
||||||
@@ -96,9 +77,7 @@ function renderTestimonials(section: { props: { items: Array<{ author: string; q
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderCta(section: { props: { text: string; href?: string } }) {
|
function renderCta(section: { props: { text: string; href?: string } }) {
|
||||||
const href = section.props.href
|
const { text, href } = section.props
|
||||||
const text = (section.props.text || '').trim()
|
|
||||||
if (text.length === 0) return ''
|
|
||||||
return `
|
return `
|
||||||
<section class="cta" aria-labelledby="cta-heading">
|
<section class="cta" aria-labelledby="cta-heading">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -117,23 +96,19 @@ const escapeHtml = (s: string) =>
|
|||||||
.replaceAll('"', '"')
|
.replaceAll('"', '"')
|
||||||
|
|
||||||
function renderHero(section: { props: { heading: string; subheading?: string; imageUrl: string } }, opts?: ExportOptions) {
|
function renderHero(section: { props: { heading: string; subheading?: string; imageUrl: string } }, opts?: ExportOptions) {
|
||||||
const { heading } = section.props
|
const { heading, subheading } = section.props
|
||||||
const subheadingRaw = section.props.subheading
|
|
||||||
const subheading = subheadingRaw ? subheadingRaw.trim() : ''
|
|
||||||
const am = opts?.assetManager
|
const am = opts?.assetManager
|
||||||
const imageUrlRaw: string = section.props.imageUrl
|
const imageUrlRaw: string = section.props.imageUrl
|
||||||
const imageUrl = am ? am.rewriteUrl(imageUrlRaw) : imageUrlRaw
|
const imageUrl = am ? am.rewriteUrl(imageUrlRaw) : imageUrlRaw
|
||||||
const loading = opts?.imageLoading ?? 'lazy'
|
|
||||||
const decoding = opts?.imageDecoding ?? 'async'
|
|
||||||
return `
|
return `
|
||||||
<section class="hero" aria-labelledby="hero-heading">
|
<section class="hero" aria-labelledby="hero-heading">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="text">
|
<div class="text">
|
||||||
<h1 id="hero-heading">${escapeHtml(heading)}</h1>
|
<h1 id="hero-heading">${escapeHtml(heading)}</h1>
|
||||||
${subheading.length > 0 ? `<p>${escapeHtml(subheading)}</p>` : ''}
|
${subheading ? `<p>${escapeHtml(subheading)}</p>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="media">
|
<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="lazy" decoding="async" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -231,7 +206,7 @@ function renderForm(page: Page) {
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildHead(page: Page, opts?: ExportOptions) {
|
function buildHead(page: Page) {
|
||||||
const ogImage = page.seo.ogImage
|
const ogImage = page.seo.ogImage
|
||||||
const analyticsParts: string[] = []
|
const analyticsParts: string[] = []
|
||||||
const ga = page.analytics?.ga4MeasurementId
|
const ga = page.analytics?.ga4MeasurementId
|
||||||
@@ -259,23 +234,10 @@ function buildHead(page: Page, opts?: ExportOptions) {
|
|||||||
</script>`)
|
</script>`)
|
||||||
}
|
}
|
||||||
const custom = page.analytics?.customHeadHtml || ''
|
const custom = page.analytics?.customHeadHtml || ''
|
||||||
const preconnect = opts?.fontPreconnect !== false
|
|
||||||
const robotsMeta = (opts?.robotsMeta && opts.robotsMeta.trim().length > 0) ? `<meta name="robots" content="${escapeHtml(opts!.robotsMeta!)}" />` : ''
|
|
||||||
const fontPreload = opts?.fontPreload === true
|
|
||||||
const rawFamily = (page.theme?.fontFamily || '').trim()
|
|
||||||
const fontHref = rawFamily.length > 0 ? `https://fonts.googleapis.com/css2?family=${encodeURIComponent(rawFamily).replace(/%20/g, '+')}&display=swap` : ''
|
|
||||||
return `
|
return `
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="canonical" href="/" />
|
<link rel="canonical" href="/" />
|
||||||
${preconnect ? '<link rel="preconnect" href="https://fonts.googleapis.com" />' : ''}
|
|
||||||
${preconnect ? '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />' : ''}
|
|
||||||
${fontPreload && fontHref ? `<link rel="preload" as="style" href="${fontHref}" />` : ''}
|
|
||||||
${fontPreload && fontHref ? `<link rel="stylesheet" href="${fontHref}" media="print" onload="this.media='all'" />` : ''}
|
|
||||||
${fontPreload && fontHref ? `<noscript><link rel="stylesheet" href="${fontHref}" /></noscript>` : ''}
|
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
|
||||||
<meta name="theme-color" content="${escapeHtml(page.theme.primaryColor)}" />
|
|
||||||
${robotsMeta}
|
|
||||||
<title>${escapeHtml(page.seo.title)}</title>
|
<title>${escapeHtml(page.seo.title)}</title>
|
||||||
<meta name="description" content="${escapeHtml(page.seo.description)}" />
|
<meta name="description" content="${escapeHtml(page.seo.description)}" />
|
||||||
<meta property="og:title" content="${escapeHtml(page.seo.title)}" />
|
<meta property="og:title" content="${escapeHtml(page.seo.title)}" />
|
||||||
@@ -300,20 +262,18 @@ function buildCss(page: Page) {
|
|||||||
button:focus, a:focus, input:focus, select:focus, textarea:focus{ outline: 3px solid #2563eb; outline-offset:2px }
|
button:focus, a:focus, input:focus, select:focus, textarea:focus{ outline: 3px solid #2563eb; outline-offset:2px }
|
||||||
.faq-list{ list-style:none; padding:0; margin:0 }
|
.faq-list{ list-style:none; padding:0; margin:0 }
|
||||||
.faq-item{ margin: .75rem 0 }
|
.faq-item{ margin: .75rem 0 }
|
||||||
.faq-q{ font-size: 1rem; font-weight:600; overflow-wrap:anywhere }
|
.faq-q{ font-size: 1rem; font-weight:600; }
|
||||||
.faq-a{ color:#334155; overflow-wrap:anywhere }
|
.faq-a{ color:#334155 }
|
||||||
.btn-cta{ background: var(--primary); color:#fff; padding:.75rem 1rem; border-radius:.5rem; display:inline-block; text-decoration:none }
|
.btn-cta{ background: var(--primary); color:#fff; padding:.75rem 1rem; border-radius:.5rem; display:inline-block; text-decoration:none }
|
||||||
.features{ background:#f8fafc }
|
.features{ background:#f8fafc }
|
||||||
.features-list{ list-style:none; padding:0; margin:0; display:grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: .75rem }
|
.features-list{ list-style:none; padding:0; margin:0; display:grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: .75rem }
|
||||||
.feature-item{ background:#fff; border:1px solid #e2e8f0; border-radius:.5rem; padding:.75rem; overflow-wrap:anywhere }
|
.feature-item{ background:#fff; border:1px solid #e2e8f0; border-radius:.5rem; padding:.75rem }
|
||||||
.benefits-list{ list-style:none; padding:0; margin:0 }
|
|
||||||
.benefit-item{ overflow-wrap:anywhere }
|
|
||||||
.testimonials{ background:#ffffff }
|
.testimonials{ background:#ffffff }
|
||||||
.testimonial{ border-left:4px solid var(--primary); padding:.5rem 1rem; margin: .75rem 0; background:#fefefe }
|
.testimonial{ border-left:4px solid var(--primary); padding:.5rem 1rem; margin: .75rem 0; background:#fefefe }
|
||||||
.testimonial blockquote{ margin:0; font-style:italic; color:#1f2937; overflow-wrap:anywhere }
|
.testimonial blockquote{ margin:0; font-style:italic; color:#1f2937 }
|
||||||
.testimonial figcaption{ color:#475569; margin-top:.25rem }
|
.testimonial figcaption{ color:#475569; margin-top:.25rem }
|
||||||
@media (max-width: 640px){ .container{ padding: 1rem } h1{ font-size: 1.5rem } }
|
@media (max-width: 640px){ .container{ padding: 1rem } h1{ font-size: 1.5rem } }
|
||||||
@media (prefers-color-scheme: dark){ body{ color:#e5e7eb; background:#0b1020 } .feature-item{ background:#0f172a; border-color:#1f2937 } a{ color:#93c5fd } a:hover{ text-decoration:underline } button{ background: var(--primary); color:#fff } .btn-cta{ background: var(--primary); color:#fff } .faq-a{ color:#cbd5e1 } }
|
@media (prefers-color-scheme: dark){ body{ color:#e5e7eb; background:#0b1020 } .feature-item{ background:#0f172a; border-color:#1f2937 } }
|
||||||
@media (prefers-reduced-motion: reduce){ *{ transition: none !important; animation: none !important } }
|
@media (prefers-reduced-motion: reduce){ *{ transition: none !important; animation: none !important } }
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
@@ -357,7 +317,8 @@ export function exportPage(page: Page, opts?: ExportOptions): Exported {
|
|||||||
const html = `<!DOCTYPE html>
|
const html = `<!DOCTYPE html>
|
||||||
<html lang="${escapeHtml(page.locale)}">
|
<html lang="${escapeHtml(page.locale)}">
|
||||||
<head>
|
<head>
|
||||||
${buildHead(page, opts)}
|
${buildHead(page)}
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<a href="#main" class="skip-link">Skip to content</a>
|
<a href="#main" class="skip-link">Skip to content</a>
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { exportPage, type ExportOptions } from '@/lib/exporter/html'
|
|
||||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
|
||||||
|
|
||||||
function makePage(): Page {
|
|
||||||
return pageSchema.parse({
|
|
||||||
title: 'Img Perf',
|
|
||||||
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('이미지 loading/decoding 토글', () => {
|
|
||||||
it('기본은 loading="lazy", decoding="async"', () => {
|
|
||||||
const page = makePage()
|
|
||||||
const out = exportPage(page)
|
|
||||||
expect(out.html).toContain('loading="lazy"')
|
|
||||||
expect(out.html).toContain('decoding="async"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('옵션으로 eager/sync로 바꿀 수 있다', () => {
|
|
||||||
const page = makePage()
|
|
||||||
const opts: ExportOptions = { imageLoading: 'eager', imageDecoding: 'sync' }
|
|
||||||
const out = exportPage(page, opts)
|
|
||||||
expect(out.html).toContain('loading="eager"')
|
|
||||||
expect(out.html).toContain('decoding="sync"')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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: 'Head Links',
|
|
||||||
locale: 'en',
|
|
||||||
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
|
|
||||||
sections: [],
|
|
||||||
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
|
||||||
seo: { title: 't', description: 'd' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Exporter - head 링크 토큰', () => {
|
|
||||||
it('site.webmanifest 링크를 포함한다', () => {
|
|
||||||
const out = exportPage(makePage())
|
|
||||||
expect(out.html).toMatch(/<link rel="manifest" href="\/site\.webmanifest"\s*\/>/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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: 'Auto Extras',
|
|
||||||
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와 site.webmanifest가 extras에 포함된다', () => {
|
|
||||||
const page = makePage()
|
|
||||||
const extras = buildExtrasForPage(page)
|
|
||||||
const keys = Object.keys(extras)
|
|
||||||
expect(keys).toContain('robots.txt')
|
|
||||||
expect(keys).toContain('site.webmanifest')
|
|
||||||
|
|
||||||
const robots = Buffer.from(extras['robots.txt']).toString('utf8')
|
|
||||||
expect(robots).toContain('User-agent: *')
|
|
||||||
|
|
||||||
const manifestStr = Buffer.from(extras['site.webmanifest']).toString('utf8')
|
|
||||||
const manifest = JSON.parse(manifestStr)
|
|
||||||
expect(manifest.start_url).toBeDefined()
|
|
||||||
expect(manifest.display).toBeDefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { exportPage, type ExportOptions } from '@/lib/exporter/html'
|
|
||||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
|
||||||
|
|
||||||
function makePage(): Page {
|
|
||||||
return pageSchema.parse({
|
|
||||||
title: 'Meta Robots',
|
|
||||||
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('meta robots', () => {
|
|
||||||
it('기본적으로 head에 robots 메타가 없다', () => {
|
|
||||||
const page = makePage()
|
|
||||||
const out = exportPage(page)
|
|
||||||
expect(out.html).not.toContain('<meta name="robots"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('옵션으로 robots 메타를 삽입할 수 있다', () => {
|
|
||||||
const page = makePage()
|
|
||||||
const opts: ExportOptions = { robotsMeta: 'noindex,nofollow' }
|
|
||||||
const out = exportPage(page, opts)
|
|
||||||
expect(out.html).toContain('<meta name="robots" content="noindex,nofollow" />')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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: 'Edge',
|
|
||||||
locale: 'en',
|
|
||||||
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
|
|
||||||
sections: [
|
|
||||||
{ type: 'features', props: { items: ['One', ' ', ' ', 'Two'] } },
|
|
||||||
{ type: 'benefits', props: { items: ['A', ' ', ' ', 'B'] } },
|
|
||||||
{ type: 'faq', props: { items: [
|
|
||||||
{ q: ' ', a: ' ' },
|
|
||||||
{ q: 'Valid Q', a: ' ' },
|
|
||||||
{ q: 'Another', a: 'Answer' },
|
|
||||||
] } },
|
|
||||||
{ type: 'testimonials', props: { items: [
|
|
||||||
{ author: ' ', quote: ' ' },
|
|
||||||
{ author: 'Jane', quote: ' Great product ' },
|
|
||||||
] } },
|
|
||||||
] 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 - sections edge cases', () => {
|
|
||||||
it('filters out whitespace-only items and keeps valid ones', () => {
|
|
||||||
const out = exportPage(makePage())
|
|
||||||
const html = out.html
|
|
||||||
|
|
||||||
// features: only One, Two
|
|
||||||
expect(html).toContain('<li class="feature-item">One</li>')
|
|
||||||
expect(html).toContain('<li class="feature-item">Two</li>')
|
|
||||||
expect(html).not.toMatch(/<li class="feature-item"><\/li>/)
|
|
||||||
|
|
||||||
// benefits: only A, B
|
|
||||||
expect(html).toContain('<li class="benefit-item">A</li>')
|
|
||||||
expect(html).toContain('<li class="benefit-item">B</li>')
|
|
||||||
|
|
||||||
// faq: drop fully empty, keep items even if answer empty when question is valid
|
|
||||||
expect(html).toContain('<h3 class="faq-q">Valid Q</h3>')
|
|
||||||
// testimonials: drop fully empty, keep trimmed quote
|
|
||||||
expect(html).toContain('<blockquote>“Great product”</blockquote>')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('adds word-break tokens to avoid overflow for long words', () => {
|
|
||||||
const out = exportPage(makePage())
|
|
||||||
const css = out.css
|
|
||||||
expect(css).toMatch(/\.feature-item\{[\s\S]*(?:word-break:break-word|overflow-wrap:anywhere)/i)
|
|
||||||
expect(css).toMatch(/\.benefit-item\{[\s\S]*(?:word-break:break-word|overflow-wrap:anywhere)/i)
|
|
||||||
expect(css).toMatch(/\.testimonial blockquote\{[\s\S]*(?:word-break:break-word|overflow-wrap:anywhere)/i)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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: 'Edge P3',
|
|
||||||
locale: 'en',
|
|
||||||
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
|
|
||||||
sections: [
|
|
||||||
{ type: 'faq', props: { items: [ { q: 'VeryVeryVeryVeryVeryLongWordWithoutSpaces', a: 'AnotherSuperLongUnbreakableWord' } ] } },
|
|
||||||
{ type: 'testimonials', props: { items: [
|
|
||||||
{ author: ' ', quote: 'Great!' },
|
|
||||||
{ author: 'Alice', quote: 'Nice' },
|
|
||||||
] } },
|
|
||||||
] 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 - phase3 edge cases', () => {
|
|
||||||
it('omits testimonial author line when author is blank', () => {
|
|
||||||
const out = exportPage(makePage())
|
|
||||||
const html = out.html
|
|
||||||
// First item author blank -> no figcaption for that blockquote
|
|
||||||
expect(html).toMatch(/<blockquote>“Great!\”<\/blockquote>[\s\S]*?<\/figure>/)
|
|
||||||
// Ensure the next item with author renders figcaption
|
|
||||||
expect(html).toMatch(/<figcaption>— Alice<\/figcaption>/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('adds word-break/overflow-wrap for FAQ q/a to avoid overflow', () => {
|
|
||||||
const out = exportPage(makePage())
|
|
||||||
const css = out.css
|
|
||||||
expect(css).toMatch(/\.faq-q\{[\s\S]*(?:overflow-wrap:anywhere|word-break:break-word)/i)
|
|
||||||
expect(css).toMatch(/\.faq-a\{[\s\S]*(?:overflow-wrap:anywhere|word-break:break-word)/i)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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: 'SEO/Perf 2',
|
|
||||||
locale: 'en',
|
|
||||||
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
|
||||||
sections: [
|
|
||||||
{ type: 'hero', props: { heading: 'Hello', subheading: 'World', imageUrl: 'https://img.example/hero.png' } },
|
|
||||||
],
|
|
||||||
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
|
||||||
seo: { title: 'SEO2', description: 'Desc2' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Exporter - theme-color meta and hero image intrinsic size', () => {
|
|
||||||
it('includes theme-color meta using page.theme.primaryColor', () => {
|
|
||||||
const out = exportPage(makePage())
|
|
||||||
const html = out.html
|
|
||||||
expect(html).toMatch(/<meta name="theme-color" content="#0ea5e9"\s*\/>/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders hero image with width/height attributes for CLS reduction', () => {
|
|
||||||
const out = exportPage(makePage())
|
|
||||||
const html = out.html
|
|
||||||
expect(html).toMatch(/<img[^>]*\swidth="[0-9]+"/)
|
|
||||||
expect(html).toMatch(/<img[^>]*\sheight="[0-9]+"/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { buildZip } from '@/lib/exporter/zip'
|
|
||||||
import { exportPage } from '@/lib/exporter/html'
|
|
||||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
|
||||||
import { buildExtrasForPage } from '@/lib/exporter/extras'
|
|
||||||
|
|
||||||
function makePageNoAction(): Page {
|
|
||||||
return pageSchema.parse({
|
|
||||||
title: 'Sheets Tpl',
|
|
||||||
locale: 'en',
|
|
||||||
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
|
|
||||||
sections: [ { type: 'hero', props: { heading: 'Hello', subheading: 'World', imageUrl: 'https://img/h.png' } } ],
|
|
||||||
form: { actionUrl: '', fields: [ { type: 'text', name: 'email', label: 'Email', required: true } ], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
|
||||||
seo: { title: 't', description: 'd' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function makePageWithAction(): Page {
|
|
||||||
return pageSchema.parse({
|
|
||||||
title: 'Sheets Off',
|
|
||||||
locale: 'en',
|
|
||||||
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
|
|
||||||
sections: [ { type: 'hero', props: { heading: 'Hello', subheading: 'World', imageUrl: 'https://img/h.png' } } ],
|
|
||||||
form: { actionUrl: 'https://example.com/submit', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
|
||||||
seo: { title: 't', description: 'd' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Sheets 템플릿 Export', () => {
|
|
||||||
it('actionUrl 미지정 시 extras에 Sheets 템플릿 파일을 포함한다', async () => {
|
|
||||||
const page = makePageNoAction()
|
|
||||||
const extras = buildExtrasForPage(page)
|
|
||||||
expect(Object.keys(extras)).toContain('sheets/Code.gs')
|
|
||||||
expect(Object.keys(extras)).toContain('sheets/README.txt')
|
|
||||||
|
|
||||||
const out = exportPage(page)
|
|
||||||
const zipBytes = await buildZip({ html: out.html, css: out.css, js: out.js, assets: {}, extras })
|
|
||||||
expect(zipBytes.byteLength).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('actionUrl 지정 시 extras에는 Sheets 템플릿이 포함되지 않는다', () => {
|
|
||||||
const page = makePageWithAction()
|
|
||||||
const extras = buildExtrasForPage(page)
|
|
||||||
expect(Object.keys(extras)).not.toContain('sheets/Code.gs')
|
|
||||||
expect(Object.keys(extras)).not.toContain('sheets/README.txt')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -5,8 +5,6 @@ export type BuildZipInput = {
|
|||||||
css: string
|
css: string
|
||||||
js: string
|
js: string
|
||||||
assets?: Record<string, Uint8Array>
|
assets?: Record<string, Uint8Array>
|
||||||
// root-level extra files like favicon.ico, site.webmanifest, robots.txt
|
|
||||||
extras?: Record<string, Uint8Array>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildZip(input: BuildZipInput): Promise<Uint8Array> {
|
export async function buildZip(input: BuildZipInput): Promise<Uint8Array> {
|
||||||
@@ -21,22 +19,6 @@ export async function buildZip(input: BuildZipInput): Promise<Uint8Array> {
|
|||||||
zip.file(path, buf as unknown as Buffer)
|
zip.file(path, buf as unknown as Buffer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (input.extras) {
|
|
||||||
// validate manifest if present
|
|
||||||
if (Object.prototype.hasOwnProperty.call(input.extras, 'site.webmanifest')) {
|
|
||||||
try {
|
|
||||||
const manifestBytes = input.extras['site.webmanifest']
|
|
||||||
const manifestStr = Buffer.from(manifestBytes).toString('utf8')
|
|
||||||
JSON.parse(manifestStr)
|
|
||||||
} catch {
|
|
||||||
throw new Error('Invalid site.webmanifest JSON')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const [path, bytes] of Object.entries(input.extras)) {
|
|
||||||
const buf = Buffer.from(bytes)
|
|
||||||
zip.file(path, buf as unknown as Buffer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const content = await zip.generateAsync({ type: 'uint8array' })
|
const content = await zip.generateAsync({ type: 'uint8array' })
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import JSZip from 'jszip'
|
|
||||||
import { exportPage } from '@/lib/exporter/html'
|
|
||||||
import { buildZip } from '@/lib/exporter/zip'
|
|
||||||
import { AssetManager } from '@/lib/assets/assetManager'
|
|
||||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
|
||||||
|
|
||||||
function makePageWithLocalAsset(): { page: Page; am: AssetManager } {
|
|
||||||
const am = new AssetManager({ maxBytes: 1024 * 1024, allowedExts: ['png','svg','jpg','jpeg','webp'] })
|
|
||||||
// simulate adding a local image and referencing it by original name via local: prefix
|
|
||||||
const bytes = new Uint8Array([7,8,9])
|
|
||||||
// add as "hero.png"
|
|
||||||
am.add({ name: 'hero.png', type: 'image/png', bytes })
|
|
||||||
|
|
||||||
const base: Page = pageSchema.parse({
|
|
||||||
title: 'Integrated ZIP',
|
|
||||||
locale: 'en',
|
|
||||||
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
|
|
||||||
sections: [
|
|
||||||
{ type: 'hero', props: { heading: 'Hello', subheading: 'World', imageUrl: 'local:hero.png' } },
|
|
||||||
{ type: 'cta', props: { text: 'Go', href: '#' } },
|
|
||||||
],
|
|
||||||
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
|
||||||
analytics: { ga4MeasurementId: 'G-TEST', metaPixelId: '', customHeadHtml: '' },
|
|
||||||
seo: { title: 't', description: 'd' },
|
|
||||||
})
|
|
||||||
return { page: base, am }
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Export ZIP 통합 검증', () => {
|
|
||||||
it('exportPage 출력 + buildZip 결과에서 assets 경로/메타가 보존된다', async () => {
|
|
||||||
const { page, am } = makePageWithLocalAsset()
|
|
||||||
const out = exportPage(page, { assetManager: am })
|
|
||||||
const assets = am.toZipStructure()
|
|
||||||
const zipBytes = await buildZip({ html: out.html, css: out.css, js: out.js, assets })
|
|
||||||
|
|
||||||
const zip = await JSZip.loadAsync(zipBytes)
|
|
||||||
const names = Object.keys(zip.files)
|
|
||||||
// assets 파일이 존재해야 함
|
|
||||||
const assetKey = Object.keys(assets)[0]
|
|
||||||
expect(names).toContain(assetKey)
|
|
||||||
|
|
||||||
// index.html에 canonical/meta/ga4 스니펫 보존
|
|
||||||
const html = await zip.files['index.html'].async('string')
|
|
||||||
expect(html).toMatch(/<link rel="canonical" href="\//)
|
|
||||||
expect(html).toMatch(/<meta name="description" content="d"/)
|
|
||||||
expect(html).toMatch(/gtag\('config', 'G-TEST'\)/)
|
|
||||||
|
|
||||||
// hero 이미지가 재작성된 경로를 사용한다
|
|
||||||
const match = html.match(/<img[^>]*src="([^"]+)"/)
|
|
||||||
expect(match && match[1]).toBe(assetKey)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import JSZip from 'jszip'
|
|
||||||
import { buildZip } from '@/lib/exporter/zip'
|
|
||||||
|
|
||||||
describe('Export ZIP 메타 보존 검증', () => {
|
|
||||||
it('index.html의 SEO/OG/Analytics 메타가 그대로 보존된다', async () => {
|
|
||||||
const html = `<!DOCTYPE html><html><head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<link rel="canonical" href="/" />
|
|
||||||
<meta name="description" content="desc" />
|
|
||||||
<meta property="og:title" content="OT" />
|
|
||||||
<meta property="og:description" content="OD" />
|
|
||||||
<script>window.dataLayer=[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());</script>
|
|
||||||
</head><body>ok</body></html>`
|
|
||||||
const css = 'body{color:#222}'
|
|
||||||
const js = 'console.log("ok")'
|
|
||||||
|
|
||||||
const zipBytes = await buildZip({ html, css, js, assets: {} })
|
|
||||||
const zip = await JSZip.loadAsync(zipBytes)
|
|
||||||
const htmlContent = await zip.files['index.html'].async('string')
|
|
||||||
|
|
||||||
expect(htmlContent).toMatch(/<link rel="canonical" href="\//)
|
|
||||||
expect(htmlContent).toMatch(/<meta name="description" content="desc"/)
|
|
||||||
expect(htmlContent).toMatch(/<meta property="og:title" content="OT"/)
|
|
||||||
expect(htmlContent).toMatch(/<meta property="og:description" content="OD"/)
|
|
||||||
expect(htmlContent).toMatch(/window\.dataLayer=\[\];/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import JSZip from 'jszip'
|
|
||||||
import { buildZip } from '@/lib/exporter/zip'
|
|
||||||
import type { BuildZipInput } from '@/lib/exporter/zip'
|
|
||||||
|
|
||||||
const TE = new TextEncoder()
|
|
||||||
|
|
||||||
describe('Export ZIP 검증 강화 4차: 루트 파일(favicon/manifest/robots)', () => {
|
|
||||||
it('루트 파일들을 포함한다: favicon.ico / site.webmanifest / robots.txt', async () => {
|
|
||||||
const manifest = {
|
|
||||||
name: 'Landing Builder',
|
|
||||||
short_name: 'LB',
|
|
||||||
start_url: '/',
|
|
||||||
display: 'standalone',
|
|
||||||
icons: [{ src: '/favicon.png', sizes: '512x512', type: 'image/png' }],
|
|
||||||
}
|
|
||||||
const input: BuildZipInput = {
|
|
||||||
html: '<!DOCTYPE html><html><head><title>a</title></head><body>ok</body></html>',
|
|
||||||
css: 'body{color:#111}',
|
|
||||||
js: 'console.log("ok")',
|
|
||||||
assets: {},
|
|
||||||
extras: {
|
|
||||||
'favicon.ico': new Uint8Array([0, 1, 2, 3]),
|
|
||||||
'site.webmanifest': TE.encode(JSON.stringify(manifest)),
|
|
||||||
'robots.txt': TE.encode('User-agent: *\nDisallow:'),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const zipBytes = await buildZip(input)
|
|
||||||
|
|
||||||
const zip = await JSZip.loadAsync(zipBytes)
|
|
||||||
const names = Object.keys(zip.files)
|
|
||||||
expect(names).toContain('favicon.ico')
|
|
||||||
expect(names).toContain('site.webmanifest')
|
|
||||||
expect(names).toContain('robots.txt')
|
|
||||||
|
|
||||||
const robots = await zip.files['robots.txt'].async('string')
|
|
||||||
expect(robots).toMatch(/User-agent: \*/)
|
|
||||||
|
|
||||||
const parsed = JSON.parse(await zip.files['site.webmanifest'].async('string'))
|
|
||||||
expect(parsed.name).toBe('Landing Builder')
|
|
||||||
expect(parsed.icons[0].type).toBe('image/png')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('잘못된 manifest JSON이면 실패한다', async () => {
|
|
||||||
const badInput: BuildZipInput = {
|
|
||||||
html: '<!DOCTYPE html><html><head><title>a</title></head><body>ok</body></html>',
|
|
||||||
css: 'body{color:#111}',
|
|
||||||
js: 'console.log("ok")',
|
|
||||||
extras: { 'site.webmanifest': TE.encode('{ invalid json') },
|
|
||||||
}
|
|
||||||
await expect(buildZip(badInput)).rejects.toThrow()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { buildZip } from '@/lib/exporter/zip'
|
|
||||||
import { exportPage } from '@/lib/exporter/html'
|
|
||||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
|
||||||
import { buildExtrasForPage } from '@/lib/exporter/extras'
|
|
||||||
|
|
||||||
function makePage(): Page {
|
|
||||||
return pageSchema.parse({
|
|
||||||
title: 'ZIP Phase5',
|
|
||||||
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('ZIP 검증 Phase5', () => {
|
|
||||||
it('robots.txt 베이스라인과 site.webmanifest JSON 파싱 검증', async () => {
|
|
||||||
const page = makePage()
|
|
||||||
const extras = buildExtrasForPage(page)
|
|
||||||
|
|
||||||
// baseline robots
|
|
||||||
const robots = Buffer.from(extras['robots.txt']).toString('utf8')
|
|
||||||
expect(robots).toContain('User-agent: *')
|
|
||||||
|
|
||||||
// manifest JSON parseable
|
|
||||||
const manifestStr = Buffer.from(extras['site.webmanifest']).toString('utf8')
|
|
||||||
const manifest = JSON.parse(manifestStr)
|
|
||||||
expect(manifest && typeof manifest === 'object').toBe(true)
|
|
||||||
expect(manifest.start_url).toBeDefined()
|
|
||||||
expect(manifest.display).toBeDefined()
|
|
||||||
|
|
||||||
// ZIP build should succeed
|
|
||||||
const out = exportPage(page)
|
|
||||||
const zipBytes = await buildZip({ html: out.html, css: out.css, js: out.js, assets: {}, extras })
|
|
||||||
expect(zipBytes.byteLength).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import JSZip from 'jszip'
|
|
||||||
import { buildZip } from '@/lib/exporter/zip'
|
|
||||||
|
|
||||||
const TE = new TextEncoder()
|
|
||||||
|
|
||||||
describe('Export ZIP 검증 강화', () => {
|
|
||||||
it('assets가 비어있을 때 index.html/styles.css/app.js만 포함한다', async () => {
|
|
||||||
const zipBytes = await buildZip({
|
|
||||||
html: '<!DOCTYPE html><html><head><title>a</title></head><body>ok</body></html>',
|
|
||||||
css: 'body{color:#111}',
|
|
||||||
js: 'console.log("ok")',
|
|
||||||
assets: {},
|
|
||||||
})
|
|
||||||
const zip = await JSZip.loadAsync(zipBytes)
|
|
||||||
const names = Object.keys(zip.files).sort()
|
|
||||||
expect(names).toEqual(['app.js','index.html','styles.css'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('assets가 있으면 모든 파일을 그대로 포함한다(서브폴더 포함)', async () => {
|
|
||||||
const assets = {
|
|
||||||
'assets/img/logo.png': new Uint8Array([0,1,2,3]),
|
|
||||||
'assets/svg/icon.svg': TE.encode('<svg></svg>'),
|
|
||||||
'assets/fonts/inter.woff2': new Uint8Array([4,5,6]),
|
|
||||||
}
|
|
||||||
const zipBytes = await buildZip({
|
|
||||||
html: '<!DOCTYPE html><html><head><title>a</title></head><body>ok</body></html>',
|
|
||||||
css: 'body{color:#111}',
|
|
||||||
js: 'console.log("ok")',
|
|
||||||
assets,
|
|
||||||
})
|
|
||||||
const zip = await JSZip.loadAsync(zipBytes)
|
|
||||||
const names = Object.keys(zip.files)
|
|
||||||
expect(names).toContain('index.html')
|
|
||||||
expect(names).toContain('styles.css')
|
|
||||||
expect(names).toContain('app.js')
|
|
||||||
expect(names).toContain('assets/img/logo.png')
|
|
||||||
expect(names).toContain('assets/svg/icon.svg')
|
|
||||||
expect(names).toContain('assets/fonts/inter.woff2')
|
|
||||||
|
|
||||||
const logo = await zip.files['assets/img/logo.png'].async('uint8array')
|
|
||||||
expect(Array.from(logo)).toEqual([0,1,2,3])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user