@zalkera/client 0.17.1 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -7
- package/bin/validate-storefront.mjs +540 -10
- package/contracts/aeo-surface-guarantees.json +4 -4
- package/dist/index.cjs +35 -16
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +142 -130
- package/dist/index.d.ts +142 -130
- package/dist/index.js +35 -17
- package/dist/index.js.map +1 -1
- package/llms.txt +163 -78
- package/package.json +1 -1
package/llms.txt
CHANGED
|
@@ -49,23 +49,55 @@ route handler 를 두고 브라우저 ↔ route handler ↔ 잘커라로 프록
|
|
|
49
49
|
- 파트너 콘솔에서 발급(원문 1회 노출·분실 시 회전). 유출 시 콘솔에서 revoke·재발급.
|
|
50
50
|
- 안 주면 종전대로 `tenant`만으로 동작(하위호환). 백엔드가 키를 요구하는 설정이면 키 없이 401 `STOREFRONT_KEY_REQUIRED`.
|
|
51
51
|
|
|
52
|
-
IP 민감 호출(문의·리드·조회수)은 원 방문자 IP 를 넘겨야
|
|
52
|
+
IP 민감 호출(문의·리드·조회수)은 원 방문자 IP 를 넘겨야 한다. **값은 `visitorIp()` 로 뽑는다**:
|
|
53
53
|
```ts
|
|
54
54
|
// route handler 안
|
|
55
|
-
|
|
55
|
+
import { visitorIp } from "@zalkera/client";
|
|
56
|
+
const ip = visitorIp(req.headers); // 프록시 1단 기준(기본). 못 정하면 undefined.
|
|
56
57
|
await zalkera.submitInquiry(input, { clientIp: ip });
|
|
57
58
|
```
|
|
58
59
|
|
|
60
|
+
⚠️ **`x-forwarded-for` 의 첫 엔트리를 직접 쓰지 마라**(`xff.split(",")[0]`). 이 헤더는 **각 프록시가
|
|
61
|
+
자기가 받은 연결의 IP 를 오른쪽에 append** 하므로, 방문자가 `X-Forwarded-For: 9.9.9.9` 를 손으로 실으면
|
|
62
|
+
헤더는 `9.9.9.9, <진짜IP>` 가 되고 **첫 엔트리는 공격자가 쓴 문자열**이다. 그걸로 레이트리밋 버킷을
|
|
63
|
+
만들면 요청마다 값을 바꿔 우회할 수 있다(실측된 취약점). 백엔드도 첫 홉을 쓰지 않는다 — 신뢰 프록시 홉
|
|
64
|
+
기반으로 채택한다. `visitorIp()` 는 그 판정을 그대로 거울한다.
|
|
65
|
+
|
|
66
|
+
**홉 수 선언**(우선순위: 인자 > env > 기본 1):
|
|
67
|
+
```ts
|
|
68
|
+
visitorIp(req.headers, { trustedHops: 2 }); // 예: CDN + 로드밸런서
|
|
69
|
+
// 또는 서버 env 로: ZALKERA_TRUSTED_PROXY_HOPS=2 (NEXT_PUBLIC_ 접두 금지 — 서버 전용 값)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**보장 경계**(이 함수는 "부르면 안전"이 아니라 "선언한 홉 수가 참인 만큼 안전"을 판다):
|
|
73
|
+
|
|
74
|
+
| 선언 vs 실제 | 채택되는 값 | 결과 | 드러남 |
|
|
75
|
+
|---|---|---|---|
|
|
76
|
+
| 선언 **<** 실제(과소) | 안쪽 인프라 IP(CDN 엣지 등) | 방문자 전원이 한 IP 로 뭉침 → 429 폭주 | **가시** |
|
|
77
|
+
| 선언 **=** 실제 | 방문자 IP | 정상 | — |
|
|
78
|
+
| 선언 **>** 실제(과대) | **공격자가 넣은 임의 엔트리** | 위조 관통 | **비가시 — 조용히 뚫린다** |
|
|
79
|
+
|
|
80
|
+
규율은 하나다: **선언은 실제 이하로만.** 그리고 **리버스 프록시 0단 직노출**(Node 를 인터넷에 직접 붙인
|
|
81
|
+
배포)에서는 이 함수를 쓰지 마라 — 거기서는 `x-forwarded-for` 전체가 방문자가 쓴 값이라 어떤 홉 수를
|
|
82
|
+
넣어도 위조가 나온다(소켓 IP 를 플랫폼 수단으로 직접 얻어라). **잘커라가 서빙하는 사이트라면** 이 값을
|
|
83
|
+
고민하지 않아도 된다: 서빙 프록시가 방문자 IP 단일 엔트리로 `X-Forwarded-For` 를 재작성하므로 홉 1 이
|
|
84
|
+
구성상 참이다(= 기본값).
|
|
85
|
+
|
|
86
|
+
넘긴 값은 전용 헤더 `X-Zalkera-Client-Ip`(+ 이행기 `X-Forwarded-For` 병행)로 나가고, 백엔드는
|
|
87
|
+
**유효 `secretKey` 가 확인된 요청에서만** 그 선언을 채택한다(무키 요청의 선언은 무시된다).
|
|
88
|
+
|
|
59
89
|
## 3. API 표면 (전체 메서드)
|
|
60
90
|
|
|
61
91
|
### 콘텐츠(CMS)
|
|
62
92
|
- `getSiteConfig()` — 회사명·연락처·테마·SEO 기본값
|
|
63
93
|
- `listCategories()` · `listPosts({category?,page?,size?,sort?})` · `getPost(slug)` · `recordPostView(slug,ctx)`
|
|
64
|
-
- `
|
|
65
|
-
- ⚠
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
94
|
+
- `getMediaUrl(id)` — 에셋 id → 단기 만료 URL. **마크업에는 넣지 마라**(§4.9 프록시가 정답)
|
|
95
|
+
- ⚠ **고정 페이지·섹션·내비를 주는 API 는 없다.** 사이트의 얼굴(페이지·섹션·문구·섹션 이미지·내비)의
|
|
96
|
+
정본은 **레포 파일**이라 백엔드 왕복이 아예 없다 — 파일 형상은 §9.1(`content/pages/*.json`·`content/nav.json`),
|
|
97
|
+
그것을 읽어 그리는 라우트 전문은 §4.8 이다. 고정 페이지를 만드는 일 = **json 1개 + 매니페스트 1행**.
|
|
98
|
+
(어휘 rev 7 이전에는 섹션이 DB 에도 살아서 `getPage`·`listPages`·`listMenus` 표면이 있었다. 그 거처가
|
|
99
|
+
퇴역하며 세 메서드도 함께 내려갔다 — 옛 코드가 이 이름을 부르면 지금은 컴파일이 실패하는 쪽이 정상이다.)
|
|
100
|
+
- `submitInquiry(input, ctx)` · `submitLead(input, ctx)` ← ctx.clientIp 필수(서버·값은 `visitorIp()` 로 — §2)
|
|
69
101
|
|
|
70
102
|
### 커머스 — 카탈로그(공개)
|
|
71
103
|
- `listProducts({productType?, keyword?, page?, size?, sort?})` → `Paginated<ProductSummary>` (카드용: slug·name·priceFrom·inStock)
|
|
@@ -150,8 +182,9 @@ await zalkera.submitInquiry(input, { clientIp: ip });
|
|
|
150
182
|
|
|
151
183
|
- `safeLinkUrl(raw)` → `string` — **모든 `href` 는 이걸 태운다.** 콘솔 입력이라 `javascript:` 가 들어올 수 있다(저장형 XSS).
|
|
152
184
|
- `parseConfig<T>(config)` → `T | null` — 섹션 `config` JSON 파싱. **절대 throw 하지 않는다**(실패 시 null).
|
|
153
|
-
- `readConfig<T>(config)` → `T | null` — 같은 계약인데 **문자열이든 객체든** 받는다.
|
|
154
|
-
(
|
|
185
|
+
- `readConfig<T>(config)` → `T | null` — 같은 계약인데 **문자열이든 객체든** 받는다. 계약이 말하는 config 는
|
|
186
|
+
**객체**지만(콘텐츠 파일) 손으로 고치는 파일이라 통째 문자열이 들어오기도 한다 — 소비자를 두 벌로 만들지
|
|
187
|
+
않으려는 입구다. 어느 쪽을 받든 이걸 쓰면 섹션 컴포넌트는 한 벌이다.
|
|
155
188
|
- `asString(v)` / `asId(v)` — 형이 안 맞으면 `undefined`.
|
|
156
189
|
- `asIdArray(v)` / `asObjectArray(v)` — **빈 배열**로 강하한다(undefined 아님).
|
|
157
190
|
빈 배열은 truthy 라 `if (items)` 로 결측을 판별하면 안 된다 — `items.length` 을 봐라.
|
|
@@ -159,9 +192,10 @@ await zalkera.submitInquiry(input, { clientIp: ip });
|
|
|
159
192
|
- `mediaSrc(assetId)` → `/media/{id}` **경로 문자열**. 이미지는 전부 이걸 통한다.
|
|
160
193
|
전제: 프로젝트에 `app/media/[id]/route.ts` 프록시 라우트가 있어야 한다(**§4.9 에 전문**).
|
|
161
194
|
이 헬퍼는 경로만 만든다 — 라우트를 안 만들면 이미지가 404 다.
|
|
162
|
-
- `
|
|
163
|
-
|
|
164
|
-
|
|
195
|
+
- `assetPath(v)` — **소스 참조 표기**(계약 `dialects.reference`)를 읽는다. 숫자 id 를 못 쓰는 자리에서 에셋은
|
|
196
|
+
레포 `public/` **루트 절대 경로**로 가리킨다. 루트 절대 경로만 통과시킨다(스킴·`//`·`..`·역슬래시 → `undefined`).
|
|
197
|
+
- `asHandle(v)` / `asHandleArray(v)` — 상품 `handle`(= `ProductSummary.slug`) 정리 헬퍼. **섹션 config 용이 아니다**
|
|
198
|
+
(rev 6 에서 업무 참조 키가 어휘에서 빠졌다) — 소스가 **자기 tsx 안에서** 자기 카탈로그를 큐레이션할 때 쓴다.
|
|
165
199
|
`asHandleArray` 는 **배열 순서를 보존**한다 — 그 순서가 노출 순서의 원장이다.
|
|
166
200
|
- `SECTION_CONTRACT` / `SECTION_CONTRACT_REV` / `sectionsOfVertical(vertical)` — 아는 섹션 어휘의 코드 표현(§9).
|
|
167
201
|
타입·키를 **지어내지 말고** 여기서 확인한다.
|
|
@@ -191,10 +225,12 @@ const product = await zalkera.getProduct(slug, { tags: ["products", `product:${s
|
|
|
191
225
|
발화 태그는 `site-config` 하나뿐이라 이걸 안 달면 그 페이지만 옛 테마로 남는다.
|
|
192
226
|
|
|
193
227
|
```ts
|
|
194
|
-
//
|
|
195
|
-
const
|
|
228
|
+
// 카테고리 라우트 — 자기 태그 + site-config 동승
|
|
229
|
+
const categories = await zalkera.listProductCategories({ tags: ["site-config", "products"] });
|
|
196
230
|
```
|
|
197
231
|
|
|
232
|
+
- **고정 페이지(§4.8)에는 이 축이 아예 없다** — 내용이 레포 파일이라 fetch 가 없고, 배포가 곧 반영이다.
|
|
233
|
+
태그를 달 자리를 찾지 마라.
|
|
198
234
|
- **안 넘기면 세그먼트 기본 캐시만 걸린다** — 콘솔에서 고쳐도 화면이 안 바뀐다는 신고가 대개 이것이다.
|
|
199
235
|
|
|
200
236
|
## 4. 레시피
|
|
@@ -355,12 +391,13 @@ if (booking.orderNo) { // status=PENDING
|
|
|
355
391
|
`fbclid`·`gclid`·`nclid` 동명. **UTM 은 클라이언트 아일랜드가 mount 후 `window.location.search`
|
|
356
392
|
로 캡처**한다 — 랜딩이 ISR(force-static)이면 RSC `searchParams`·`useSearchParams()` 는 정적 셸을
|
|
357
393
|
깨므로 금지(하나라도 있으면 `tracking` 을 채우고, 전무면 undefined).
|
|
358
|
-
- **BFF 필수**: 브라우저에서 `submitLead` 직호출 금지(§2). route handler 에서 `
|
|
359
|
-
|
|
394
|
+
- **BFF 필수**: 브라우저에서 `submitLead` 직호출 금지(§2). route handler 에서 `visitorIp(req.headers)` 로
|
|
395
|
+
뽑아 `{ clientIp }` 로 넘긴다(안 넘기면 방문자 전원 429 — §4.6·문의와 동일 관용구).
|
|
396
|
+
**첫 홉 직접 추출 금지** — 방문자가 위조할 수 있어 레이트리밋이 우회된다(§2 보장 경계 표).
|
|
360
397
|
|
|
361
398
|
### 4.8 콘텐츠 파일로 그리는 고정 페이지 (`"content": "source"` · 권장)
|
|
362
399
|
|
|
363
|
-
사이트의 얼굴을 레포가 정본으로 가질 때의 배선이다. 파일
|
|
400
|
+
사이트의 얼굴을 레포가 정본으로 가질 때의 배선이다. 파일 형상·참조 표기는 §9.1·§9.2, 아래는 그것을 읽는 코드다.
|
|
364
401
|
**라우트는 하나면 된다** — 페이지가 늘어도 `content/` 에 json 이 느는 것이지 라우트가 늘지 않는다.
|
|
365
402
|
|
|
366
403
|
```ts
|
|
@@ -417,10 +454,25 @@ export default async function StaticPage({ params }: { params: Promise<{ slug: s
|
|
|
417
454
|
}
|
|
418
455
|
```
|
|
419
456
|
|
|
420
|
-
-
|
|
421
|
-
|
|
457
|
+
- **섹션은 업무 데이터를 가리키지 않는다**(어휘 rev 6 · §9.3). `SectionList` 는 백엔드를 **한 번도 안 부른다** —
|
|
458
|
+
선언이 곧 데이터라 자기완결이다. 상품·갈래를 화면에 비추는 일은 선언이 아니라 **소스가 이 패키지를 직접
|
|
459
|
+
호출**해서 한다(`listProducts()`·`listProductCategories()`). 그러면 "어디에"도 "어떻게"도 소스에 있어,
|
|
460
|
+
받은 사람의 LLM 이 그 진열을 마음대로 뜯어고칠 수 있다. 같은 홈에 저작물(HERO·FAQ — 선언)과
|
|
461
|
+
조회(진열 — 직접 호출)가 나란히 사는 것이 정상 형상이다.
|
|
422
462
|
- 홈(`app/page.tsx`)은 `loadPageContent("home")` 의 섹션이 있으면 그것을 그리고, 없으면 커머스 골격으로
|
|
423
463
|
강하한다. 콘텐츠 없는 것은 **정상**이다(커머스 테넌트).
|
|
464
|
+
- **내비(헤더·푸터)도 같은 자리다.** 레이아웃이 `content/nav.json` 의 `header`/`footer` 배열
|
|
465
|
+
(`{ label, href }` · **배열 순서가 노출 순서**)을 그대로 그린다 — 메뉴를 주는 API 는 없다.
|
|
466
|
+
`href` 는 외부 링크가 정당하므로 `safeLinkUrl()` 을 태우고, 파일이 없으면 내비가 비는 것이지 오류가 아니다.
|
|
467
|
+
|
|
468
|
+
```tsx
|
|
469
|
+
// app/layout.tsx — 내비도 백엔드 왕복 0
|
|
470
|
+
import { safeLinkUrl } from "@zalkera/client";
|
|
471
|
+
import { nav } from "@/content";
|
|
472
|
+
|
|
473
|
+
const header = (nav as { header?: { label: string; href: string }[] }).header ?? [];
|
|
474
|
+
// <nav>{header.map((m, i) => <a key={`${m.href}-${i}`} href={safeLinkUrl(m.href)}>{m.label}</a>)}</nav>
|
|
475
|
+
```
|
|
424
476
|
|
|
425
477
|
### 4.9 미디어 프록시 라우트 (`/media/{id}`) — **필수 부품**
|
|
426
478
|
|
|
@@ -639,11 +691,16 @@ export function merchantReturnPolicyJsonLd(config: SiteConfig, windowDays?: numb
|
|
|
639
691
|
- ❌ **`var(--oneq-*)` 참조**(정의처 없는 죽은 레거시 토큰). ✅ 테넌트 색은 토큰 유틸리티(`bg-primary`·
|
|
640
692
|
`text-primary` 등, §8)로 쓴다.
|
|
641
693
|
- ❌ 서버에서 `submitInquiry`/`submitLead`/`recordPostView` 부를 때 clientIp 누락 → 방문자 전원 rate-limit.
|
|
694
|
+
- ❌ **`x-forwarded-for` 첫 엔트리를 clientIp 로 쓰기**(`xff.split(",")[0]`). 첫 엔트리는 **방문자가 요청에 손으로
|
|
695
|
+
실은 값**이라 IP 레이트리밋이 한 줄로 우회되고 IP 기록이 오염된다(실측). ✅ `visitorIp(req.headers)` 를 쓰고
|
|
696
|
+
프록시 홉 수를 선언한다(§2). `x-real-ip` 폴백도 쓰지 마라 — 세우는 주체가 프록시마다 다르고 안 세우면 위조 자유다.
|
|
697
|
+
검사기(`zalkera-validate`)가 이 형상을 `[I1]` 경고로 잡는다.
|
|
642
698
|
- ❌ **콘텐츠 파일 사이트에서 페이지를 라우트로 신설**(`app/오시는길/page.tsx` 를 새로 짜기). ✅ `content/pages/<slug>.json`
|
|
643
699
|
**+ 매니페스트 1행**이면 끝이고 라우팅·sitemap 은 이미 있다(§4.8). 라우트를 새로 짜면 그 페이지만 계약 밖으로
|
|
644
700
|
나가 다음번 "말로 고치기"가 다시 tsx 탐색이 된다 — 실측된 회귀다.
|
|
645
|
-
- ❌
|
|
646
|
-
|
|
701
|
+
- ❌ **콘텐츠 파일에 숫자 id 적기**(`"assetId": 12`·`"productIds": [3,7]`). ✅ 에셋은 `public/` 루트 절대
|
|
702
|
+
경로로 적는다(§9.2). 상품·갈래는 애초에 선언이 아니라 tsx 의 직접 호출로 그린다(§9.3 규약).
|
|
703
|
+
숫자 id 는 테넌트 스코프라 그 소스를 재업로드하면 의미를 잃는다.
|
|
647
704
|
- ❌ **콘텐츠 파일에 `sortOrder` 를 넣거나 읽은 뒤 정렬**. ✅ 배열 순서가 곧 화면 순서다(§9.1).
|
|
648
705
|
- ❌ **콘텐츠 json 을 런타임 `fs.readFile` 로 읽기.** ✅ `content/index.ts` 정적 import — fs 로 읽으면 dev HMR 이
|
|
649
706
|
안 돌고 `next build`(standalone) 산출물에서 페이지가 통째로 사라진다.
|
|
@@ -685,13 +742,14 @@ export function merchantReturnPolicyJsonLd(config: SiteConfig, windowDays?: numb
|
|
|
685
742
|
목록과 같은 규율을 그대로 따른다: 각 `ListItem` 은 `url` + **이름만** 들고 가는 요약형(가격의 정본은 상세의
|
|
686
743
|
`Offer` 다), **항목이 0건이면 그래프를 내지 않는다**, `BreadcrumbList` 를 함께 내고 **ISR 로 유지**한다.
|
|
687
744
|
카테고리가 하나도 없는 사이트는 이 라우트를 내비·sitemap 에 올리지 않는다 — 열면 빈 선반이 된다.
|
|
688
|
-
- ✅ **예약(시술) 사이트의 목록
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
745
|
+
- ✅ **예약(시술) 사이트의 목록 보장도 `ItemList` 다 — 다만 그것을 내는 자리는 소스가 정한다.**
|
|
746
|
+
종전 이 자리에는 "`SERVICE_MENU` 섹션이 낸다"고 적혀 있었다. **어휘 rev 6 에서 그 섹션 타입이 삭제됐다**
|
|
747
|
+
— 목록을 어디에 어떻게 그릴지가 어휘에 고정되면 그 진열의 디자인이 얼어붙기 때문이다. 보장은 그대로다:
|
|
748
|
+
**개시된 페이지 어딘가에서 시술 목록의 `ItemList` 가 나오면 된다**(판정 지점은 소스가 아니라 산출물이다).
|
|
749
|
+
실물로 그것을 내는 자리는 둘이다 — `/products` 목록 라우트(위 항목과 같은 규율), 그리고 소스가 홈에
|
|
750
|
+
조합한 진열(`listProducts()`/`listProductCategories()` 직접 호출 + 화면과 **같은 배열**에서 만든
|
|
751
|
+
`itemListJsonLd`). 규율은 목록 라우트와 동일하다: 요약형 `ListItem`(가격의 정본은 상세의 `Offer`),
|
|
752
|
+
**항목이 0건이면 그래프를 내지 않는다**, ISR 유지.
|
|
695
753
|
- ✅ **CMS 고정 페이지(`/[slug]`)를 비워 두지 마라 — `WebPage` + `BreadcrumbList`.** 홈은 `Organization`,
|
|
696
754
|
상품은 `Product`, 글은 `BlogPosting` 을 내는데 콘솔·시드가 만든 서브페이지(회사소개·이용안내 등)만 그래프가
|
|
697
755
|
비기 쉽다. 순수 마케팅 사이트는 **그 서브페이지가 콘텐츠의 전부**라, 비어 있으면 답변 엔진이 인용할 노드가
|
|
@@ -735,6 +793,29 @@ npx zalkera-aeo-check --print-guarantees # 잣대 해석
|
|
|
735
793
|
- ❌ 배송 전(PAID)에 후기 작성 시도 → `NOT_DELIVERED_YET`(409). ✅ 주문이 **DELIVERED 이상**일 때만
|
|
736
794
|
버튼을 낸다(상태로 게이트). orderItemId 는 주문 상세 items[].id 에서만 얻는다 — 상품 상세엔 없다.
|
|
737
795
|
|
|
796
|
+
## 5.2 서빙 산출물 계약 — 잘커라가 호스팅한다면 (필수)
|
|
797
|
+
|
|
798
|
+
> **잘커라가 서빙하는 소스는 빌드가 `.next/standalone` 자기완결 산출물을 내야 한다.**
|
|
799
|
+
|
|
800
|
+
우리 박스는 `next start` 를 쓰지 않는다. 빌드가 낸 `.next/standalone` 을 아티팩트로 떼어 **읽기 전용으로
|
|
801
|
+
마운트하고 `node server.js`** 로 띄운다. 그래서 그 산출물이 없으면 **`next build` 가 성공해도 배포가
|
|
802
|
+
반려된다**(서빙 게이트 exit 4). 빌드가 그린이라 눈에 안 띄는 종류의 고장이다.
|
|
803
|
+
|
|
804
|
+
- ✅ `next.config` 에 **`output: "standalone"`**. 실무적으로 이 한 줄이 전부다.
|
|
805
|
+
- ❌ **유지보수 중 이 키를 지우기.** `next.config` 를 다시 쓸 일이 생기면(이미지 도메인 추가·리라이트·
|
|
806
|
+
헤더) 나머지는 자유지만 **이 키는 보존한다.** 지운 순간 그 사이트는 다음 배포에서 안 뜬다.
|
|
807
|
+
- ❌ `output: 'export'`(정적 export)·`distDir` 변경 — 같은 결과다.
|
|
808
|
+
- ❌ **런타임에 파일 쓰기**(업로드 저장·로그 파일·생성 캐시). 파일시스템은 읽기 전용이고 쓸 수 있는
|
|
809
|
+
곳은 `.next/cache`(휘발)뿐이다. 빌드는 통과하고 **서빙 중에** 죽는다. 저장은 백엔드 API 로 한다.
|
|
810
|
+
- ❌ 콘텐츠 json 을 런타임 `fs` 로 읽기 — standalone 트레이싱이 그 파일을 안 싣는다(§5 참조).
|
|
811
|
+
|
|
812
|
+
**자체 호스팅(BYO)이면 이 절은 통째로 해당 없다.** Vercel·자기 컨테이너·정적 export 무엇이든 자유다 —
|
|
813
|
+
이 요건의 근거는 어휘가 아니라 **누가 서빙하는가**이기 때문이다.
|
|
814
|
+
|
|
815
|
+
계약은 키가 아니라 **산출물**이다. 그래서 판정도 설정 문자열이 아니라 빌드가 낸 것으로 한다
|
|
816
|
+
(`verify-zip` · CI · 서빙 게이트). `zalkera-validate` 의 `[O1]` 은 **경고일 뿐** 판정이 아니다 —
|
|
817
|
+
정적 판독은 조건부 조립 앞에서 양쪽으로 틀리기 때문이다.
|
|
818
|
+
|
|
738
819
|
## 6. 에러 처리
|
|
739
820
|
|
|
740
821
|
**원인 분기는 `e.code`(기계 판독 `errorCode`)로 한다.** 한 엔드포인트가 같은 상태코드로 여러 원인을 낸다 —
|
|
@@ -933,14 +1014,14 @@ shadcn 소스는 자기 변수층(`--card`·`--muted-foreground` …)을 전제
|
|
|
933
1014
|
|
|
934
1015
|
## 9. 섹션 어휘 — 페이지는 데이터로 그린다
|
|
935
1016
|
|
|
936
|
-
고정 페이지는 본문 HTML 한 덩어리가 아니라 **섹션 배열**이다. 각 섹션은 `type`(
|
|
937
|
-
JSON)뿐이고, 백엔드는 config 를 **파싱하지 않는다** — 스키마는 프론트 계약이다.
|
|
1017
|
+
고정 페이지는 본문 HTML 한 덩어리가 아니라 **섹션 배열**이다. 각 섹션은 `type`(계약 어휘의 값)과
|
|
1018
|
+
`config`(타입별 JSON)뿐이고, 백엔드는 config 를 **파싱하지 않는다** — 스키마는 프론트 계약이다.
|
|
938
1019
|
|
|
939
1020
|
**왜 이 구조인가**: 콘텐츠가 마크업에 박히지 않고 계약을 타므로, 테넌트가 "말로" 고친 것이 재코딩 없이
|
|
940
1021
|
반영된다("FAQ 에 배송 질문 추가해줘"). **마크업에 문구를 하드코딩하면 이 경로가 죽는다** — 문구 한 줄을
|
|
941
1022
|
고치는 일이 컴포넌트 트리 탐색이 되고, 그 탐색이 곧 토큰이다.
|
|
942
1023
|
|
|
943
|
-
**그 배열이 사는 곳은
|
|
1024
|
+
**그 배열이 사는 곳은 레포 파일 하나다**(`content/pages/*.json` — §9.1). 레포는 그 사실을 선언한다.
|
|
944
1025
|
|
|
945
1026
|
```json
|
|
946
1027
|
// package.json
|
|
@@ -949,17 +1030,17 @@ JSON)뿐이고, 백엔드는 config 를 **파싱하지 않는다** — 스키마
|
|
|
949
1030
|
|
|
950
1031
|
| 선언 | 의미 |
|
|
951
1032
|
|---|---|
|
|
952
|
-
| `"source"` | 사이트의 얼굴(페이지·섹션·문구·섹션 이미지·내비)의 정본이 **레포 파일**.
|
|
953
|
-
|
|
|
954
|
-
| 미선언 | 안전 기본 — **선언이 없으면 이 계약을 안 쓰는 레포로 본다**(계약 검사도 걸지 않는다) |
|
|
1033
|
+
| `"source"` | 사이트의 얼굴(페이지·섹션·문구·섹션 이미지·내비)의 정본이 **레포 파일**. 지금 만드는 사이트는 전부 이 형상이다 |
|
|
1034
|
+
| 미선언 | 안전 기본 — **선언이 없으면 이 계약을 안 쓰는 레포로 본다**(계약 검사도 걸지 않는다). 문구를 tsx 에 직접 든 레포가 여기고, 그래도 개시·발행·"말로 고치기"는 전부 정상이다 |
|
|
955
1035
|
|
|
956
|
-
> ⚠
|
|
957
|
-
>
|
|
958
|
-
>
|
|
1036
|
+
> ⚠ **`"sections-db"` 는 은퇴한 값이다**(어휘 rev 7 · memo144). 섹션이 백엔드 DB 에도 살던 시절의
|
|
1037
|
+
> 전환기 표기였는데 그 거처(`page_section` 계열)가 통째로 퇴역했다 — 값과 함께 `getPage`·`listPages`·
|
|
1038
|
+
> `listMenus` 표면도, 콘솔의 페이지·섹션·메뉴 편집 화면도 내려갔다. 아직 이 값을 선언한 레포가 있다면
|
|
1039
|
+
> 검사기(`npx zalkera-validate`)가 경고로 알려 준다. 옮길 곳은 §9.1 이고, 옮기고 나면 선언은 `"source"` 다.
|
|
959
1040
|
|
|
960
|
-
|
|
1041
|
+
거처가 하나라 **어휘도 config 키도 한 벌**이다. 참조를 적는 표기는 §9.2 가 정본이다.
|
|
961
1042
|
|
|
962
|
-
### 9.1
|
|
1043
|
+
### 9.1 콘텐츠 파일 (`"content": "source"`) — 사이트 얼굴의 정본
|
|
963
1044
|
|
|
964
1045
|
```
|
|
965
1046
|
content/
|
|
@@ -975,15 +1056,20 @@ content/
|
|
|
975
1056
|
"seo": { "title": "…", "description": "…" },
|
|
976
1057
|
"sections": [
|
|
977
1058
|
{ "type": "HERO", "config": { "title": "…", "asset": "/images/hero.png" } },
|
|
978
|
-
{ "type": "
|
|
1059
|
+
{ "type": "FAQ_LIST", "config": { "items": [{ "question": "…", "answer": "…" }] } }
|
|
979
1060
|
]
|
|
980
1061
|
}
|
|
981
1062
|
```
|
|
982
1063
|
|
|
1064
|
+
- ⚠ **여기에 상품 목록을 적으려 하지 마라.** 콘텐츠 파일이 나르는 것은 **값이 이 파일에 사는 저작물**
|
|
1065
|
+
(문구·이미지 경로·링크·배열 순서)뿐이다. 상품·갈래는 값이 업무 DB 에 살고 화면은 비추기만 하므로
|
|
1066
|
+
**소스가 `listProducts()`·`listProductCategories()` 를 직접 호출**해 그린다 — 그 진열의 자리·형태·필드는
|
|
1067
|
+
전부 tsx 의 자유다(어휘 rev 6 · §9.3).
|
|
1068
|
+
|
|
983
1069
|
- **배열 순서가 화면 순서다.** `sortOrder` 키는 **없다** — 정렬하지 마라. 순서를 정하는 곳이 둘이면
|
|
984
1070
|
"후기를 위로 올려줘"가 매번 어느 쪽을 고치는지 판별 문제가 된다.
|
|
985
|
-
- **`config` 는 객체다**(문자열이 아니다).
|
|
986
|
-
|
|
1071
|
+
- **`config` 는 객체다**(문자열이 아니다). 그래도 읽을 때는 `readConfig` 를 써라 — 손으로 고친 파일이
|
|
1072
|
+
통째 문자열을 실어 와도 섹션 컴포넌트가 한 벌로 버틴다.
|
|
987
1073
|
- **정적 import 매니페스트를 우회하지 마라.** 런타임 `fs` 로 읽으면 ⑴ dev 에서 json 을 고쳐도 HMR 이
|
|
988
1074
|
안 돌고(확인이 비싸지면 재시도가 늘고 그게 곧 토큰이다) ⑵ `next build`(standalone) 산출물에 콘텐츠가
|
|
989
1075
|
트레이싱되지 않아 **개시된 사이트에서만** 페이지가 사라진다.
|
|
@@ -995,48 +1081,44 @@ content/
|
|
|
995
1081
|
- 이 계약을 **안 지킨 레포도 정상으로 돈다.** 문구를 tsx 에 직접 든 레포도 개시·발행·"말로 고치기"가
|
|
996
1082
|
전부 동작한다 — 강제가 아니라 권장이고, 검사는 위 선언을 **스스로 했을 때만** 격상된다.
|
|
997
1083
|
|
|
998
|
-
### 9.2 참조
|
|
1084
|
+
### 9.2 참조 표기 — 소스는 숫자 id 를 못 쓴다
|
|
999
1085
|
|
|
1000
|
-
|
|
1001
|
-
|
|
1086
|
+
숫자 id 는 백엔드가 발급하고 테넌트마다 다른데, **소스는 그 id 를 알 수 없다**(그리고 알아도 못 쓴다 —
|
|
1087
|
+
아래). 그래서 콘텐츠 파일의 참조는 사람이 읽고 쓰는 문자열이다. 계약 어휘 표(§9.3)의 키 이름은 `assetId`
|
|
1088
|
+
계열로 굳어 있는데, 그것은 섹션이 DB 컬럼에 살던 시절 표기가 그대로 남은 것이다 — **파일에 적을 때는
|
|
1089
|
+
아래 오른쪽 열로 옮겨 적는다.**
|
|
1002
1090
|
|
|
1003
|
-
| 참조 |
|
|
1091
|
+
| 참조 | 어휘 표의 키 이름(§9.3) | **콘텐츠 파일에 적는 표기** |
|
|
1004
1092
|
|---|---|---|
|
|
1005
|
-
| 에셋 | `assetId`·`*AssetId` : number
|
|
1006
|
-
|
|
1007
|
-
|
|
1093
|
+
| 에셋 | `assetId`·`*AssetId` : number | `asset`·`*Asset` : `"/images/hero.png"` → `assetPath(v)` |
|
|
1094
|
+
|
|
1095
|
+
키의 **의미**는 같다 — 바뀌는 것은 이름과 값의 형뿐이다. `mediaSrc(id)` 는 이 축이 아니라 **카탈로그가
|
|
1096
|
+
발급한 id**(예 `ProductDetail.coverAssetId`)를 그릴 때 쓴다.
|
|
1008
1097
|
|
|
1009
|
-
-
|
|
1098
|
+
- ❌ **섹션 config 에 업무 참조 키를 적지 마라**(`product`·`products`·`*Product(s)`·`categorySlug`).
|
|
1099
|
+
rev 6 에서 이것은 참조 표기가 아니라 **금지 키 형상**이다 — 그 키를 소비하던 타입 둘이 어휘에서 삭제됐고,
|
|
1100
|
+
진열은 소스 직접 호출의 소관이 됐다. 자기 소스가 자기 카탈로그의 handle 을 **tsx 안에서** 큐레이션으로
|
|
1101
|
+
가리키는 것은 정당하다(자기 카탈로그 안에서만 참이면 된다) — 금지되는 것은 **배송물의 선언**이다.
|
|
1010
1102
|
- ❌ **소스에 숫자 id 를 적지 마라.** 테넌트 스코프 값이라, 고객이 그 소스를 소유하고 다른 테넌트에
|
|
1011
|
-
재업로드하는 순간 의미를 잃는다.
|
|
1103
|
+
재업로드하는 순간 의미를 잃는다. 판정은 키 형상으로 하고 어간은 `asset`·`product`·`category` 셋이다
|
|
1104
|
+
(어휘에 그 타입이 없어진 뒤에도 유지한다 — 막는 것은 타입이 아니라 형상이다).
|
|
1012
1105
|
- `assetPath` 는 **레포 루트 절대 경로만** 통과시킨다(스킴·`//`·`..`·역슬래시 → `undefined`). 이미지 실물은
|
|
1013
1106
|
레포 `public/` 에 둔다. 원격 이미지는 계약 영역이 아니라 자유 영역이다.
|
|
1014
1107
|
- `asHandleArray` 는 **배열 순서를 보존한다** — 그 순서가 노출 순서의 원장이다.
|
|
1015
|
-
- 상품 상세의 커버(`coverAssetId`)는
|
|
1016
|
-
|
|
1017
|
-
-
|
|
1018
|
-
|
|
1108
|
+
- 상품 상세의 커버(`coverAssetId`)는 **여전히 숫자 id 다.** 그건 카탈로그가 발급한 값이지 소스가 적는
|
|
1109
|
+
값이 아니다 — 섹션 이미지와 상품 이미지는 다른 축이다.
|
|
1110
|
+
- **React key 는 `type`+index 로 짠다.** 섹션에 id 는 없고(계약에 그런 필드가 없다) `sortOrder` 도 없다 —
|
|
1111
|
+
배열 순서가 곧 순서다(§9.1).
|
|
1019
1112
|
|
|
1020
|
-
### 9.3
|
|
1021
|
-
|
|
1022
|
-
```ts
|
|
1023
|
-
const page = await zalkera.getPage("home");
|
|
1024
|
-
page.sections // [{ type: "HERO", sortOrder: 0, config: "{\"title\":\"…\"}" }, …]
|
|
1025
|
-
```
|
|
1026
|
-
|
|
1027
|
-
- `config` 가 **문자열**이다(컬럼 저장 형상). `parseConfig` 또는 `readConfig` 로 읽는다.
|
|
1028
|
-
- **`sortOrder` 로 한 번 더 정렬한다.** 서버가 정렬해 주지만 순서가 틀리면 화면에서 바로 티가 난다.
|
|
1029
|
-
- **key 는 `type`+`sortOrder`+index 로 짠다.** `PageSection` 계약에 id 가 없다(실측).
|
|
1030
|
-
|
|
1031
|
-
### 9.4 어휘 (12종) — 두 거처 공통
|
|
1113
|
+
### 9.3 어휘 (10종)
|
|
1032
1114
|
|
|
1033
1115
|
**규약**
|
|
1034
1116
|
- 파싱 헬퍼는 이 패키지가 준다: `readConfig`/`parseConfig` + `asString`/`asId`/`asIdArray`/`asObjectArray`
|
|
1035
|
-
+
|
|
1117
|
+
+ 참조 헬퍼 `asHandle`/`asHandleArray`/`assetPath`. **절대 throw 하지 않는다** — 필수 필드가 없으면
|
|
1036
1118
|
**그 섹션만 안 그린다**. 페이지 전체가 죽으면 안 된다.
|
|
1037
1119
|
- **미지 타입은 조용히 건너뛴다.** 어휘는 append-only 라 타입이 늘어도 옛 사이트가 깨지지 않아야 한다.
|
|
1038
1120
|
`default:` 에서 에러·경고를 내지 마라 — 그게 계약이다.
|
|
1039
|
-
- **모든 href 는 `safeLinkUrl()` 을 태운다.**
|
|
1121
|
+
- **모든 href 는 `safeLinkUrl()` 을 태운다.** 사람과 AI 가 손으로 고치는 json 이라 `javascript:` 가 들어올 수 있다(저장형 XSS).
|
|
1040
1122
|
- 자유 HTML 은 없다 — plain text + 줄바꿈뿐(`whitespace-pre-wrap`).
|
|
1041
1123
|
- **디스패치는 직접 짠다** — `type` 으로 컴포넌트를 고르는 `switch` 하나면 된다.
|
|
1042
1124
|
- 타입·키를 **지어내지 마라.** 계약 정본은 백엔드 레포의 `doc/contracts/section-vocabulary.json` 이고,
|
|
@@ -1044,9 +1126,9 @@ page.sections // [{ type: "HERO", sortOrder: 0, config: "{\"title\":\"…\"}" },
|
|
|
1044
1126
|
KDoc 이 같은 말을 한다). 코드에서 읽을 것은 `SECTION_CONTRACT` 가 맞지만, 어휘를 **늘리는** 결정은
|
|
1045
1127
|
정본 쪽에서 난다 — 필요한 어휘가 없으면 만들지 말고 **보고**한다.
|
|
1046
1128
|
|
|
1047
|
-
`vertical` 은
|
|
1129
|
+
`vertical` 은 어휘의 업종 분류일 뿐 사용 제한이 아니다(GENERAL 은 뷰티 사이트도 그대로 쓴다).
|
|
1048
1130
|
|
|
1049
|
-
| type | vertical | config —
|
|
1131
|
+
| type | vertical | config — 키 이름은 **계약 표기**(필수는 굵게 · 파일에 적는 표기로의 대응은 §9.2) | JSON-LD |
|
|
1050
1132
|
|---|---|---|---|
|
|
1051
1133
|
| `HERO` | GENERAL | eyebrow?, **title**, subtitle?, ctaLabel?, ctaHref?, assetId? | — |
|
|
1052
1134
|
| `FEATURE_GRID` | GENERAL | title?, **items**[{icon?, **title**, body?}] | — |
|
|
@@ -1056,18 +1138,21 @@ page.sections // [{ type: "HERO", sortOrder: 0, config: "{\"title\":\"…\"}" },
|
|
|
1056
1138
|
| `TESTIMONIALS` | GENERAL | title?, **items**[{**quote**, author?, role?, assetId?}] | **없음(의도)** |
|
|
1057
1139
|
| `FAQ_LIST` | GENERAL | title?, **items**[{**question**, **answer**}] | `FAQPage` |
|
|
1058
1140
|
| `LEAD_CTA` | GENERAL | title?, body?, interest?, quick? | — (`submitLead` 계약) |
|
|
1059
|
-
| `SERVICE_MENU` | BEAUTY | **productIds**(rev 3 부터 필수), categorySlug? | `ItemList`(rev 2 부터) |
|
|
1060
1141
|
| `BEFORE_AFTER_GALLERY` | BEAUTY | **items**[{**beforeAssetId**, **afterAssetId**, caption?}] | — |
|
|
1061
|
-
| `BOOKING_CTA` | BEAUTY | **productId**, label? | — |
|
|
1062
1142
|
| `DOCTOR_INTRO` | BEAUTY | **name**, title?, photoAssetId?, bio? | — |
|
|
1063
1143
|
|
|
1144
|
+
**10종이다.** rev 6 에서 `SERVICE_MENU`·`BOOKING_CTA` 가 **삭제**됐다 — 둘 다 업무 데이터(상품·갈래)를
|
|
1145
|
+
가리키는 **조회형**이었고, 조회의 자리는 선언이 아니라 소스이기 때문이다. 대체 타입을 만들지 마라
|
|
1146
|
+
(`PRODUCT_GRID` 류 신설은 같은 위반의 재생산이다): 상품을 진열하려면 tsx 에서 `listProducts()` 를 부르고
|
|
1147
|
+
원하는 대로 그려라. 미지 타입은 렌더러가 조용히 스킵하므로 옛 타입이 적힌 콘텐츠도 페이지를 죽이지 않는다.
|
|
1148
|
+
|
|
1064
1149
|
**섹션별 주의**
|
|
1065
1150
|
- `FAQ_LIST` 는 네이티브 `<details>/<summary>` 로 그린다 — 런타임 JS 0, 접근성 내장, 그리고 **닫힌 답변도
|
|
1066
1151
|
SSR 마크업에 실린다**(AI 답변엔진이 읽는다). 아코디언 라이브러리를 쓰지 마라. `FAQPage` JSON-LD 를 함께 낸다.
|
|
1067
1152
|
- `TESTIMONIALS` 에 **`Review`·`AggregateRating` 을 내지 마라.** 자사 사이트의 자사 후기에 별점을 붙이는 것은
|
|
1068
1153
|
self-serving reviews 정책 위반이라 제재 대상이다. 이건 빠뜨린 게 아니라 결정이다.
|
|
1069
1154
|
- `LEAD_CTA` 의 리드 폼은 **클라이언트 아일랜드**(`"use client"`)로 만들고 `/api/lead` BFF 로 POST 한다
|
|
1070
|
-
(`clientIp` 는
|
|
1155
|
+
(`clientIp` 는 그 BFF 가 `visitorIp(req.headers)` 로 뽑아 붙인다 — 첫 홉 직접 추출 금지·§2). 전환 부품을 두 벌 만들지 마라 — 하나로 재사용한다. 요건 넷:
|
|
1071
1156
|
· **연락처 필수 · 이메일 선택**(문의 폼과 반대다). 본문은 `{name, phone, email?, message?, interest,
|
|
1072
1157
|
isQuick, consentMarketing, tracking}`.
|
|
1073
1158
|
· **UTM·클릭ID 8키**를 `tracking` 으로 동봉한다 — `utm_source`·`utm_medium`·`utm_campaign`·
|
|
@@ -1078,10 +1163,10 @@ page.sections // [{ type: "HERO", sortOrder: 0, config: "{\"title\":\"…\"}" },
|
|
|
1078
1163
|
필드별로 표시하고, 성공 시 입력만 비우고 **`tracking` 은 유지**한다(같은 방문의 재제출도 같은 유입 귀속).
|
|
1079
1164
|
이 섹션은 **`id="lead"` 앵커**를 갖는다 — 원페이지 랜딩에서 히어로 CTA 를
|
|
1080
1165
|
`"ctaHref": "#lead"` 로 여기에 걸 수 있다. 앵커를 지우면 그 버튼이 조용히 아무 데도 안 간다.
|
|
1081
|
-
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1166
|
+
- **진열(상품 목록·예약 CTA)은 섹션이 아니다.** tsx 에서 `listProducts()`·`listProductCategories()` 를 부르고
|
|
1167
|
+
원하는 형태로 그린 뒤, **화면과 같은 배열**에서 `itemListJsonLd` 를 만들어 함께 낸다. **결과가 0건이면
|
|
1168
|
+
`return null`** — 빈 진열대를 그리지 마라(그것은 방문자에게 거짓이고, "상품을 등록하면 여기 표시됩니다"
|
|
1169
|
+
같은 안내도 넣지 마라: 그 문장의 독자는 사장이고 사장의 표면은 콘솔이다).
|
|
1085
1170
|
- `LOGO_WALL` 에 실존 기업 로고를 넣지 마라 — 사용 허락이 있는 것만.
|
|
1086
1171
|
|
|
1087
1172
|
**아이콘 — 큐레이션 맵의 키 문자열만**
|