에디터에서 본 화면과 발행된 화면이 달랐다. 렌더러를 두 벌 들고 있었기 때문이다 —
캔버스는 `builder/canvas/variants/*` 25종, 발행본은 `site/src/sections/*`.
Playwright 로 재 보니 아예 다른 물건이었다(2026-09-09, 1024px):
발행본 15섹션 · 에디터 12섹션 · 겹치는 건 4개뿐, 이름도 달랐다
(gallery↔photos · location↔map · guide↔local)
겹치는 4개조차 높이가 달랐다(info 488↔535 · booking 242↔487 · itinerary 881↔383)
소스를 하나로 모은다. 편집·미리보기 둘 다 발행본 렌더러가 그린다.
**데이터도 한 벌** — `GET /v1/place/{id}/site/preview` 가 발행이 굽는 것과 **같은 함수**
(`build_snapshot` → `to_site_payload`)로 payload 를 만든다. DB 도 파일도 건드리지 않는다.
**왜 iframe 인가** — 컴포넌트만 같게 해서는 안 됐다. 미디어 쿼리는 창 폭을 보는데 실제
사이트 폭은 그 안의 프레임이라, 그리드 컬럼 수가 어긋나 섹션이 두 배씩 길어졌다
(festival 2560→6027 · guide 1168→2168). iframe 은 자체 뷰포트를 가져 발행본과 같은 폭을 본다.
폭만이 아니라 **높이도** 준다 — 히어로가 `clamp(24rem, 62vh, 36rem)` 이라 낮은 iframe 에서는
하한에 걸렸다(384 ↔ 발행본 576). 자리에 안 들어가면 transform 으로 줄인다: 크기는 그대로,
그림만 줄여야 미디어 쿼리가 안 흔들린다.
**색·서체도 한 벌** — `themeVars(payload)` · `fontHref(payload)`. 셸에는 발행본 `<head>` 의
폰트 링크가 없어 글자만 기본 산세리프로 떨어졌다(지오메트리는 같은데 픽셀 차이 92%).
**에디터가 저장된 템플릿을 안 읽던 것** — `applyTheme` 이 섹션·색팔레트는 되살리는데
templateId 를 빠뜨렸다. templateId 는 theme JSON 이 아니라 `sites.template_id` **컬럼**이라
저장 경로가 다른데 읽는 쪽이 theme 만 봤다. 사장님이 '옛 항구' 를 골라 발행해도 다시
들어오면 편집 화면만 흰 바탕·고딕이었다.
**고르기는 iframe 안에서** — 같은 오리진이라 안쪽 문서에 직접 리스너를 건다. 어느 섹션인지는
`data-editor-id` 로 안다(화면 id `gallery` ↔ 설정 id `photos`; `display:contents` 라 레이아웃
무영향). 표시는 outline 이다 — 상자 크기를 바꾸지 않아 발행본과 픽셀이 그대로다.
곁들여 정리한 것
- 켤 수 없는 섹션 둘(`pricing`·`planner`)을 뗐다 — 기본표에도 [+섹션 추가]에도 없고 DB 참조 0건.
- 반대로 `event`(소식)는 기본표가 켜서 **발행되는데** 채울 UI 가 없었다. 명세를 넣는다.
이 아이템만 프롬프트가 "찾아라" 가 아니라 **"옮겨 적어라"** 다 — 이 가게에서 지금 하는
일이라 모델이 알 수 없고, 지어내면 손님이 없는 행사를 보고 찾아온다.
- 예약 버튼이 "네이버 예약 예약" 이었다. `{bookingLabel} 예약` 을 13개 파일에서 각자 이어
붙이고 있었다 — `bookingActionLabel()` 하나로 모은다.
- `solution/site` 의 별칭을 `@` → `@site` 로 옮겼다(60파일 195건). 두 앱이 '@' 를 각자 자기
src 로 두면 발행본 컴포넌트를 빌더에서 부를 때 **조용히 다른 파일을 잡는다.**
검증(Playwright, 같은 사업장·1024px):
섹션 15 = 15 · 순서 일치 · **한쪽에만 있는 섹션 0개**
15개 전부 높이·글자 수·제목이 정확히 같다
편집·미리보기·발행본 셋 다 --tpl-bg #e4dac0 · Gugi
`/preview` ↔ 발행본 문서 높이 9029 = 9029, 픽셀 차이 2.88%(축제 카드 지연 로딩 타이밍)
tsc -b 통과 · eslint 통과.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
81 lines
5.1 KiB
TypeScript
81 lines
5.1 KiB
TypeScript
import {reactRouter} from '@react-router/dev/vite';
|
|
import tailwindcss from '@tailwindcss/vite';
|
|
import path from 'path';
|
|
import {defineConfig} from 'vite';
|
|
|
|
export default defineConfig({
|
|
// ★ @vitejs/plugin-react 를 따로 넣지 않는다 — reactRouter() 가 안에서 켠다.
|
|
// 둘 다 넣으면 리프레시 런타임이 두 번 주입돼 HMR 이 깨진다.
|
|
plugins: [reactRouter(), tailwindcss()],
|
|
resolve: {
|
|
alias: {
|
|
'@': path.resolve(__dirname, 'src'),
|
|
// css 는 더 구체적인 별칭이 먼저 와야 한다 — 아래 '@o2o/shared' 접두어 규칙이
|
|
// 먼저 걸리면 shared/src/tokens.css(없는 경로)로 떨어진다.
|
|
// 실제 파일은 styles/ 아래에 있고, package.json exports 도 그쪽을 가리킨다.
|
|
'@o2o/shared/tokens.css': path.resolve(__dirname, '../shared/src/styles/tokens.css'),
|
|
'@o2o/shared/base.css': path.resolve(__dirname, '../shared/src/styles/base.css'),
|
|
// 워크스페이스 심볼릭 링크를 타지 않고 소스를 직접 가리킨다 —
|
|
// shared 를 고치면 빌드 없이 HMR 이 바로 돈다.
|
|
'@o2o/shared': path.resolve(__dirname, '../shared/src'),
|
|
// ★ 발행본 렌더러를 **그대로** 쓴다(2026-09-09). 미리보기와 발행본이 컴포넌트를 두 벌
|
|
// 두면 같은 데이터로도 다른 그림이 나온다 — 실측으로 캔버스는 소개 섹션을 설명
|
|
// 문구로 채워 그렸는데 발행본은 데이터가 0자라 섹션째 뺐다. 사장님은 채워진 화면을
|
|
// 보고 발행해 절반이 사라진 페이지를 받았다.
|
|
// 저쪽 별칭이 '@' 가 아니라 '@site' 인 것도 이 때문이다(solution/site/vite.config.ts).
|
|
'@site': path.resolve(__dirname, '../site/src'),
|
|
},
|
|
},
|
|
// ★ 산출물이 `dist/` 가 아니라 `build/client/` 로 나간다(프레임워크 모드 기본값).
|
|
// nginx/Dockerfile 의 COPY 경로가 이 값과 맞아야 한다 — 어긋나면 빈 이미지가 구워지고
|
|
// 컨테이너는 정상으로 뜬다.
|
|
build: {
|
|
// ★ 발행본과 **같은 오리진**을 쓰므로 `/assets/` 를 서로 뺏는다. 빌더 번들만 다른
|
|
// 디렉토리로 뺀다 — 안 그러면 nginx 의 `/assets/` 규칙이 발행본 것만 주고
|
|
// 빌더 JS·CSS 가 404 다(화면은 뜨는데 스타일도 동작도 없다).
|
|
assetsDir: 'builder-assets',
|
|
},
|
|
server: {
|
|
// ★ Vite 6 는 모르는 Host 헤더를 403 "Blocked request" 로 막는다(DNS rebinding 방어).
|
|
// 운영은 앞단 nginx 가 Host 를 그대로 넘기므로 발행 호스트를 여기 넣어야 화면이 뜬다.
|
|
// 빠뜨리면 프록시는 정상인데 앱만 전부 403 이다.
|
|
allowedHosts: [process.env.VITE_PUBLISH_HOST ?? 'web4ai.o2osolution.ai'],
|
|
// 발행 사이트는 별도 정적 서버(:3001)가 만들고 서빙하지만, 사용자는 admin 과 같은
|
|
// origin(:3000)의 `/s/<slug>` 로 접근한다. 프로세스 포트만 분리하고 공개 URL은 하나로 둔다.
|
|
proxy: {
|
|
// `'/s'`로 두면 Vite의 `/src/app/main.tsx`까지 매칭되어 에디터가 흰 화면이 된다.
|
|
// 발행 사이트 루트(`/s`)와 그 하위 경로(`/s/...`)만 정확히 전달한다.
|
|
'^/s(?:/|$)': {
|
|
target: 'http://127.0.0.1:3001',
|
|
changeOrigin: true,
|
|
},
|
|
/**
|
|
* ★ 발행본의 JS·CSS 번들.
|
|
*
|
|
* 프리렌더는 사이트마다 번들을 복사하지 않고 `out/assets/` 하나를 **모든 사이트가 공유**한다
|
|
* (캐시 엔트리가 사이트 수만큼 늘지 않는다). 그래서 페이지 HTML 은 `/s/<slug>/assets/...` 가
|
|
* 아니라 루트 절대경로 `/assets/...` 를 가리킨다.
|
|
*
|
|
* 이 규칙이 없으면 그 요청이 위의 `^/s` 에 걸리지 않아 **관리자 dev 서버**로 떨어지고,
|
|
* Vite 는 모르는 경로에 SPA fallback(index.html)을 200 으로 돌려준다 —
|
|
* 브라우저는 JS 자리에서 HTML 을 받아 파싱 에러를 내고, CSS 는 MIME 불일치로 무시한다.
|
|
* 화면에는 스타일도 상호작용도 없는 맨 HTML 만 남는다. 404 가 아니라 200 이라
|
|
* 네트워크 탭만 봐서는 원인이 잘 안 보이는 자리다.
|
|
*
|
|
* ★ 운영에서는 nginx·CDN 이 `out/` 을 루트로 서빙하므로 `/assets/...` 가 그대로 맞다 —
|
|
* 이 프록시는 개발에서 두 프로세스를 한 origin 으로 보이게 하는 몫만 한다.
|
|
* ★ `/fonts` 는 넘기지 않는다. 관리자와 발행본이 **같은 경로를 각자** 쓰고 있어
|
|
* 여기서 넘기면 관리자 자기 폰트가 발행본 쪽으로 샌다. 지금 발행본 HTML 은
|
|
* `/fonts/...` 를 참조하지 않는다(웹폰트는 Google Fonts CDN 에서 받는다).
|
|
*/
|
|
'^/assets/': {
|
|
target: 'http://127.0.0.1:3001',
|
|
changeOrigin: true,
|
|
},
|
|
},
|
|
// 도커(Mac) 바인드마운트는 네이티브 inotify 가 컨테이너로 전파되지 않아
|
|
// 파일 감시를 polling 으로 해야 호스트 변경이 감지된다.
|
|
watch: {usePolling: true, interval: 300},
|
|
},
|
|
});
|