@zalkera/client 0.13.0 → 0.14.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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/llms.txt +496 -58
  3. package/package.json +57 -57
package/README.md CHANGED
@@ -69,7 +69,7 @@ await zalkera.submitInquiry({name, email, subject, message}, {clientIp});
69
69
  `npx zalkera-aeo-check <사이트URL> --category BOOKING`(bin `zalkera-aeo-check`).
70
70
  소스가 아니라 산출물을 재므로 스택·디자인을 가리지 않는다.
71
71
  보장 주장이 없는 사이트는 `--category` 대신 `--site-wide-only`(robots·sitemap·JSON-LD 절대 URL 만).
72
- 스토어프론트 템플릿의 `npm run check:aeo` 는 이 bin 을 부르는 wrapper 다.
72
+ 잣대 해석만 확인하려면 URL 없이 `--print-guarantees`.
73
73
  - 정본은 잘커라 백엔드에 있고 여기 실린 것은 그 **발행 산출물**이다(발행 전 기계 대조).
74
74
 
75
75
  ## API 스펙 (메서드)
package/llms.txt CHANGED
@@ -62,6 +62,9 @@ await zalkera.submitInquiry(input, { clientIp: ip });
62
62
  - `getSiteConfig()` — 회사명·연락처·테마·SEO 기본값
63
63
  - `listCategories()` · `listPosts({category?,page?,size?,sort?})` · `getPost(slug)` · `recordPostView(slug,ctx)`
64
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 라 메서드는 남지만, 소스 정본 레포에서 이걸 부르면 빈 결과로 화면이 비는 쪽이 정상이다.
65
68
  - `submitInquiry(input, ctx)` · `submitLead(input, ctx)` ← ctx.clientIp 필수(서버)
66
69
 
67
70
  ### 커머스 — 카탈로그(공개)
@@ -144,7 +147,7 @@ await zalkera.submitInquiry(input, { clientIp: ip });
144
147
  빈 배열은 truthy 라 `if (items)` 로 결측을 판별하면 안 된다 — `items.length` 을 봐라.
145
148
  - 위 가드로 **필수 값이 없으면 그 섹션만 안 그린다**. 페이지 전체가 죽으면 안 된다.
146
149
  - `mediaSrc(assetId)` → `/media/{id}` **경로 문자열**. 이미지는 전부 이걸 통한다.
147
- 전제: 프로젝트에 `app/media/[id]/route.ts` 프록시 라우트가 있어야 한다(템플릿에 있다).
150
+ 전제: 프로젝트에 `app/media/[id]/route.ts` 프록시 라우트가 있어야 한다(**§4.9 에 전문**).
148
151
  이 헬퍼는 경로만 만든다 — 라우트를 안 만들면 이미지가 404 다.
149
152
  - `asHandle(v)` / `asHandleArray(v)` / `assetPath(v)` — **참조 방언**(계약 `contractRev` 4 의 `dialects`)을 읽는다.
150
153
  숫자 id 를 못 쓰는 자리에서 상품은 `handle`(= `ProductSummary.slug`), 에셋은 레포 `public/` **루트 절대 경로**로
