@zalkera/client 0.18.0 → 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/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
- const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
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
- - `getPage(slug)` · `listPages({page?,size?})`(열거 전용·본문 없음·sitemap 용·size 상한 100) · `listMenus()` (HEADER/FOOTER) · `getMediaUrl(id)`
65
- - ⚠ 셋(`getPage`·`listPages`·`listMenus`)은 **섹션이 DB 있는 사이트**(`"content": "sections-db"`)의 표면이다.
66
- **새로 만드는 사이트는 부른다** 페이지·섹션·내비의 정본이 레포 파일이라 백엔드 왕복이 아예 없다(§4.8·§9.1).
67
- 계약은 append-only 메서드는 남지만, 소스 정본 레포에서 이걸 부르면 결과로 화면이 비는 쪽이 정상이다.
68
- - `submitInquiry(input, ctx)` · `submitLead(input, ctx)` ctx.clientIp 필수(서버)
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` — 같은 계약인데 **문자열이든 객체든** 받는다. 섹션 config 의 거처가 둘이라
154
- (백엔드 `page_section.config` = JSON 문자열 · 레포 콘텐츠 파일 = 객체) 소비자를 두 벌로 만들지 않으려는 입구다.
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,8 +192,8 @@ 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
- - `assetPath(v)` — **참조 방언**(계약 `dialects`) 읽는다. 숫자 id 를 못 쓰는 자리에서 에셋은 레포 `public/`
163
- **루트 절대 경로**로 가리킨다. 루트 절대 경로만 통과시킨다(스킴·`//`·`..`·역슬래시 → `undefined`).
195
+ - `assetPath(v)` — **소스 참조 표기**(계약 `dialects.reference`) 읽는다. 숫자 id 를 못 쓰는 자리에서 에셋은
196
+ 레포 `public/` **루트 절대 경로**로 가리킨다. 루트 절대 경로만 통과시킨다(스킴·`//`·`..`·역슬래시 → `undefined`).
164
197
  - `asHandle(v)` / `asHandleArray(v)` — 상품 `handle`(= `ProductSummary.slug`) 정리 헬퍼. **섹션 config 용이 아니다**
165
198
  (rev 6 에서 업무 참조 키가 어휘에서 빠졌다) — 소스가 **자기 tsx 안에서** 자기 카탈로그를 큐레이션할 때 쓴다.
166
199
  `asHandleArray` 는 **배열 순서를 보존**한다 — 그 순서가 노출 순서의 원장이다.
