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