@@ -236,7 +239,7 @@ export async function POST(req: Request) {
236
239
  if (session.widget) {
237
240
  // 위젯형(토스) — 내 사이트에서 결제창을 띄우고, 성공 콜백 파라미터를 confirm 으로 넘긴다
238
241
  // widget = { vendor:"TOSS_PAYMENTS", clientKey, orderId, amount, orderName }
239
- // → 템플릿의 checkout/page.tsx(openWidget)·payment/complete/page.tsx 그대로 쓴다
242
+ // → 결제창 페이지에서 openWidget, 성공 리다이렉트 페이지에서 confirm (이 절의 흐름 그대로)
240
243
  await zalkera.confirmPayment(orderNo, { paymentKey }, access); // BFF 경유
241
244
  } else {
242
245
  redirect(session.paymentUrl); // 리다이렉트형(PG 결제창으로 이동)
@@ -260,7 +263,8 @@ export async function POST(req: Request) {
260
263
  >
261
264
  > 관용구는 소비자에 따라 둘이다:
262
265
  > - **브라우저(쿠키 있음)** = `` `co-${session.cartSessionKey}` `` **+ 체크아웃 성공 응답에서 카트 쿠키 회전**
263
- > (템플릿 `rotateCartSessionKey`). **회전 없이 카트키를 멱등키로 쓰지 마라** — 카트 쿠키는 30일이라
266
+ > (체크아웃 BFF 응답에 `response.cookies.set(CART_COOKIE, randomUUID(), {httpOnly:true, sameSite:"lax",
267
+ > secure, path:"/", maxAge: 30일})`). **회전 없이 카트키를 멱등키로 쓰지 마라** — 카트 쿠키는 30일이라
264
268
  > 회전이 없으면 위의 "영구 재사용"이 그대로 실현된다(실측 사고). 회전을 응답에 실으면 Set-Cookie 가
265
269
  > 성공 도달과 원자적으로 묶여서, 응답 유실 시엔 옛 키가 살아남아 재시도가 원주문을 재생한다 —
266
270
  > **인메모리 "시도당 키"보다 이 쪽이 안전하다**(새로고침에 안 죽는다).
@@ -295,8 +299,8 @@ const shipment = await zalkera.getShipment(orderNo, { phone }); // 배송 상태
295
299
  // 1) 슬롯 보여주기 — 공개(비로그인 가능).
296
300
  // ⚠️ 이 RSC 직독 샘플은 **요청마다 렌더되는 동적 라우트에서만** 신선하다. 상품 상세가 ISR
297
301
  // (force-static + revalidate=N)이면 슬롯이 프리렌더에 구워져 N초 낡은 시간표를 보여주고
298
- // SLOT_FULL 을 만든다 — 그땐 **클라이언트 아일랜드 + BFF 프록시**로 내려라(템플릿
299
- // BookingPanel + /api/booking/availability 가 선례다. 볼라틸한 데이터는 클라이언트 아일랜드로).
302
+ // SLOT_FULL 을 만든다 — 그땐 **클라이언트 아일랜드 + BFF 프록시**로 내려라(예약 패널을
303
+ // "use client" 로 두고 /api/booking/availability 가 대신 조회. 볼라틸한 데이터는 아일랜드로).
300
304
  const slots = await zalkera.availability({
301
305
  productId: product.id, // slug 아님 — getProduct(slug).id
302
306
  from: new Date().toISOString(),
@@ -343,7 +347,272 @@ if (booking.orderNo) { // status=PENDING
343
347
  깨므로 금지(하나라도 있으면 `tracking` 을 채우고, 전무면 undefined).
344
348
  - **BFF 필수**: 브라우저에서 `submitLead` 직호출 금지(§2). route handler 에서 `x-forwarded-for` 첫
345
349
  홉을 `{ clientIp }` 로 넘긴다(안 넘기면 방문자 전원 429 — §4.6·문의와 동일 관용구).
346
- - 선례: `components/LeadForm.tsx`(아일랜드·UTM 캡처) · `app/api/lead/route.ts`(BFF·201 관통).
350
+
351
+ ### 4.8 콘텐츠 파일로 그리는 고정 페이지 (`"content": "source"` · 권장)
352
+
353
+ 사이트의 얼굴을 레포가 정본으로 가질 때의 배선이다. 파일 형상·방언은 §9.1·§9.2, 아래는 그것을 읽는 코드다.
354
+ **라우트는 하나면 된다** — 페이지가 늘어도 `content/` 에 json 이 느는 것이지 라우트가 늘지 않는다.
355
+
356
+ ```ts
357
+ // content/index.ts — 이 디렉터리의 유일한 코드. **정적 import** 라야 dev HMR + standalone 트레이싱을 얻는다.
358
+ import home from "./pages/home.json";
359
+ import about from "./pages/about.json";
360
+ import nav from "./nav.json";
361
+
362
+ // 값을 unknown 으로 둔다: 손으로 고치는 파일이라 형상이 틀릴 수 있고, 판정·강하는 로더 한 곳이 한다.
363
+ // 여기서 타입을 주장하면 틀린 json 하나가 **빌드를 세운다** — 계약은 "틀린 부분만 안 그린다" 다.
364
+ export const pages: Record<string, unknown> = { home, about };
365
+ export { nav };
366
+ ```
367
+
368
+ ```ts
369
+ // lib/content.ts — **절대 throw 하지 않는다.**
370
+ import { asString } from "@zalkera/client";
371
+ import { pages } from "../content";
372
+
373
+ export interface ContentSection { type: string; config: unknown } // config 는 **객체**다
374
+
375
+ export function loadPageContent(slug: string) {
376
+ const raw = pages[slug];
377
+ if (raw == null || typeof raw !== "object" || Array.isArray(raw)) return null;
378
+ const r = raw as Record<string, unknown>;
379
+ const sections = (Array.isArray(r.sections) ? r.sections : []).flatMap((s): ContentSection[] => {
380
+ if (s == null || typeof s !== "object") return [];
381
+ const type = asString((s as Record<string, unknown>).type)?.trim();
382
+ return type ? [{ type, config: (s as Record<string, unknown>).config }] : []; // config 는 그대로 넘긴다
383
+ });
384
+ return { title: asString(r.title)?.trim() || slug, seo: r.seo, sections };
385
+ }
386
+
387
+ export const pageSlugs = (): string[] => Object.keys(pages); // sitemap·generateStaticParams 가 읽는다
388
+ ```
389
+
390
+ ```tsx
391
+ // app/[slug]/page.tsx — 전 페이지를 이 하나가 그린다. 백엔드 왕복이 없다.
392
+ export const dynamic = "force-static";
393
+ export function generateStaticParams() { return pageSlugs().map((slug) => ({ slug })); }
394
+
395
+ export default async function StaticPage({ params }: { params: Promise<{ slug: string }> }) {
396
+ const { slug } = await params;
397
+ const page = loadPageContent(slug);
398
+ if (!page) notFound();
399
+ if (slug === "home") redirect("/"); // 홈의 정본 주소는 루트다(같은 내용이 두 URL 로 색인되면 손해)
400
+ return (
401
+ <main>
402
+ <JsonLd data={webPageJsonLd({ title: page.title, slug }, siteUrl())} />
403
+ <h1>{page.title}</h1>
404
+ <SectionList sections={page.sections} /> {/* 정렬하지 않는다 — 배열 순서가 화면 순서다 */}
405
+ </main>
406
+ );
407
+ }
408
+ ```
409
+
410
+ - 상품 참조 섹션(`SERVICE_MENU`·`BOOKING_CTA`)이 있으면 `SectionList` 가 `listProducts` 를 **1회** 부르고
411
+ `slug`(= handle)를 키로 맵을 만들어 각 섹션에 넘긴다. 조회가 실패해도 페이지는 산다 — 그 섹션만 빠진다.
412
+ - 홈(`app/page.tsx`)은 `loadPageContent("home")` 의 섹션이 있으면 그것을 그리고, 없으면 커머스 골격으로
413
+ 강하한다. 콘텐츠 없는 것은 **정상**이다(커머스 테넌트).
414
+
415
+ ### 4.9 미디어 프록시 라우트 (`/media/{id}`) — **필수 부품**
416
+
417
+ `mediaSrc(assetId)` 는 `/media/{id}` **경로 문자열만** 만든다. 그 경로를 받는 라우트는 프로젝트가 갖는다.
418
+ **안 만들면 사이트의 이미지가 전부 404 다.** 아래를 `app/media/[id]/route.ts` 로 그대로 복사해라.
419
+
420
+ 왜 프록시가 필요한가 — 백엔드 공개 미디어는 둘 다 브라우저·크롤러가 직접 못 쓴다:
421
+ - `/api/public/media/{id}/url` → **수 분 뒤 만료되는 presigned URL**. ISR 로 캐시된 HTML 안에서 죽고,
422
+ JSON-LD 에 넣으면 크롤러가 캐시한 뒤 깨진 이미지가 된다.
423
+ - `/api/public/media/{id}/raw` → 안정 URL 이지만 **`X-Tenant` 헤더를 요구**한다(없으면 400). 크롤러도
424
+ `<img>` 도 그 헤더를 못 보낸다.
425
+
426
+ ```ts
427
+ import {NextResponse} from "next/server";
428
+
429
+ export async function GET(_req: Request, {params}: {params: Promise<{id: string}>}) {
430
+ const {id} = await params;
431
+ // id 를 URL 에 이어붙이므로 숫자만 통과시킨다(경로 주입 차단).
432
+ if (!/^\d+$/.test(id)) return new NextResponse(null, {status: 404});
433
+
434
+ // **미설정이면 여기서 죽는다.** 빈 헤더로 백엔드를 부르면 임의 테넌트에 조용히 붙거나
435
+ // 전 이미지가 400 이 되는데, 둘 다 원인이 안 보인다.
436
+ const base = process.env.ZALKERA_API_BASE;
437
+ const tenant = process.env.ZALKERA_TENANT;
438
+ if (!base || !tenant) throw new Error("ZALKERA_API_BASE·ZALKERA_TENANT 미설정");
439
+
440
+ let res: Response;
441
+ try {
442
+ res = await fetch(`${base}/api/public/media/${id}/raw`, {
443
+ headers: {"X-Tenant": tenant},
444
+ // 302 를 따라가면 이미지 바이트가 이 런타임을 통과한다 — Location 만 필요하다.
445
+ redirect: "manual",
446
+ cache: "no-store",
447
+ });
448
+ } catch {
449
+ return new NextResponse(null, {status: 502});
450
+ }
451
+
452
+ const location = res.headers.get("location");
453
+ // 302+Location 이 없으면(없는 id·타 테넌트 id) 그대로 없는 것으로 취급한다.
454
+ if (!location) return new NextResponse(null, {status: res.status === 404 ? 404 : 502});
455
+
456
+ return NextResponse.redirect(location, {status: 302, headers: {"Cache-Control": "no-store"}});
457
+ }
458
+ ```
459
+
460
+ - **바이트를 스트리밍하지 마라.** 그러면 모든 이미지 트래픽이 Next 런타임을 통과해 밀도 비용이 붙는다.
461
+ 바이트는 스토리지→브라우저 직행하고, 이미지뷰당 서명 요청 1회만 낸다.
462
+ - **`no-store` 를 지켜라** — 302 대상이 곧 만료되므로 이 응답을 캐시하면 죽은 링크를 재사용한다
463
+ (이미지 바이트는 스토리지 응답 헤더로 브라우저가 캐시한다).
464
+
465
+ ### 4.10 JSON-LD 부품 — **그대로 복사해서 쓴다**
466
+
467
+ §5.1 이 요구하는 그래프를 만드는 부품이다. 재발명하지 마라 — 아래가 정본이고, 지어낸 필드·빈 값은
468
+ 구조화 데이터 정책 위반(리치결과 박탈)이다. **값이 없으면 필드를 통째로 뺀다**(널·0 을 넣지 않는다).
469
+
470
+ 삽입 컴포넌트(서버 컴포넌트로 유지 — `"use client"` 금지):
471
+
472
+ ```tsx
473
+ export function JsonLd({data}: {data: object}) {
474
+ return (
475
+ <script
476
+ type="application/ld+json"
477
+ // `<` 를 유니코드 이스케이프 — 데이터에 `</script>` 가 섞여도 스크립트가 조기 종료되지
478
+ // 않는다(JSON-LD 삽입의 고전적 XSS 벡터). JSON.stringify 는 이걸 해주지 않는다.
479
+ dangerouslySetInnerHTML={{__html: JSON.stringify(data).replace(/</g, "\\u003c")}}
480
+ />
481
+ );
482
+ }
483
+ ```
484
+
485
+ 빌더 — 전부 순수 함수다(평범한 객체를 반환한다):
486
+
487
+ ```ts
488
+ import type {PostDetail, ProductDetail, RatingSummary, SiteConfig} from "@zalkera/client";
489
+
490
+ /** 상품 상세. **variant 마다 Offer 하나** — 우리는 항상 variant 단위로 판다(옵션 없는 상품도 default 1개). */
491
+ export function productJsonLd(product: ProductDetail, rating?: RatingSummary | null, siteBase = "", returnPolicy?: object | null) {
492
+ const url = `${siteBase}/products/${product.slug}`;
493
+ const offers = product.variants.map((v) => ({
494
+ "@type": "Offer", url, price: v.price, priceCurrency: v.currency,
495
+ availability: v.inStock ? "https://schema.org/InStock" : "https://schema.org/OutOfStock",
496
+ ...(v.sku ? {sku: v.sku} : {}),
497
+ ...(v.optionSignature ? {name: v.optionSignature} : {}), // 단순 상품은 빈 문자열 — 이름을 안 붙인다
498
+ ...(returnPolicy ? {hasMerchantReturnPolicy: returnPolicy} : {}),
499
+ }));
500
+ return {
501
+ "@context": "https://schema.org", "@type": "Product", name: product.name, url,
502
+ // 페이지가 그리는 그 이미지 — **안정 URL(`/media/{id}`)**. presigned 는 만료돼 크롤러 캐시가 죽는다.
503
+ ...(product.coverAssetId != null ? {image: [`${siteBase}/media/${product.coverAssetId}`]} : {}),
504
+ ...(product.description ? {description: product.description} : {}),
505
+ ...(offers.length > 0 ? {offers} : {}),
506
+ // 후기 0건이면 aggregateRating 자체를 뺀다 — ratingValue:0 은 "0점짜리 상품"이라는 거짓 진술이다.
507
+ ...(rating && rating.reviewCount > 0
508
+ ? {aggregateRating: {"@type": "AggregateRating", ratingValue: rating.averageRating, reviewCount: rating.reviewCount}}
509
+ : {}),
510
+ };
511
+ }
512
+
513
+ /** 사이트 주체 — 홈에 1회. 업태는 **명시 입력값으로만** 좁힌다(테마 선택은 업태 진술이 아니다). */
514
+ export function organizationJsonLd(config: SiteConfig, siteBase: string, type?: string) {
515
+ return {
516
+ "@context": "https://schema.org",
517
+ // 모르는 값·미설정은 Organization — 거짓 진술보다 덜 구체적인 진술이 낫다.
518
+ // 온라인 전용 몰에 LocalBusiness 를 붙이지 마라(주소·영업시간을 요구하는 타입이다).
519
+ "@type": type ?? (config.businessType === "BEAUTY" ? "BeautySalon" : "Organization"),
520
+ name: config.companyName, url: siteBase,
521
+ ...(config.tel ? {telephone: config.tel} : {}),
522
+ ...(config.email ? {email: config.email} : {}),
523
+ ...(config.address ? {address: {"@type": "PostalAddress", streetAddress: config.address, addressCountry: "KR"}} : {}),
524
+ };
525
+ }
526
+
527
+ /** 블로그·공지 상세. **author 를 넣지 마라** — 데이터에도 화면에도 없다(지어내면 위반). */
528
+ export function blogPostingJsonLd(post: PostDetail, siteBase: string) {
529
+ return {
530
+ "@context": "https://schema.org", "@type": "BlogPosting",
531
+ headline: post.title, url: `${siteBase}/blog/${post.slug}`,
532
+ ...(post.publishedAt ? {datePublished: post.publishedAt} : {}),
533
+ ...(post.summary ? {description: post.summary} : {}),
534
+ ...(post.coverAssetId != null ? {image: [`${siteBase}/media/${post.coverAssetId}`]} : {}),
535
+ };
536
+ }
537
+
538
+ /** 고정 페이지(회사소개·이용안내 등). 없으면 순수 마케팅 사이트는 인용될 노드가 홈 하나뿐이 된다. */
539
+ export function webPageJsonLd(page: {title: string; slug: string}, siteBase: string, description?: string) {
540
+ return {
541
+ "@context": "https://schema.org", "@type": "WebPage",
542
+ name: page.title, url: `${siteBase}/${page.slug}`, // name 은 페이지가 그리는 <h1> 과 같은 값
543
+ ...(description ? {description} : {}),
544
+ };
545
+ }
546
+
547
+ function itemListNode(items: Array<{name: string; url: string}>) {
548
+ return {
549
+ "@type": "ItemList",
550
+ // position 은 **화면에 보이는 그 순서** — 원장이 정한 노출 순서를 기계에도 같은 순서로 준다.
551
+ itemListElement: items.map((item, i) => ({"@type": "ListItem", position: i + 1, name: item.name, url: item.url})),
552
+ };
553
+ }
554
+
555
+ /**
556
+ * 목록 표면(상품 목록·시술 메뉴·글 목록). **요약형** — 각 항목은 url 로 상세를 가리키고 이름만 든다.
557
+ * 가격·재고를 여기 복제하지 마라: 같은 사실이 두 곳에 있으면 갈라지고 갈라진 쪽이 거짓이 된다.
558
+ * 가격의 정본은 상세의 `Offer` 다.
559
+ */
560
+ export function itemListJsonLd(items: Array<{name: string; url: string}>) {
561
+ return {"@context": "https://schema.org", ...itemListNode(items)};
562
+ }
563
+
564
+ /**
565
+ * 카테고리 페이지 — `ItemList` 를 `mainEntity` 로 **품는다**(나란히 내면 "이 페이지가 곧 그 목록"이
566
+ * 안 전해진다). **상품 0건이어도 낸다** — 이 페이지의 주어가 이 카테고리 하나라 "지금 0건"이 참인
567
+ * 진술이기 때문이다(카탈로그 전체 목록에서 빈 ItemList 를 빼는 것과 갈리는 자리다).
568
+ */
569
+ export function collectionPageJsonLd(category: {name: string; slug: string}, items: Array<{name: string; url: string}>, siteBase: string, description?: string) {
570
+ return {
571
+ "@context": "https://schema.org", "@type": "CollectionPage",
572
+ name: category.name, url: `${siteBase}/c/${category.slug}`,
573
+ ...(description ? {description} : {}),
574
+ mainEntity: itemListNode(items), // 중첩 노드에 @context 를 다시 달지 않는다
575
+ };
576
+ }
577
+
578
+ /** 경로 이동 — 검색결과에 `홈 > 상품 > 이름` 으로 노출된다. items 는 표시순. */
579
+ export function breadcrumbJsonLd(items: Array<{name: string; url: string}>) {
580
+ return {
581
+ "@context": "https://schema.org", "@type": "BreadcrumbList",
582
+ itemListElement: items.map((item, i) => ({"@type": "ListItem", position: i + 1, name: item.name, item: item.url})),
583
+ };
584
+ }
585
+ ```
586
+
587
+ **환불 정책**(`MerchantReturnPolicy`)을 Offer 에 붙이면 구글이 "무료 반품·N일 이내"를 리치결과에 노출하고
588
+ AI 에이전트가 "이 가게 환불 되나"를 읽는다. 두 규율이 중요하다:
589
+ - **금액은 `config.defaultReturnShippingFee`(운영 값)** 를 쓴다 — 정책 문구가 아니라. 실제 차감액과
590
+ 표시가 갈리면 표시 의무 위반이다.
591
+ - **기간(`windowDays`)이 없으면 정책 전체를 내지 않는다** — 구글이 `merchantReturnDays` 를 요구하고,
592
+ 창구를 모르는 채 "반품 됨"만 주장하는 건 무의미하다.
593
+
594
+ ```ts
595
+ export function merchantReturnPolicyJsonLd(config: SiteConfig, windowDays?: number) {
596
+ // 구글이 정수를 요구한다. 7.5·-1 을 흘리면 정책 노드 전체가 무효 판정된다.
597
+ if (windowDays == null || !Number.isInteger(windowDays) || windowDays < 0) return null;
598
+ const fee = config.defaultReturnShippingFee;
599
+ return {
600
+ "@type": "MerchantReturnPolicy", applicableCountry: "KR",
601
+ returnPolicyCategory: "https://schema.org/MerchantReturnFiniteReturnWindow",
602
+ merchantReturnDays: windowDays, returnMethod: "https://schema.org/ReturnByMail",
603
+ ...(fee != null && fee > 0
604
+ ? {returnFees: "https://schema.org/ReturnShippingFees",
605
+ returnShippingFeesAmount: {"@type": "MonetaryAmount", value: fee, currency: "KRW"}}
606
+ : {returnFees: "https://schema.org/FreeReturn"}), // 0원이면 무료 반품 — 그 사실이 노출된다
607
+ };
608
+ }
609
+ ```
610
+
611
+ > 테넌트 커머스 정책은 **스키마리스 JSON** 이라 소비 쪽에서 방어적으로 읽어라. 최상위 한 겹만 보면
612
+ > 부족하다 — `{"returns":{"notes":{"ko":"…","en":"…"}}}` 같은 다국어 객체가 흔한 확장 모양인데, 그걸
613
+ > 그대로 React 자식으로 그리면 *"Objects are not valid as a React child"* 로 페이지가 500 이 된다.
614
+ > **필드별로 `typeof` 를 확인**하고 형에 안 맞으면 그 필드만 버려라(절 전체를 버리지 않는다).
615
+ > 정책은 부가 정보이므로 파싱 실패가 페이지를 죽이면 안 된다.
347
616
 
348
617
  ## 5. 흔한 실수(하지 말 것)
349
618
 
@@ -360,6 +629,14 @@ if (booking.orderNo) { // status=PENDING
360
629
  - ❌ **`var(--oneq-*)` 참조**(정의처 없는 죽은 레거시 토큰). ✅ 테넌트 색은 토큰 유틸리티(`bg-primary`·
361
630
  `text-primary` 등, §8)로 쓴다.
362
631
  - ❌ 서버에서 `submitInquiry`/`submitLead`/`recordPostView` 부를 때 clientIp 누락 → 방문자 전원 rate-limit.
632
+ - ❌ **콘텐츠 파일 사이트에서 페이지를 라우트로 신설**(`app/오시는길/page.tsx` 를 새로 짜기). ✅ `content/pages/<slug>.json`
633
+ **+ 매니페스트 1행**이면 끝이고 라우팅·sitemap 은 이미 있다(§4.8). 라우트를 새로 짜면 그 페이지만 계약 밖으로
634
+ 나가 다음번 "말로 고치기"가 다시 tsx 탐색이 된다 — 실측된 회귀다.
635
+ - ❌ **소스 파일에 숫자 id 적기**(`"assetId": 12`·`"productIds": [3,7]`). ✅ 소스 방언으로 — 에셋은 `public/`
636
+ 루트 절대 경로, 상품은 handle(§9.2). 숫자 id 는 테넌트 스코프라 그 소스를 재업로드하면 의미를 잃는다.
637
+ - ❌ **콘텐츠 파일에 `sortOrder` 를 넣거나 읽은 뒤 정렬**. ✅ 배열 순서가 곧 화면 순서다(§9.1).
638
+ - ❌ **콘텐츠 json 을 런타임 `fs.readFile` 로 읽기.** ✅ `content/index.ts` 정적 import — fs 로 읽으면 dev HMR 이
639
+ 안 돌고 `next build`(standalone) 산출물에서 페이지가 통째로 사라진다.
363
640
  - ❌ **상품·후기·게시글 등 도메인 데이터를 하드코딩**(리터럴 배열·목업 후기 등). ✅ 반드시 `@zalkera/client`
364
641
  로 조회한다. 계약에 없는 데이터(예: slug→productId 매핑이 없어 후기를 못 붙임)면 **하드코딩으로 때우지
365
642
  말고 그 사실을 보고**한다. 하드코딩한 데이터는 프리뷰·실사이트에서 실데이터와 갈라져 첫인상을 죽인다.
@@ -369,7 +646,7 @@ if (booking.orderNo) { // status=PENDING
369
646
  실시간·개인화 데이터(라이브 재고·개인화)는 **클라이언트 컴포넌트(아일랜드)**로 가져오고, 상태 변경
370
647
  (장바구니·주문)은 **BFF route handler** 로 한다. 신선도는 **온디맨드 revalidate**(백엔드 데이터 변경 시
371
648
  `POST /api/revalidate`)로 지킨다. 동적 SSR 이 꼭 필요하면(예: 검색) **정당화 주석**(`// zalkera-allow-dynamic:
372
- <이유>`)이 필요하다 — 서버 동적 렌더는 상시 런타임 원가라 CI validator(§5)가 SEO 라우트에서 강제 차단한다.
649
+ <이유>`)이 필요하다 — 서버 동적 렌더는 **상시 런타임 원가**라 SEO 라우트에서는 기본이 아니다.
373
650
 
374
651
  ## 5.1 산출물 규범 — 발견되는 사이트 (필수)
375
652
 
@@ -379,8 +656,8 @@ if (booking.orderNo) { // status=PENDING
379
656
  - ✅ **SEO 페이지에 JSON-LD(schema.org) 필수.** 상품 상세 = `Product` + variant 마다 `Offer`(price·
380
657
  priceCurrency·availability) + 후기 있으면 `AggregateRating`. 홈 = `Organization`(오프라인 점포면
381
658
  `LocalBusiness`, 뷰티샵이면 `BeautySalon` 으로 좁힌다). 목록·상세엔 `BreadcrumbList`.
382
- 템플릿의 `src/components/JsonLd.tsx`(안전 직렬화 + `productJsonLd`/`organizationJsonLd`/
383
- `breadcrumbJsonLd` 헬퍼)를 **그대로 쓴다 — 재발명 금지**.
659
+ **§4.10 의 부품**(안전 직렬화 + `productJsonLd`/`organizationJsonLd`/`breadcrumbJsonLd`)을
660
+ **그대로 복사해 쓴다 — 재발명 금지**.
384
661
  - ✅ **목록 라우트도 그래프를 낸다 — `ItemList`.** 상품 목록(`/products`)·글 목록(`/blog`)은 **화면에 그리는
385
662
  바로 그 순서·그 항목**으로 `ItemList` 를 낸다(`itemListJsonLd`). 왜 목록에도 그래프가 필요한가: 상세 N건만
386
663
  있으면 "이 가게가 무엇을 파는가"를 기계가 한 번에 못 받고 상세를 하나씩 발견해야 한다 — 목록은 그 N건을
@@ -390,27 +667,26 @@ if (booking.orderNo) { // status=PENDING
390
667
  내지 않는다**(빈 목록을 그래프로 주장하지 않는다 — "페이지에 없는 것을 쓰지 마라"의 목록판). 목록에도
391
668
  `BreadcrumbList` 를 함께 낸다. 그리고 목록은 **ISR 로 유지한다** — 정렬·필터를 `searchParams` 로 받는 순간
392
669
  라우트가 동적 렌더로 강등돼 크롤러 방문마다 서버 렌더가 돈다. 더 넓히려면 `/products/page/[n]` 같은
393
- **정적 세그먼트**로 늘려라. 본보기: `src/app/products/page.tsx` · `src/app/blog/page.tsx`.
670
+ **정적 세그먼트**로 늘려라.
394
671
  - ✅ **예약(시술) 사이트의 목록 보장은 `SERVICE_MENU` 섹션이 낸다.** 섹션 어휘 `SERVICE_MENU` 는 계약상
395
- `jsonLd: "ItemList"` 이고(`SECTION_CONTRACT` · `contractRev` 2 이상), config 의 `productIds` **순서 그대로**
672
+ `jsonLd: "ItemList"` 이고(`SECTION_CONTRACT` · `contractRev` 2 이상), config 의 상품 참조 배열
673
+ (DB 방언 `productIds` · 소스 방언 `products` — §9.2) **순서 그대로**
396
674
  목록 그래프를 낸다 — 원장이 정한 노출 순서를 기계에도 같은 순서로 준다. 시술 목록의 정위치는 **홈의 이
397
675
  섹션**이지 별도 라우트가 아니다: 뷰티 사이트에 목록 라우트를 강제하는 것은 디자인 자유를 깎으면서 얻는
398
676
  것이 없어서, 예약 유형의 목록 보장은 이 산출로 충족한다(`FAQ_LIST`↔`FAQPage` 와 같은 선례 — 목록의 정본이
399
- 그 섹션의 배열이라 다른 데서 다시 만들면 두 벌이 되고 갈라진다). 본보기:
400
- `src/components/sections/ServiceMenuSection.tsx`.
677
+ 그 섹션의 배열이라 다른 데서 다시 만들면 두 벌이 되고 갈라진다).
401
678
  - ✅ **CMS 고정 페이지(`/[slug]`)를 비워 두지 마라 — `WebPage` + `BreadcrumbList`.** 홈은 `Organization`,
402
679
  상품은 `Product`, 글은 `BlogPosting` 을 내는데 콘솔·시드가 만든 서브페이지(회사소개·이용안내 등)만 그래프가
403
680
  비기 쉽다. 순수 마케팅 사이트는 **그 서브페이지가 콘텐츠의 전부**라, 비어 있으면 답변 엔진이 인용할 노드가
404
681
  홈 하나뿐이 된다. `name` 은 페이지가 그리는 `<h1>` 과 같은 값으로, `description` 은 SEO 오버라이드가
405
682
  **실제로 있을 때만** 붙인다(`datePublished`·`author`·`image` 는 그 페이지에 그런 것이 없으므로 내지 않는다).
406
- 서브페이지의 `BreadcrumbList` 는 홈 아래 1뎁스(`홈 > 회사소개`)다. 본보기: `src/app/[slug]/page.tsx` ·
407
- `webPageJsonLd`.
683
+ 서브페이지의 `BreadcrumbList` 는 홈 아래 1뎁스(`홈 > 회사소개`)다. 그래프는 `webPageJsonLd`(§4.10)로 낸다.
408
684
  - ✅ **`sitemap.ts`·`robots.ts` 필수.** sitemap 엔 **실제로 존재하는 공개 라우트만** — 홈 · **목록 라우트
409
685
  (`/products`·`/blog`)** · 상품 상세 · 글 상세 · 콘텐츠 페이지. 목록 라우트를 빼면 카탈로그의 허브가 크롤러에게
410
686
  안 알려져 상세 N건을 개별 발견에만 맡기게 된다. 반대로 **비어 있는 목록은 싣지 않는다** — 상품 0건에
411
687
  `/products` 를, 글 0건에 `/blog` 를 실으면 크롤러에게 빈 페이지를 색인시키는 것이라 위 `ItemList` 규칙과
412
- 같은 판단이다(둘 다 "없는 것을 있다고 말하지 않는다"). 본보기 `src/app/sitemap.ts` 목록 API 훑어
413
- 조건부 등재를 한다. robots 는 세션·쓰기 경로(`/api/`·`/cart`·`/checkout`·`/mypage`·`/orders`·`/login`·
688
+ 같은 판단이다(둘 다 "없는 것을 있다고 말하지 않는다"). **목록 API 훑어 0건이면 빼는 조건부 등재**를
689
+ `sitemap.ts` 안에서 한다. robots 는 세션·쓰기 경로(`/api/`·`/cart`·`/checkout`·`/mypage`·`/orders`·`/login`·
414
690
  `/auth`)만 막고 공개 카탈로그는 전부 연다. **AI 크롤러(GPTBot·ClaudeBot 등)를 막지 마라** — 발견 경로를
415
691
  우리 손으로 닫는 것이다.
416
692
  - ✅ **절대 URL.** JSON-LD·sitemap·robots 는 상대경로 불가. 단일 사이트는 env(`ZALKERA_SITE_URL`),
@@ -420,7 +696,7 @@ if (booking.orderNo) { // status=PENDING
420
696
  - ❌ **페이지에 없는 것을 JSON-LD 에 쓰지 마라.** 구조화 데이터는 **보이는 내용만** 서술한다. 후기 0건인데
421
697
  `aggregateRating`, 렌더하지도 않는 `image`, 지어낸 브랜드·재고는 전부 구조화 데이터 정책 위반(리치결과 박탈).
422
698
  값이 없으면 **그 필드를 통째로 뺀다**(널·0 을 넣지 않는다).
423
- - ✅ **상품 이미지는 `/media/{id}` 안정 URL 로.** 템플릿의 `src/app/media/[id]/route.ts` `X-Tenant` 를 붙여
699
+ - ✅ **상품 이미지는 `/media/{id}` 안정 URL 로.** §4.9 프록시 라우트가 `X-Tenant` 를 붙여
424
700
  백엔드를 부르고 302 Location 만 넘긴다(바이트는 스토리지→브라우저 직행 — Next 런타임에 태우지 마라).
425
701
  `<img src={`/media/${product.coverAssetId}`} loading="lazy">` + JSON-LD `image` 둘 다 이 URL 을 쓴다.
426
702
  `next/image` 는 최적화 프록시가 바이트를 런타임에 태우므로 금지(쓰려면 `unoptimized`).
@@ -436,9 +712,7 @@ if (booking.orderNo) { // status=PENDING
436
712
  ```bash
437
713
  npx zalkera-aeo-check https://개시된사이트 --category BOOKING # 0=통과 · 1=미충족 · 2=실행 불가
438
714
  npx zalkera-aeo-check https://개시된사이트 --site-wide-only # 보장 주장이 없는 사이트(사이트 축만)
439
- # 템플릿에서 출발한 프로젝트라면 같은 검사기를 이렇게도 부른다:
440
- npm run check:aeo -- https://개시된사이트 --category BOOKING
441
- npm run check:aeo -- --print-guarantees # 잣대 해석만 확인(크롤 없음)
715
+ npx zalkera-aeo-check --print-guarantees # 잣대 해석만 확인(크롤 없음)
442
716
  ```
443
717
 
444
718
  - ❌ 배송 전(PAID)에 후기 작성 시도 → `NOT_DELIVERED_YET`(409). ✅ 주문이 **DELIVERED 이상**일 때만
@@ -493,15 +767,83 @@ catch (e) {
493
767
 
494
768
  ## 8. 스타일 규약 — Tailwind v4 + 테마 토큰 (필수)
495
769
 
496
- 템플릿은 **Tailwind v4** 와 **테마 토큰**으로 스타일한다. 화면을 그릴 때 아래를 지킨다 — 어기면
497
- 생성물이 초라해지거나(생짜 HTML) 테넌트의 "말로 색 바꾸기"가 깨진다. CI validator(S1~S5)가 강제한다.
770
+ 잘커라 사이트는 **Tailwind v4** 와 **테마 토큰**으로 스타일한다. 화면을 그릴 때 아래를 지킨다 — 어기면
771
+ 생성물이 초라해지거나(생짜 HTML) 테넌트의 "말로 색 바꾸기"가 깨진다.
772
+
773
+ > 이 절은 **규약이지 게이트가 아니다.** 어떤 스택으로 짜든 사이트는 개시된다 — 판정 잣대는 §5.1 의
774
+ > 산출물 검사(`zalkera-aeo-check`)뿐이고 소스를 보지 않는다. 다만 테마 토큰을 안 쓰면 테넌트가
775
+ > 콘솔에서 색을 바꿔도 화면이 안 따라온다(그건 검사기가 아니라 **기능이 빠지는** 것이다).
498
776
 
499
777
  **스택 — 이것만 쓴다**
500
778
  - ✅ **Tailwind v4 유틸리티 클래스로만** 스타일한다. CSS 파일은 `src/app/globals.css` **하나뿐**이다 —
501
- 새 `.css` 파일·CSS Modules·CSS-in-JS 를 추가하지 마라(S3·S5).
502
- - ❌ 인라인 `style={{}}`(S2), 웹폰트·외부 스타일 CDN 추가, `tailwind.config.*` 생성(v4 는 config 없이 돈다).
779
+ 새 `.css` 파일·CSS Modules·CSS-in-JS 를 추가하지 마라.
780
+ - ❌ 인라인 `style={{}}`, 웹폰트·외부 스타일 CDN 추가, `tailwind.config.*` 생성(v4 는 config 없이 돈다).
503
781
  인라인 style 은 **CSS 변수 주입**(`style={{"--x":v}}`) 한 용례에만 허용된다(루트 layout 의 테마 주입이 그것).
504
782
 
783
+ **토큰 정의 — `globals.css` 의 `@theme` 블록 (없으면 아래 유틸리티가 존재하지 않는다)**
784
+
785
+ Tailwind v4 는 config 없이 돌고 토큰을 **CSS 에서** 선언한다. 이 블록이 없으면 `bg-primary`·`text-muted` 같은
786
+ 클래스가 **그냥 안 먹는다**(에러도 안 난다). §4.9 의 미디어 라우트와 같은 부류의 필수 부품이라 전문을 싣는다.
787
+
788
+ ```css
789
+ @import "tailwindcss";
790
+
791
+ @theme {
792
+ /* 테넌트 오버라이드 지점 — themeColors 가 <html> inline style 로 이 값들을 덮는다 */
793
+ --color-primary: oklch(20.8% 0.042 265.755); /* slate-900 — 테넌트 색이 얹히기 전 기본 */
794
+ --color-primary-foreground: #ffffff; /* primary 위 글자색(서버가 명도로 산출) */
795
+ --color-secondary: oklch(55.4% 0.046 257.417);
796
+ --color-background: #ffffff;
797
+ --color-foreground: oklch(20.8% 0.042 265.755);
798
+
799
+ /* slate 중립 스케일에서 파생 — 테넌트 오버라이드 대상이 아니다(콘솔 스키마에 키가 없다) */
800
+ --color-muted: oklch(55.4% 0.046 257.417);
801
+ --color-border: oklch(92.9% 0.013 255.508);
802
+ --color-surface: oklch(98.4% 0.003 247.858);
803
+ --color-danger: oklch(57.7% 0.245 27.325);
804
+
805
+ /* 웹폰트 다운로드 없음 — 시스템 스택. font knob 이 --font-sans 를 덮는다 */
806
+ --font-sans: ui-sans-serif, system-ui, -apple-system, "Apple SD Gothic Neo",
807
+ "Malgun Gothic", "Noto Sans KR", sans-serif;
808
+
809
+ /* radius knob — 무단위 배수가 스케일 전체를 곱한다. sharp=0 · soft=1(기본) · round=2.
810
+ var() 폴백 1 이라 knob 미주입 시에도 Tailwind 기본 스케일과 정확히 일치한다. */
811
+ --radius-knob: 1;
812
+ --radius: calc(0.25rem * var(--radius-knob, 1));
813
+ --radius-sm: calc(0.25rem * var(--radius-knob, 1));
814
+ --radius-md: calc(0.375rem * var(--radius-knob, 1));
815
+ --radius-lg: calc(0.5rem * var(--radius-knob, 1));
816
+ --radius-xl: calc(0.75rem * var(--radius-knob, 1));
817
+ --radius-2xl: calc(1rem * var(--radius-knob, 1));
818
+
819
+ /* density knob — 이 베이스가 전 여백을 스케일한다(cozy=0.25rem 기본 · compact=0.22rem) */
820
+ --spacing: 0.25rem;
821
+ }
822
+ ```
823
+
824
+ 루트 `layout` 이 이 값들을 덮어 쓰는 것이 "말로 색 바꾸기"의 전부다:
825
+
826
+ ```tsx
827
+ const {cssVars} = parseThemeColors(config.themeColors); // §3 계약 헬퍼 — 화이트리스트 파서
828
+ return <html lang="ko" style={cssVars}>{/* … */}</html>;
829
+ ```
830
+
831
+ `@theme` 만으로는 부족하다. Tailwind preflight 가 제목·폼 요소를 리셋하므로, **base 레이어가 없으면
832
+ 아래 레시피의 "`<h1>` 그대로"·"폼은 맨 요소 그대로"가 본문 크기 제목과 생짜 input 을 낳는다.**
833
+ 같은 파일에 이어 둔다:
834
+
835
+ ```css
836
+ @layer base {
837
+ h1 { @apply text-2xl font-semibold tracking-tight; }
838
+ h2 { @apply text-lg font-semibold tracking-tight; }
839
+ a { @apply underline-offset-4 hover:underline; } /* 색은 상속 — 액센트 남발 금지 */
840
+ input, select, textarea {
841
+ @apply w-full rounded-lg border border-border bg-background px-3 py-2 text-sm
842
+ placeholder:text-muted focus:outline-2 focus:outline-primary;
843
+ }
844
+ }
845
+ ```
846
+
505
847
  **토큰 — 색은 토큰으로만**
506
848
  | 유틸리티 | 뜻 |
507
849
  |---|---|
@@ -512,7 +854,7 @@ catch (e) {
512
854
  | `border-border` | 경계선 | `bg-surface` | 카드·필드 배경 | `text-danger` | 오류 |
513
855
 
514
856
  - ✅ 테넌트 브랜드색은 **`primary` 토큰으로만** 표현한다. ❌ hex 하드코딩(`bg-[#e91e63]`)은 콘솔의
515
- "말로 색 바꾸기"를 죽인다(S4).
857
+ "말로 색 바꾸기"를 죽인다.
516
858
  - ✅ 중립 명도 단계가 토큰 표에 없으면 **slate 스케일만**(`text-slate-400` 등). ❌ gray·zinc·stone 혼용,
517
859
  유채색 팔레트(rose·emerald 등) 직접 사용 금지 — 새 색이 필요하면 **지어내지 말고 보고**한다.
518
860
  - 간격·타이포·라운드·섀도는 **Tailwind 기본 스케일**(`p-4`·`text-2xl`·`rounded-lg`·`shadow-sm`)을 쓴다.
@@ -520,7 +862,7 @@ catch (e) {
520
862
 
521
863
  **레시피**
522
864
  - 페이지 `<main className="py-8">` · 섹션 `<section className="mb-12">` · 제목은 base 가 크기를 주므로 `<h1>` 그대로.
523
- - 버튼은 `src/components/ui/Button`(`<Link>`·`<a>` 에는 `buttonClasses(variant)` 문자열) · 카드는 `ui/Card`.
865
+ - 버튼·카드는 **프리미티브 벌**을 만들어 재사용한다(화면마다 새로 만들지 마라).
524
866
  - 폼 필드(`<input>`·`<select>`·`<textarea>`)는 base 레이어가 스타일하므로 **맨 요소 그대로** 쓴다.
525
867
 
526
868
  **절제 — 화려하게 만들지 마라**
@@ -528,7 +870,8 @@ catch (e) {
528
870
 
529
871
  **전역 토큰 knob — 말로 폰트·모서리·밀도 바꾸기 (계약버전 2)**
530
872
  색 외에 **폰트·모서리 둥글기·여백 밀도**도 테넌트가 콘솔에서 "말로" 바꾼다(`site.theme.update`). 값은 **enum**
531
- 뿐이며 `src/lib/theme.ts` 매핑 테이블이 CSS 변수로 변환해 색과 같은 경로(`<html>` inline 주입)로 전 화면에 반영된다.
873
+ 뿐이며 **이 패키지의 `parseThemeColors`**(§3 계약 헬퍼)가 CSS 변수로 변환해 색과 같은 경로
874
+ (`<html>` inline 주입)로 전 화면에 반영된다. 매핑 테이블을 직접 만들지 마라 — 그 함수가 정본이다.
532
875
 
533
876
  | knob | enum 값 | 효과 |
534
877
  |---|---|---|
@@ -541,22 +884,22 @@ catch (e) {
541
884
  - 새 knob 값(예: 다른 폰트)이 필요하면 **지어내지 말고 보고**한다 — enum 확장은 계약버전 증가를 동반한다.
542
885
 
543
886
  **테마 동작 — 배선을 건드리지 마라**
544
- - 테넌트 색은 `getSiteConfig().themeColors` → **루트 `layout` 이 `<html>` 의 CSS 변수로 주입**한다. 이 배선
545
- (layout 테마 주입·`src/lib/theme.ts`)을 제거·우회하지 마라. **색 변경에 코드 수정이 필요하면 설계 위반**이다 —
887
+ - 테넌트 색은 `getSiteConfig().themeColors` → `parseThemeColors` → **루트 `layout` 이 `<html>` 의 CSS
888
+ 변수로 주입**한다. 배선을 제거·우회하지 마라. **색 변경에 코드 수정이 필요하면 설계 위반**이다 —
546
889
  콘솔에서 색을 바꾸면 재코딩 없이 반영되는 것이 정상이다.
547
890
 
548
- **UI 프리미티브 관용구 — 있는 것을 쓴다**
891
+ **UI 프리미티브 관용구 — 벌만 만들어 재사용한다**
549
892
  - `cn(...)` = `twMerge(clsx(...))`. 조건부 클래스와 **덮어쓰기**(`cn("px-4", props.className)` 에서 `px-6` 이 이김)를
550
893
  둘 다 처리한다. 문자열 이어붙이기로 대체하지 마라 — 덮어쓰기가 조용히 안 먹는다.
551
- - 변형(variant)은 **cva** 로 선언한다(`ui/Button.tsx` 가 본보기). 조건문으로 클래스 문자열을 조립하지 마라.
552
- - `ui/Button`(버튼) · `buttonClasses(variant)`(`<Link>`·`<a>` 용 문자열) · `ui/Card`(카드) · `ui/Icon`(아이콘).
894
+ - 변형(variant)은 **cva** 로 선언한다. 조건문으로 클래스 문자열을 조립하지 마라.
895
+ - 버튼·카드·아이콘은 **프리미티브 한 벌**로 두고 화면마다 새로 만들지 마라. `<Link>`·`<a>` 에는
896
+ 같은 변형을 **클래스 문자열로 내주는 헬퍼**를 함께 둔다(버튼 컴포넌트를 앵커로 감싸지 않는다).
553
897
  - 새 프리미티브가 정말 필요하면 shadcn/ui 에서 **발췌**해 온다(CLI 상시 설치 아님). 발췌물은 아래 재작성 표를
554
898
  적용하고 `asChild`/`Slot` 을 제거해 **Radix 의존 0** 을 유지한 뒤 `src/components/ui/` 에 커밋한다.
555
899
 
556
900
  **shadcn 발췌 재작성 표 — 남의 토큰 어휘는 반입 금지**
557
901
  shadcn 소스는 자기 변수층(`--card`·`--muted-foreground` …)을 전제한다. 우리는 그 층을 들이지 않는다(토큰 레이어가
558
- 둘이 되면 테넌트 색 주입의 우선순위가 사람 머리에 얹힌다). 발췌 시 **좌변을 우변으로 기계적으로 바꾼다**.
559
- CI validator **S6** 가 좌변 잔존을 error 로 막는다.
902
+ 둘이 되면 테넌트 색 주입의 우선순위가 사람 머리에 얹힌다). 발췌 시 **좌변을 우변으로 기계적으로 바꾼다** — 아래 표가 그 규범이다.
560
903
 
561
904
  | shadcn | 우리 |
562
905
  |---|---|
@@ -573,39 +916,120 @@ CI validator **S6** 가 좌변 잔존을 error 로 막는다.
573
916
 
574
917
  ## 9. 섹션 어휘 — 페이지는 데이터로 그린다
575
918
 
576
- 고정 페이지(`getPage(slug)`)는 본문 HTML 있는 아니라 **섹션 배열**을 갖는다. 각 섹션은 `type`(코드 enum)과
577
- `config`(타입별 JSON)뿐이고, 백엔드는 config 를 **파싱하지 않는다** — 스키마는 프론트 계약이다.
919
+ 고정 페이지는 본문 HTML 덩어리가 아니라 **섹션 배열**이다. 각 섹션은 `type`(코드 enum)과 `config`(타입별
920
+ JSON)뿐이고, 백엔드는 config 를 **파싱하지 않는다** — 스키마는 프론트 계약이다.
921
+
922
+ **왜 이 구조인가**: 콘텐츠가 마크업에 박히지 않고 계약을 타므로, 테넌트가 "말로" 고친 것이 재코딩 없이
923
+ 반영된다("FAQ 에 배송 질문 추가해줘"). **마크업에 문구를 하드코딩하면 이 경로가 죽는다** — 문구 한 줄을
924
+ 고치는 일이 컴포넌트 트리 탐색이 되고, 그 탐색이 곧 토큰이다.
925
+
926
+ **그 배열이 사는 곳은 둘이고, 어느 쪽인지는 레포가 선언한다.**
927
+
928
+ ```json
929
+ // package.json
930
+ "zalkera": { "styling": "tailwind-tokens", "content": "source" }
931
+ ```
932
+
933
+ | 선언 | 의미 |
934
+ |---|---|
935
+ | `"source"` | 사이트의 얼굴(페이지·섹션·문구·섹션 이미지·내비)의 정본이 **레포 파일**. **새로 만드는 사이트는 이 형상을 쓴다**(v1 팩 태생 사이트가 남아 있는 동안 그쪽은 `sections-db` 다) |
936
+ | `"sections-db"` | 전환기 표기 — 섹션이 백엔드에 있고 `getPage(slug)` 가 준다(구 프리셋 태생 사이트) |
937
+ | 미선언 | 안전 기본 — **선언이 없으면 이 계약을 안 쓰는 레포로 본다**(계약 검사도 걸지 않는다) |
938
+
939
+ > ⚠ 이 선언은 **지금은 계약 표기일 뿐**이다. 콘솔이 이 값을 읽어 페이지·섹션 폼을 감추는 배선은
940
+ > **아직 서지 않았다** — 그때까지는 선언과 무관하게 콘솔 폼이 보인다. 레시피가 실물을 앞지르지
941
+ > 않으려고 그대로 적는다.
942
+
943
+ 두 거처는 **같은 어휘·같은 config 키**를 쓴다. 다른 것은 참조를 적는 방언 하나뿐이다(§9.2).
944
+
945
+ ### 9.1 소스 정본 (`"content": "source"`) — 권장 형상
946
+
947
+ ```
948
+ content/
949
+ index.ts # 이 디렉터리의 유일한 코드 — 페이지 json 을 정적 import 해 slug→맵으로 내놓는다
950
+ nav.json # { "header": [{label, href}], "footer": [...] }
951
+ pages/home.json # 페이지당 1파일. 키가 곧 URL 경로(home → /, about → /about)
952
+ ```
953
+
954
+ ```json
955
+ // content/pages/home.json
956
+ {
957
+ "title": "홈",
958
+ "seo": { "title": "…", "description": "…" },
959
+ "sections": [
960
+ { "type": "HERO", "config": { "title": "…", "asset": "/images/hero.png" } },
961
+ { "type": "SERVICE_MENU", "config": { "products": ["care-basic", "gel-onecolor"] } }
962
+ ]
963
+ }
964
+ ```
965
+
966
+ - **배열 순서가 화면 순서다.** `sortOrder` 키는 **없다** — 정렬하지 마라. 순서를 정하는 곳이 둘이면
967
+ "후기를 위로 올려줘"가 매번 어느 쪽을 고치는지 판별 문제가 된다.
968
+ - **`config` 는 객체다**(문자열이 아니다). 두 거처를 하나의 소비자로 읽으려면 `readConfig` 를 쓴다 —
969
+ 문자열이든 객체든 받는다.
970
+ - **정적 import 매니페스트를 우회하지 마라.** 런타임 `fs` 로 읽으면 ⑴ dev 에서 json 을 고쳐도 HMR 이
971
+ 안 돌고(확인이 비싸지면 재시도가 늘고 그게 곧 토큰이다) ⑵ `next build`(standalone) 산출물에 콘텐츠가
972
+ 트레이싱되지 않아 **개시된 사이트에서만** 페이지가 사라진다.
973
+ - **페이지 신설 = json 1개 + 매니페스트 1행.** 라우트를 새로 짜지 마라 — `src/app/[slug]/page.tsx` 하나가
974
+ 전 페이지를 그리고 `src/app/sitemap.ts` 가 매니페스트에서 등재한다. 라우트를 새로 짜면 그 페이지만
975
+ 계약 밖으로 나가 "말로 고치기"가 다시 tsx 탐색이 된다.
976
+ - **로더는 절대 throw 하지 않는다.** json 은 사람과 AI 가 손으로 고치는 파일이라 형상이 틀릴 수 있다 —
977
+ 틀린 곳만 안 그려지고 페이지는 산다. 문구 하나 잘못 고쳤다고 사이트가 500 이면 "말로 고친다"가 성립 안 한다.
978
+ - 이 계약을 **안 지킨 레포도 정상으로 돈다.** 문구를 tsx 에 직접 든 레포도 개시·발행·"말로 고치기"가
979
+ 전부 동작한다 — 강제가 아니라 권장이고, 검사는 위 선언을 **스스로 했을 때만** 격상된다.
980
+
981
+ ### 9.2 참조 방언 — 소스는 숫자 id 를 못 쓴다
982
+
983
+ DB 는 자기가 발급한 숫자 id 를 쓰고, **소스는 그 id 를 알 수 없으므로** 사람이 읽고 쓰는 참조를 쓴다.
984
+ **거처가 방언을 정한다.**
985
+
986
+ | 참조 | DB 방언(`sections-db`) | 소스 방언(`source`) |
987
+ |---|---|---|
988
+ | 에셋 | `assetId`·`*AssetId` : number → `mediaSrc(id)` | `asset`·`*Asset` : `"/images/hero.png"` → `assetPath(v)` |
989
+ | 상품 1개 | `productId` : number | `product`·`*Product` : handle 문자열 |
990
+ | 상품 배열 | `productIds` : number[] | `products`·`*Products` : handle 문자열 배열 |
991
+
992
+ - **handle = `ProductSummary.slug`.** 소스가 DB 카탈로그를 가리키는 **유일한 안정 키**다.
993
+ - ❌ **소스에 숫자 id 를 적지 마라.** 테넌트 스코프 값이라, 고객이 그 소스를 소유하고 다른 테넌트에
994
+ 재업로드하는 순간 의미를 잃는다.
995
+ - `assetPath` 는 **레포 루트 절대 경로만** 통과시킨다(스킴·`//`·`..`·역슬래시 → `undefined`). 이미지 실물은
996
+ 레포 `public/` 에 둔다. 원격 이미지는 계약 영역이 아니라 자유 영역이다.
997
+ - `asHandleArray` 는 **배열 순서를 보존한다** — 그 순서가 노출 순서의 원장이다.
998
+ - 상품 상세의 커버(`coverAssetId`)는 소스 방언에서도 여전히 숫자 id 다. 그건 카탈로그가 발급한 값이지
999
+ 소스가 적는 값이 아니다 — 섹션 이미지와 상품 이미지는 다른 축이다.
1000
+ - **아래 §9.4 어휘 표의 `config` 선언은 DB 방언 표기다.** 소스에 적을 때 이 표로 옮겨 적는다.
1001
+ 키의 **의미**는 두 방언이 같다.
1002
+
1003
+ ### 9.3 DB 정본 (`"content": "sections-db"`) — 전환기
578
1004
 
579
1005
  ```ts
580
1006
  const page = await zalkera.getPage("home");
581
1007
  page.sections // [{ type: "HERO", sortOrder: 0, config: "{\"title\":\"…\"}" }, …]
582
1008
  ```
583
1009
 
584
- **왜 구조인가**: 콘텐츠가 마크업에 박히지 않고 계약을 타므로, 테넌트가 콘솔에서·"말로" 고친 것이 재코딩 없이
585
- 반영된다("FAQ 배송 질문 추가해줘"). **마크업에 문구를 하드코딩하면 경로가 죽는다.**
1010
+ - `config` **문자열**이다(컬럼 저장 형상). `parseConfig` 또는 `readConfig` 읽는다.
1011
+ - **`sortOrder` 정렬한다.** 서버가 정렬해 주지만 순서가 틀리면 화면에서 바로 티가 난다.
1012
+ - **key 는 `type`+`sortOrder`+index 로 짠다.** `PageSection` 계약에 id 가 없다(실측).
1013
+
1014
+ ### 9.4 어휘 (12종) — 두 거처 공통
586
1015
 
587
1016
  **규약**
588
- - `config` **문자열**이다. 파싱은 `parseConfig` + `asString`/`asId`/`asIdArray`/`asObjectArray` 로 한다
589
- (이 패키지가 export 절대 throw 하지 않는다). 필수 필드가 없으면 **그 섹션만 안 그린다**.
590
- 페이지 전체가 죽으면 안 된다.
591
- - **미지 타입은 조용히 건너뛴다.** 어휘는 append-only 라 백엔드가 타입을 늘려도 옛 사이트가 깨지지 않아야 한다.
1017
+ - 파싱 헬퍼는 패키지가 준다: `readConfig`/`parseConfig` + `asString`/`asId`/`asIdArray`/`asObjectArray`
1018
+ + 방언 헬퍼 `asHandle`/`asHandleArray`/`assetPath`. **절대 throw 하지 않는다** 필수 필드가 없으면
1019
+ **그 섹션만 안 그린다**. 페이지 전체가 죽으면 안 된다.
1020
+ - **미지 타입은 조용히 건너뛴다.** 어휘는 append-only 라 타입이 늘어도 옛 사이트가 깨지지 않아야 한다.
592
1021
  `default:` 에서 에러·경고를 내지 마라 — 그게 계약이다.
593
- - 이미지는 `assetId`(숫자)다. `mediaSrc(id)`(이 패키지 export)로 프록시 경유해 그린다.
594
- - **모든 href 는 `safeLinkUrl()` 을 태운다.** 콘솔 입력이라 `javascript:` 가 들어올 수 있다(저장형 XSS).
1022
+ - **모든 href `safeLinkUrl()` 태운다.** 콘솔 입력·손으로 고친 json 이라 `javascript:` 가 들어올 수 있다(저장형 XSS).
595
1023
  - 자유 HTML 은 없다 — plain text + 줄바꿈뿐(`whitespace-pre-wrap`).
1024
+ - **디스패치는 직접 짠다** — `type` 으로 컴포넌트를 고르는 `switch` 하나면 된다.
596
1025
  - 타입·키를 **지어내지 마라.** 계약 정본은 백엔드 레포의 `doc/contracts/section-vocabulary.json` 이고,
597
- 이 패키지가 export 하는 `SECTION_CONTRACT` 는 그것을 npm 으로 실어 나르는 **운반체**다(`src/sections.ts`
1026
+ 이 패키지가 export 하는 `SECTION_CONTRACT` 는 그것을 npm 으로 실어 나르는 **운반체**다(`sections.ts`
598
1027
  KDoc 이 같은 말을 한다). 코드에서 읽을 것은 `SECTION_CONTRACT` 가 맞지만, 어휘를 **늘리는** 결정은
599
1028
  정본 쪽에서 난다 — 필요한 어휘가 없으면 만들지 말고 **보고**한다.
600
1029
 
601
- **디스패치는 직접 짠다** — `type` 으로 컴포넌트를 고르는 `switch` 하나면 된다. 규약에 더해 둘을 지켜라:
602
-
603
- - **`sortOrder` 로 한 번 더 정렬한다.** 서버가 정렬해 주지만 순서가 틀리면 화면에서 바로 티가 나는 종류다.
604
- - **key 는 `type`+`sortOrder`+index 로 짠다.** `PageSection` 계약에 id 가 없어(실측) 순수 인덱스보다 낫다.
605
-
606
- **어휘 (12종)** — `vertical` 은 콘솔 픽커 그룹핑용이지 사용 제한이 아니다(GENERAL 은 뷰티 테넌트도 쓴다).
1030
+ `vertical` 콘솔 픽커 그룹핑용이지 사용 제한이 아니다(GENERAL 뷰티 테넌트도 쓴다).
607
1031
 
608
- | type | vertical | config (필수는 굵게) | JSON-LD |
1032
+ | type | vertical | config — **DB 방언 표기**(필수는 굵게 · 소스 방언 대응은 §9.2) | JSON-LD |
609
1033
  |---|---|---|---|
610
1034
  | `HERO` | GENERAL | eyebrow?, **title**, subtitle?, ctaLabel?, ctaHref?, assetId? | — |
611
1035
  | `FEATURE_GRID` | GENERAL | title?, **items**[{icon?, **title**, body?}] | — |
@@ -615,7 +1039,7 @@ page.sections // [{ type: "HERO", sortOrder: 0, config: "{\"title\":\"…\"}" },
615
1039
  | `TESTIMONIALS` | GENERAL | title?, **items**[{**quote**, author?, role?, assetId?}] | **없음(의도)** |
616
1040
  | `FAQ_LIST` | GENERAL | title?, **items**[{**question**, **answer**}] | `FAQPage` |
617
1041
  | `LEAD_CTA` | GENERAL | title?, body?, interest?, quick? | — (`submitLead` 계약) |
618
- | `SERVICE_MENU` | BEAUTY | productIds?, categorySlug? | |
1042
+ | `SERVICE_MENU` | BEAUTY | **productIds**(rev 3 부터 필수), categorySlug? | `ItemList`(rev 2 부터) |
619
1043
  | `BEFORE_AFTER_GALLERY` | BEAUTY | **items**[{**beforeAssetId**, **afterAssetId**, caption?}] | — |
620
1044
  | `BOOKING_CTA` | BEAUTY | **productId**, label? | — |
621
1045
  | `DOCTOR_INTRO` | BEAUTY | **name**, title?, photoAssetId?, bio? | — |
@@ -625,14 +1049,28 @@ page.sections // [{ type: "HERO", sortOrder: 0, config: "{\"title\":\"…\"}" },
625
1049
  SSR 마크업에 실린다**(AI 답변엔진이 읽는다). 아코디언 라이브러리를 쓰지 마라. `FAQPage` JSON-LD 를 함께 낸다.
626
1050
  - `TESTIMONIALS` 에 **`Review`·`AggregateRating` 을 내지 마라.** 자사 사이트의 자사 후기에 별점을 붙이는 것은
627
1051
  self-serving reviews 정책 위반이라 제재 대상이다. 이건 빠뜨린 게 아니라 결정이다.
628
- - `LEAD_CTA` 템플릿의 `components/LeadForm` 그대로 쓴다(UTM·클릭ID 추적·레이트리밋 안내 동봉).
629
- 전환 부품을 두 벌 만들지 마라. 이 섹션은 **`id="lead"` 앵커**를 갖는다 원페이지 랜딩에서 히어로 CTA 를
1052
+ - `LEAD_CTA` 리드 폼은 **클라이언트 아일랜드**(`"use client"`)로 만들고 `/api/lead` BFF POST 한다
1053
+ (`clientIp` 는 서버가 XFF 에서 붙인다). 전환 부품을 두 벌 만들지 마라하나로 재사용한다. 요건 넷:
1054
+ · **연락처 필수 · 이메일 선택**(문의 폼과 반대다). 본문은 `{name, phone, email?, message?, interest,
1055
+ isQuick, consentMarketing, tracking}`.
1056
+ · **UTM·클릭ID 8키**를 `tracking` 으로 동봉한다 — `utm_source`·`utm_medium`·`utm_campaign`·
1057
+ `utm_adgroup`·`utm_content`·`fbclid`·`gclid`·`nclid`. 하나라도 있으면 채운다.
1058
+ · **캡처는 mount 후 `window.location.search`** 로 한다. 랜딩이 force-static ISR 일 수 있어 RSC 에서
1059
+ `searchParams` 를 읽으면 정적 셸이 깨진다 — `useSearchParams()` 도 같은 이유로 금지.
1060
+ · **`TOO_MANY_REQUESTS` 안내를 반드시 넣는다**(공개 폼이라 레이트리밋이 흔하다). 400 은 `errors[]` 를
1061
+ 필드별로 표시하고, 성공 시 입력만 비우고 **`tracking` 은 유지**한다(같은 방문의 재제출도 같은 유입 귀속).
1062
+ 이 섹션은 **`id="lead"` 앵커**를 갖는다 — 원페이지 랜딩에서 히어로 CTA 를
630
1063
  `"ctaHref": "#lead"` 로 여기에 걸 수 있다. 앵커를 지우면 그 버튼이 조용히 아무 데도 안 간다.
1064
+ - `SERVICE_MENU` 는 상품 참조가 **필수**다(rev 3). 참조가 0 이면 렌더러가 그 섹션을 안 그려서 **개시 직후
1065
+ 조용히 사라진다** — 시술 목록의 AEO 보장(`ItemList`·§5.1)이 그 섹션에 걸려 있으므로 보장까지 함께 죽는다.
1066
+ 소스 방언에서는 `config.products` 의 **handle 배열 순서를 그대로** 그린다(정렬하지 마라 — 그 배열이 노출
1067
+ 순서의 원장이고, `ItemList` 도 같은 순서로 나가야 한다).
631
1068
  - `LOGO_WALL` 에 실존 기업 로고를 넣지 마라 — 사용 허락이 있는 것만.
632
1069
 
633
1070
  **아이콘 — 큐레이션 맵의 키 문자열만**
634
- `FEATURE_GRID` 의 `icon` 은 아래 32개 중 하나다. 렌더는 `ui/Icon` 맵 lookup 이고 **미지 이름은 아이콘 영역을
635
- 생략**한다(죽지 않는다·기본 글리프도 쓴다 — 틀린 아이콘보다 없는 아이콘이 낫다).
1071
+ `FEATURE_GRID` 의 `icon` 은 아래 32개 중 하나다. 렌더러는 **이 → 글리프 맵 lookup** 으로 짜고,
1072
+ **미지 이름은 아이콘 영역을 생략**한다(죽지 않는다·기본 글리프로 때우지도 않는다 — 틀린 아이콘보다
1073
+ 없는 아이콘이 낫다).
636
1074
 
637
1075
  ```
638
1076
  shield-check rocket line-chart trending-up users user-check clock calendar-check
package/package.json CHANGED
@@ -1,60 +1,60 @@
1
1
  {
2
- "name": "@zalkera/client",
3
- "version": "0.13.0",
4
- "description": "zalkera 헤드리스 CMS 공개 API 클라이언트 (테넌트 사이트용)",
5
- "license": "MIT",
6
- "author": "Credium Co., Ltd.",
7
- "type": "module",
8
- "publishConfig": {
9
- "access": "public"
10
- },
11
- "keywords": [
12
- "zalkera",
13
- "headless",
14
- "commerce",
15
- "cms",
16
- "api-client",
17
- "sdk"
18
- ],
19
- "homepage": "https://zalkera.com",
20
- "files": [
21
- "dist",
22
- "llms.txt",
23
- "contracts",
24
- "bin",
25
- "lib"
26
- ],
27
- "main": "./dist/index.cjs",
28
- "module": "./dist/index.js",
29
- "types": "./dist/index.d.ts",
30
- "bin": {
31
- "zalkera-aeo-check": "./bin/check-aeo-surfaces.mjs"
32
- },
33
- "exports": {
34
- ".": {
35
- "types": "./dist/index.d.ts",
36
- "import": "./dist/index.js",
37
- "require": "./dist/index.cjs"
2
+ "name": "@zalkera/client",
3
+ "version": "0.14.0",
4
+ "description": "zalkera 헤드리스 CMS 공개 API 클라이언트 (테넌트 사이트용)",
5
+ "license": "MIT",
6
+ "author": "Credium Co., Ltd.",
7
+ "type": "module",
8
+ "publishConfig": {
9
+ "access": "public"
38
10
  },
39
- "./contracts/aeo-surface-guarantees.json": "./contracts/aeo-surface-guarantees.json",
40
- "./bin/check-aeo-surfaces.mjs": "./bin/check-aeo-surfaces.mjs",
41
- "./lib/site-crawl.mjs": "./lib/site-crawl.mjs"
42
- },
43
- "scripts": {
44
- "build": "tsup",
45
- "dev": "tsup --watch",
46
- "typecheck": "tsc --noEmit",
47
- "test": "vitest run",
48
- "test:watch": "vitest",
49
- "prepublishOnly": "npm run typecheck && npm test && npm run build"
50
- },
51
- "engines": {
52
- "node": ">=18"
53
- },
54
- "devDependencies": {
55
- "@types/node": "^22.10.2",
56
- "tsup": "^8.3.5",
57
- "typescript": "^5.7.2",
58
- "vitest": "^2.1.8"
59
- }
11
+ "keywords": [
12
+ "zalkera",
13
+ "headless",
14
+ "commerce",
15
+ "cms",
16
+ "api-client",
17
+ "sdk"
18
+ ],
19
+ "homepage": "https://zalkera.com",
20
+ "files": [
21
+ "dist",
22
+ "llms.txt",
23
+ "contracts",
24
+ "bin",
25
+ "lib"
26
+ ],
27
+ "main": "./dist/index.cjs",
28
+ "module": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "bin": {
31
+ "zalkera-aeo-check": "./bin/check-aeo-surfaces.mjs"
32
+ },
33
+ "exports": {
34
+ ".": {
35
+ "types": "./dist/index.d.ts",
36
+ "import": "./dist/index.js",
37
+ "require": "./dist/index.cjs"
38
+ },
39
+ "./contracts/aeo-surface-guarantees.json": "./contracts/aeo-surface-guarantees.json",
40
+ "./bin/check-aeo-surfaces.mjs": "./bin/check-aeo-surfaces.mjs",
41
+ "./lib/site-crawl.mjs": "./lib/site-crawl.mjs"
42
+ },
43
+ "scripts": {
44
+ "build": "tsup",
45
+ "dev": "tsup --watch",
46
+ "typecheck": "tsc --noEmit",
47
+ "test": "vitest run",
48
+ "test:watch": "vitest",
49
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
50
+ },
51
+ "engines": {
52
+ "node": ">=18"
53
+ },
54
+ "devDependencies": {
55
+ "@types/node": "^22.10.2",
56
+ "tsup": "^8.3.5",
57
+ "typescript": "^5.7.2",
58
+ "vitest": "^2.1.8"
59
+ }
60
60
  }