@@ -192,10 +225,12 @@ const product = await zalkera.getProduct(slug, { tags: ["products", `product:${s
192
225
  발화 태그는 `site-config` 하나뿐이라 이걸 안 달면 그 페이지만 옛 테마로 남는다.
193
226
 
194
227
  ```ts
195
- // 페이지 라우트 — 자기 태그 + site-config 동승
196
- const page = await zalkera.getPage(slug, { tags: ["site-config", "pages", `page:${slug}`] });
228
+ // 카테고리 라우트 — 자기 태그 + site-config 동승
229
+ const categories = await zalkera.listProductCategories({ tags: ["site-config", "products"] });
197
230
  ```
198
231
 
232
+ - **고정 페이지(§4.8)에는 이 축이 아예 없다** — 내용이 레포 파일이라 fetch 가 없고, 배포가 곧 반영이다.
233
+ 태그를 달 자리를 찾지 마라.
199
234
  - **안 넘기면 세그먼트 기본 캐시만 걸린다** — 콘솔에서 고쳐도 화면이 안 바뀐다는 신고가 대개 이것이다.
200
235
 
201
236
  ## 4. 레시피
@@ -356,12 +391,13 @@ if (booking.orderNo) { // status=PENDING
356
391
  `fbclid`·`gclid`·`nclid` 동명. **UTM 은 클라이언트 아일랜드가 mount 후 `window.location.search`
357
392
  로 캡처**한다 — 랜딩이 ISR(force-static)이면 RSC `searchParams`·`useSearchParams()` 는 정적 셸을
358
393
  깨므로 금지(하나라도 있으면 `tracking` 을 채우고, 전무면 undefined).
359
- - **BFF 필수**: 브라우저에서 `submitLead` 직호출 금지(§2). route handler 에서 `x-forwarded-for`
360
- 홉을 `{ clientIp }` 로 넘긴다(안 넘기면 방문자 전원 429 — §4.6·문의와 동일 관용구).
394
+ - **BFF 필수**: 브라우저에서 `submitLead` 직호출 금지(§2). route handler 에서 `visitorIp(req.headers)`
395
+ 뽑아 `{ clientIp }` 로 넘긴다(안 넘기면 방문자 전원 429 — §4.6·문의와 동일 관용구).
396
+ **첫 홉 직접 추출 금지** — 방문자가 위조할 수 있어 레이트리밋이 우회된다(§2 보장 경계 표).
361
397
 
362
398
  ### 4.8 콘텐츠 파일로 그리는 고정 페이지 (`"content": "source"` · 권장)
363
399
 
364
- 사이트의 얼굴을 레포가 정본으로 가질 때의 배선이다. 파일 형상·방언은 §9.1·§9.2, 아래는 그것을 읽는 코드다.
400
+ 사이트의 얼굴을 레포가 정본으로 가질 때의 배선이다. 파일 형상·참조 표기는 §9.1·§9.2, 아래는 그것을 읽는 코드다.
365
401
  **라우트는 하나면 된다** — 페이지가 늘어도 `content/` 에 json 이 느는 것이지 라우트가 늘지 않는다.
366
402
 
367
403
  ```ts
@@ -418,13 +454,25 @@ export default async function StaticPage({ params }: { params: Promise<{ slug: s
418
454
  }
419
455
  ```
420
456
 
421
- - **섹션은 업무 데이터를 가리키지 않는다**(어휘 rev 6 · §9.4). `SectionList` 는 백엔드를 **한 번도 안 부른다** —
457
+ - **섹션은 업무 데이터를 가리키지 않는다**(어휘 rev 6 · §9.3). `SectionList` 는 백엔드를 **한 번도 안 부른다** —
422
458
  선언이 곧 데이터라 자기완결이다. 상품·갈래를 화면에 비추는 일은 선언이 아니라 **소스가 이 패키지를 직접
423
459
  호출**해서 한다(`listProducts()`·`listProductCategories()`). 그러면 "어디에"도 "어떻게"도 소스에 있어,
424
460
  받은 사람의 LLM 이 그 진열을 마음대로 뜯어고칠 수 있다. 같은 홈에 저작물(HERO·FAQ — 선언)과
425
461
  조회(진열 — 직접 호출)가 나란히 사는 것이 정상 형상이다.
426
462
  - 홈(`app/page.tsx`)은 `loadPageContent("home")` 의 섹션이 있으면 그것을 그리고, 없으면 커머스 골격으로
427
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
+ ```
428
476
 
429
477
  ### 4.9 미디어 프록시 라우트 (`/media/{id}`) — **필수 부품**
430
478
 
@@ -643,11 +691,16 @@ export function merchantReturnPolicyJsonLd(config: SiteConfig, windowDays?: numb
643
691
  - ❌ **`var(--oneq-*)` 참조**(정의처 없는 죽은 레거시 토큰). ✅ 테넌트 색은 토큰 유틸리티(`bg-primary`·
644
692
  `text-primary` 등, §8)로 쓴다.
645
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]` 경고로 잡는다.
646
698
  - ❌ **콘텐츠 파일 사이트에서 페이지를 라우트로 신설**(`app/오시는길/page.tsx` 를 새로 짜기). ✅ `content/pages/<slug>.json`
647
699
  **+ 매니페스트 1행**이면 끝이고 라우팅·sitemap 은 이미 있다(§4.8). 라우트를 새로 짜면 그 페이지만 계약 밖으로
648
700
  나가 다음번 "말로 고치기"가 다시 tsx 탐색이 된다 — 실측된 회귀다.
649
- - ❌ **소스 파일에 숫자 id 적기**(`"assetId": 12`·`"productIds": [3,7]`). ✅ 소스 방언으로 — 에셋은 `public/`
650
- 루트 절대 경로, 상품은 handle(§9.2). 숫자 id 테넌트 스코프라 소스를 재업로드하면 의미를 잃는다.
701
+ - ❌ **콘텐츠 파일에 숫자 id 적기**(`"assetId": 12`·`"productIds": [3,7]`). ✅ 에셋은 `public/` 루트 절대
702
+ 경로로 적는다(§9.2). 상품·갈래는 애초에 선언이 아니라 tsx 직접 호출로 그린다(§9.3 규약).
703
+ 숫자 id 는 테넌트 스코프라 그 소스를 재업로드하면 의미를 잃는다.
651
704
  - ❌ **콘텐츠 파일에 `sortOrder` 를 넣거나 읽은 뒤 정렬**. ✅ 배열 순서가 곧 화면 순서다(§9.1).
652
705
  - ❌ **콘텐츠 json 을 런타임 `fs.readFile` 로 읽기.** ✅ `content/index.ts` 정적 import — fs 로 읽으면 dev HMR 이
653
706
  안 돌고 `next build`(standalone) 산출물에서 페이지가 통째로 사라진다.
@@ -740,6 +793,29 @@ npx zalkera-aeo-check --print-guarantees # 잣대 해석
740
793
  - ❌ 배송 전(PAID)에 후기 작성 시도 → `NOT_DELIVERED_YET`(409). ✅ 주문이 **DELIVERED 이상**일 때만
741
794
  버튼을 낸다(상태로 게이트). orderItemId 는 주문 상세 items[].id 에서만 얻는다 — 상품 상세엔 없다.
742
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
+
743
819
  ## 6. 에러 처리
744
820
 
745
821
  **원인 분기는 `e.code`(기계 판독 `errorCode`)로 한다.** 한 엔드포인트가 같은 상태코드로 여러 원인을 낸다 —
@@ -938,14 +1014,14 @@ shadcn 소스는 자기 변수층(`--card`·`--muted-foreground` …)을 전제
938
1014
 
939
1015
  ## 9. 섹션 어휘 — 페이지는 데이터로 그린다
940
1016
 
941
- 고정 페이지는 본문 HTML 한 덩어리가 아니라 **섹션 배열**이다. 각 섹션은 `type`(코드 enum)과 `config`(타입별
942
- JSON)뿐이고, 백엔드는 config 를 **파싱하지 않는다** — 스키마는 프론트 계약이다.
1017
+ 고정 페이지는 본문 HTML 한 덩어리가 아니라 **섹션 배열**이다. 각 섹션은 `type`(계약 어휘의 값)과
1018
+ `config`(타입별 JSON)뿐이고, 백엔드는 config 를 **파싱하지 않는다** — 스키마는 프론트 계약이다.
943
1019
 
944
1020
  **왜 이 구조인가**: 콘텐츠가 마크업에 박히지 않고 계약을 타므로, 테넌트가 "말로" 고친 것이 재코딩 없이
945
1021
  반영된다("FAQ 에 배송 질문 추가해줘"). **마크업에 문구를 하드코딩하면 이 경로가 죽는다** — 문구 한 줄을
946
1022
  고치는 일이 컴포넌트 트리 탐색이 되고, 그 탐색이 곧 토큰이다.
947
1023
 
948
- **그 배열이 사는 곳은 둘이고, 어느 쪽인지는 레포가 선언한다.**
1024
+ **그 배열이 사는 곳은 레포 파일 하나다**(`content/pages/*.json` §9.1). 레포는 그 사실을 선언한다.
949
1025
 
950
1026
  ```json
951
1027
  // package.json
@@ -954,17 +1030,17 @@ JSON)뿐이고, 백엔드는 config 를 **파싱하지 않는다** — 스키마
954
1030
 
955
1031
  | 선언 | 의미 |
956
1032
  |---|---|
957
- | `"source"` | 사이트의 얼굴(페이지·섹션·문구·섹션 이미지·내비)의 정본이 **레포 파일**. **새로 만드는 사이트는 이 형상을 쓴다**(v1 팩 태생 사이트가 남아 있는 동안 그쪽은 `sections-db` 다) |
958
- | `"sections-db"` | 전환기 표기섹션이 백엔드에 있고 `getPage(slug)` 준다( 프리셋 태생 사이트) |
959
- | 미선언 | 안전 기본 — **선언이 없으면 이 계약을 안 쓰는 레포로 본다**(계약 검사도 걸지 않는다) |
1033
+ | `"source"` | 사이트의 얼굴(페이지·섹션·문구·섹션 이미지·내비)의 정본이 **레포 파일**. 지금 만드는 사이트는 전부 형상이다 |
1034
+ | 미선언 | 안전 기본**선언이 없으면 계약을 쓰는 레포로 본다**(계약 검사도 걸지 않는다). 문구를 tsx 에 직접 든 레포가 여기고, 그래도 개시·발행·"말로 고치기"는 전부 정상이다 |
960
1035
 
961
- > ⚠ 선언은 **지금은 계약 표기일 뿐**이다. 콘솔이 값을 읽어 페이지·섹션 폼을 감추는 배선은
962
- > **아직 서지 않았다** 그때까지는 선언과 무관하게 콘솔 폼이 보인다. 레시피가 실물을 앞지르지
963
- > 않으려고 그대로 적는다.
1036
+ > ⚠ **`"sections-db"` 은퇴한 값이다**(어휘 rev 7 · memo144). 섹션이 백엔드 DB 에도 살던 시절의
1037
+ > 전환기 표기였는데 거처(`page_section` 계열)가 통째로 퇴역했다 값과 함께 `getPage`·`listPages`·
1038
+ > `listMenus` 표면도, 콘솔의 페이지·섹션·메뉴 편집 화면도 내려갔다. 아직 이 값을 선언한 레포가 있다면
1039
+ > 검사기(`npx zalkera-validate`)가 경고로 알려 준다. 옮길 곳은 §9.1 이고, 옮기고 나면 선언은 `"source"` 다.
964
1040
 
965
- 거처는 **같은 어휘·같은 config 키**를 쓴다. 다른 것은 참조를 적는 방언 하나뿐이다(§9.2).
1041
+ 거처가 하나라 **어휘도 config 키도 벌**이다. 참조를 적는 표기는 §9.2 가 정본이다.
966
1042
 
967
- ### 9.1 소스 정본 (`"content": "source"`) — 권장 형상
1043
+ ### 9.1 콘텐츠 파일 (`"content": "source"`) — 사이트 얼굴의 정본
968
1044
 
969
1045
  ```
970
1046
  content/
@@ -988,12 +1064,12 @@ content/
988
1064
  - ⚠ **여기에 상품 목록을 적으려 하지 마라.** 콘텐츠 파일이 나르는 것은 **값이 이 파일에 사는 저작물**
989
1065
  (문구·이미지 경로·링크·배열 순서)뿐이다. 상품·갈래는 값이 업무 DB 에 살고 화면은 비추기만 하므로
990
1066
  **소스가 `listProducts()`·`listProductCategories()` 를 직접 호출**해 그린다 — 그 진열의 자리·형태·필드는
991
- 전부 tsx 의 자유다(어휘 rev 6 · §9.4).
1067
+ 전부 tsx 의 자유다(어휘 rev 6 · §9.3).
992
1068
 
993
1069
  - **배열 순서가 화면 순서다.** `sortOrder` 키는 **없다** — 정렬하지 마라. 순서를 정하는 곳이 둘이면
994
1070
  "후기를 위로 올려줘"가 매번 어느 쪽을 고치는지 판별 문제가 된다.
995
- - **`config` 는 객체다**(문자열이 아니다). 거처를 하나의 소비자로 읽으려면 `readConfig` 를 쓴다
996
- 문자열이든 객체든 받는다.
1071
+ - **`config` 는 객체다**(문자열이 아니다). 그래도 읽을 때는 `readConfig` 를 써라 손으로 고친 파일이
1072
+ 통째 문자열을 실어 와도 섹션 컴포넌트가 한 벌로 버틴다.
997
1073
  - **정적 import 매니페스트를 우회하지 마라.** 런타임 `fs` 로 읽으면 ⑴ dev 에서 json 을 고쳐도 HMR 이
998
1074
  안 돌고(확인이 비싸지면 재시도가 늘고 그게 곧 토큰이다) ⑵ `next build`(standalone) 산출물에 콘텐츠가
999
1075
  트레이싱되지 않아 **개시된 사이트에서만** 페이지가 사라진다.
@@ -1005,17 +1081,22 @@ content/
1005
1081
  - 이 계약을 **안 지킨 레포도 정상으로 돈다.** 문구를 tsx 에 직접 든 레포도 개시·발행·"말로 고치기"가
1006
1082
  전부 동작한다 — 강제가 아니라 권장이고, 검사는 위 선언을 **스스로 했을 때만** 격상된다.
1007
1083
 
1008
- ### 9.2 참조 방언 — 소스는 숫자 id 를 못 쓴다
1084
+ ### 9.2 참조 표기 — 소스는 숫자 id 를 못 쓴다
1009
1085
 
1010
- DB자기가 발급한 숫자 id 를 쓰고, **소스는 그 id 를 알 수 없으므로** 사람이 읽고 쓰는 참조를 쓴다.
1011
- **거처가 방언을 정한다.**
1086
+ 숫자 id 백엔드가 발급하고 테넌트마다 다른데, **소스는 그 id 를 알 수 없다**(그리고 알아도 쓴다
1087
+ 아래). 그래서 콘텐츠 파일의 참조는 사람이 읽고 쓰는 문자열이다. 계약 어휘 표(§9.3)의 키 이름은 `assetId`
1088
+ 계열로 굳어 있는데, 그것은 섹션이 DB 컬럼에 살던 시절 표기가 그대로 남은 것이다 — **파일에 적을 때는
1089
+ 아래 오른쪽 열로 옮겨 적는다.**
1012
1090
 
1013
- | 참조 | DB 방언(`sections-db`) | 소스 방언(`source`) |
1091
+ | 참조 | 어휘 표의 키 이름(§9.3) | **콘텐츠 파일에 적는 표기** |
1014
1092
  |---|---|---|
1015
- | 에셋 | `assetId`·`*AssetId` : number → `mediaSrc(id)` | `asset`·`*Asset` : `"/images/hero.png"` → `assetPath(v)` |
1093
+ | 에셋 | `assetId`·`*AssetId` : number | `asset`·`*Asset` : `"/images/hero.png"` → `assetPath(v)` |
1094
+
1095
+ 키의 **의미**는 같다 — 바뀌는 것은 이름과 값의 형뿐이다. `mediaSrc(id)` 는 이 축이 아니라 **카탈로그가
1096
+ 발급한 id**(예 `ProductDetail.coverAssetId`)를 그릴 때 쓴다.
1016
1097
 
1017
1098
  - ❌ **섹션 config 에 업무 참조 키를 적지 마라**(`product`·`products`·`*Product(s)`·`categorySlug`).
1018
- rev 6 에서 이것은 방언이 아니라 **금지 키 형상**이다 — 그 키를 소비하던 타입 둘이 어휘에서 삭제됐고,
1099
+ rev 6 에서 이것은 참조 표기가 아니라 **금지 키 형상**이다 — 그 키를 소비하던 타입 둘이 어휘에서 삭제됐고,
1019
1100
  진열은 소스 직접 호출의 소관이 됐다. 자기 소스가 자기 카탈로그의 handle 을 **tsx 안에서** 큐레이션으로
1020
1101
  가리키는 것은 정당하다(자기 카탈로그 안에서만 참이면 된다) — 금지되는 것은 **배송물의 선언**이다.
1021
1102
  - ❌ **소스에 숫자 id 를 적지 마라.** 테넌트 스코프 값이라, 고객이 그 소스를 소유하고 다른 테넌트에
@@ -1024,31 +1105,20 @@ DB 는 자기가 발급한 숫자 id 를 쓰고, **소스는 그 id 를 알 수
1024
1105
  - `assetPath` 는 **레포 루트 절대 경로만** 통과시킨다(스킴·`//`·`..`·역슬래시 → `undefined`). 이미지 실물은
1025
1106
  레포 `public/` 에 둔다. 원격 이미지는 계약 영역이 아니라 자유 영역이다.
1026
1107
  - `asHandleArray` 는 **배열 순서를 보존한다** — 그 순서가 노출 순서의 원장이다.
1027
- - 상품 상세의 커버(`coverAssetId`)는 소스 방언에서도 여전히 숫자 id 다. 그건 카탈로그가 발급한 값이지
1028
- 소스가 적는 값이 아니다 — 섹션 이미지와 상품 이미지는 다른 축이다.
1029
- - **아래 §9.4 어휘 표의 `config` 선언은 DB 방언 표기다.** 소스에 적을 표로 옮겨 적는다.
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 가 없다(실측).
1108
+ - 상품 상세의 커버(`coverAssetId`)는 **여전히 숫자 id 다.** 그건 카탈로그가 발급한 값이지 소스가 적는
1109
+ 값이 아니다 — 섹션 이미지와 상품 이미지는 다른 축이다.
1110
+ - **React key `type`+index 짠다.** 섹션에 id 없고(계약에 그런 필드가 없다) `sortOrder` 도 없다 —
1111
+ 배열 순서가 순서다(§9.1).
1042
1112
 
1043
- ### 9.4 어휘 (10종) — 두 거처 공통
1113
+ ### 9.3 어휘 (10종)
1044
1114
 
1045
1115
  **규약**
1046
1116
  - 파싱 헬퍼는 이 패키지가 준다: `readConfig`/`parseConfig` + `asString`/`asId`/`asIdArray`/`asObjectArray`
1047
- + 방언 헬퍼 `asHandle`/`asHandleArray`/`assetPath`. **절대 throw 하지 않는다** — 필수 필드가 없으면
1117
+ + 참조 헬퍼 `asHandle`/`asHandleArray`/`assetPath`. **절대 throw 하지 않는다** — 필수 필드가 없으면
1048
1118
  **그 섹션만 안 그린다**. 페이지 전체가 죽으면 안 된다.
1049
1119
  - **미지 타입은 조용히 건너뛴다.** 어휘는 append-only 라 타입이 늘어도 옛 사이트가 깨지지 않아야 한다.
1050
1120
  `default:` 에서 에러·경고를 내지 마라 — 그게 계약이다.
1051
- - **모든 href 는 `safeLinkUrl()` 을 태운다.** 콘솔 입력·손으로 고친 json 이라 `javascript:` 가 들어올 수 있다(저장형 XSS).
1121
+ - **모든 href 는 `safeLinkUrl()` 을 태운다.** 사람과 AI 손으로 고치는 json 이라 `javascript:` 가 들어올 수 있다(저장형 XSS).
1052
1122
  - 자유 HTML 은 없다 — plain text + 줄바꿈뿐(`whitespace-pre-wrap`).
1053
1123
  - **디스패치는 직접 짠다** — `type` 으로 컴포넌트를 고르는 `switch` 하나면 된다.
1054
1124
  - 타입·키를 **지어내지 마라.** 계약 정본은 백엔드 레포의 `doc/contracts/section-vocabulary.json` 이고,
@@ -1056,9 +1126,9 @@ page.sections // [{ type: "HERO", sortOrder: 0, config: "{\"title\":\"…\"}" },
1056
1126
  KDoc 이 같은 말을 한다). 코드에서 읽을 것은 `SECTION_CONTRACT` 가 맞지만, 어휘를 **늘리는** 결정은
1057
1127
  정본 쪽에서 난다 — 필요한 어휘가 없으면 만들지 말고 **보고**한다.
1058
1128
 
1059
- `vertical` 은 콘솔 픽커 그룹핑용이지 사용 제한이 아니다(GENERAL 은 뷰티 테넌트도 쓴다).
1129
+ `vertical` 은 어휘의 업종 분류일 사용 제한이 아니다(GENERAL 은 뷰티 사이트도 그대로 쓴다).
1060
1130
 
1061
- | type | vertical | config — **DB 방언 표기**(필수는 굵게 · 소스 방언 대응은 §9.2) | JSON-LD |
1131
+ | type | vertical | config — 이름은 **계약 표기**(필수는 굵게 · 파일에 적는 표기로의 대응은 §9.2) | JSON-LD |
1062
1132
  |---|---|---|---|
1063
1133
  | `HERO` | GENERAL | eyebrow?, **title**, subtitle?, ctaLabel?, ctaHref?, assetId? | — |
1064
1134
  | `FEATURE_GRID` | GENERAL | title?, **items**[{icon?, **title**, body?}] | — |
@@ -1082,7 +1152,7 @@ page.sections // [{ type: "HERO", sortOrder: 0, config: "{\"title\":\"…\"}" },
1082
1152
  - `TESTIMONIALS` 에 **`Review`·`AggregateRating` 을 내지 마라.** 자사 사이트의 자사 후기에 별점을 붙이는 것은
1083
1153
  self-serving reviews 정책 위반이라 제재 대상이다. 이건 빠뜨린 게 아니라 결정이다.
1084
1154
  - `LEAD_CTA` 의 리드 폼은 **클라이언트 아일랜드**(`"use client"`)로 만들고 `/api/lead` BFF 로 POST 한다
1085
- (`clientIp` 는 서버가 XFF 에서 붙인다). 전환 부품을 두 벌 만들지 마라 — 하나로 재사용한다. 요건 넷:
1155
+ (`clientIp` 는 BFF `visitorIp(req.headers)` 로 뽑아 붙인다 — 첫 홉 직접 추출 금지·§2). 전환 부품을 두 벌 만들지 마라 — 하나로 재사용한다. 요건 넷:
1086
1156
  · **연락처 필수 · 이메일 선택**(문의 폼과 반대다). 본문은 `{name, phone, email?, message?, interest,
1087
1157
  isQuick, consentMarketing, tracking}`.
1088
1158
  · **UTM·클릭ID 8키**를 `tracking` 으로 동봉한다 — `utm_source`·`utm_medium`·`utm_campaign`·
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalkera/client",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "zalkera 헤드리스 CMS 공개 API 클라이언트 (테넌트 사이트용)",
5
5
  "license": "MIT",
6
6
  "author": "Credium Co., Ltd.",