@zalkera/client 0.13.1 → 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 +316 -39
  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
@@ -147,7 +147,7 @@ await zalkera.submitInquiry(input, { clientIp: ip });
147
147
  빈 배열은 truthy 라 `if (items)` 로 결측을 판별하면 안 된다 — `items.length` 을 봐라.
148
148
  - 위 가드로 **필수 값이 없으면 그 섹션만 안 그린다**. 페이지 전체가 죽으면 안 된다.
149
149
  - `mediaSrc(assetId)` → `/media/{id}` **경로 문자열**. 이미지는 전부 이걸 통한다.
150
- 전제: 프로젝트에 `app/media/[id]/route.ts` 프록시 라우트가 있어야 한다(템플릿에 있다).
150
+ 전제: 프로젝트에 `app/media/[id]/route.ts` 프록시 라우트가 있어야 한다(**§4.9 에 전문**).
151
151
  이 헬퍼는 경로만 만든다 — 라우트를 안 만들면 이미지가 404 다.
152
152
  - `asHandle(v)` / `asHandleArray(v)` / `assetPath(v)` — **참조 방언**(계약 `contractRev` 4 의 `dialects`)을 읽는다.
153
153
  숫자 id 를 못 쓰는 자리에서 상품은 `handle`(= `ProductSummary.slug`), 에셋은 레포 `public/` **루트 절대 경로**로
@@ -239,7 +239,7 @@ export async function POST(req: Request) {
239
239
  if (session.widget) {
240
240
  // 위젯형(토스) — 내 사이트에서 결제창을 띄우고, 성공 콜백 파라미터를 confirm 으로 넘긴다
241
241
  // widget = { vendor:"TOSS_PAYMENTS", clientKey, orderId, amount, orderName }
242
- // → 템플릿의 checkout/page.tsx(openWidget)·payment/complete/page.tsx 그대로 쓴다
242
+ // → 결제창 페이지에서 openWidget, 성공 리다이렉트 페이지에서 confirm (이 절의 흐름 그대로)
243
243
  await zalkera.confirmPayment(orderNo, { paymentKey }, access); // BFF 경유
244
244
  } else {
245
245
  redirect(session.paymentUrl); // 리다이렉트형(PG 결제창으로 이동)
@@ -263,7 +263,8 @@ export async function POST(req: Request) {
263
263
  >
264
264
  > 관용구는 소비자에 따라 둘이다:
265
265
  > - **브라우저(쿠키 있음)** = `` `co-${session.cartSessionKey}` `` **+ 체크아웃 성공 응답에서 카트 쿠키 회전**
266
- > (템플릿 `rotateCartSessionKey`). **회전 없이 카트키를 멱등키로 쓰지 마라** — 카트 쿠키는 30일이라
266
+ > (체크아웃 BFF 응답에 `response.cookies.set(CART_COOKIE, randomUUID(), {httpOnly:true, sameSite:"lax",
267
+ > secure, path:"/", maxAge: 30일})`). **회전 없이 카트키를 멱등키로 쓰지 마라** — 카트 쿠키는 30일이라
267
268
  > 회전이 없으면 위의 "영구 재사용"이 그대로 실현된다(실측 사고). 회전을 응답에 실으면 Set-Cookie 가
268
269
  > 성공 도달과 원자적으로 묶여서, 응답 유실 시엔 옛 키가 살아남아 재시도가 원주문을 재생한다 —
269
270
  > **인메모리 "시도당 키"보다 이 쪽이 안전하다**(새로고침에 안 죽는다).
@@ -298,8 +299,8 @@ const shipment = await zalkera.getShipment(orderNo, { phone }); // 배송 상태
298
299
  // 1) 슬롯 보여주기 — 공개(비로그인 가능).
299
300
  // ⚠️ 이 RSC 직독 샘플은 **요청마다 렌더되는 동적 라우트에서만** 신선하다. 상품 상세가 ISR
300
301
  // (force-static + revalidate=N)이면 슬롯이 프리렌더에 구워져 N초 낡은 시간표를 보여주고
301
- // SLOT_FULL 을 만든다 — 그땐 **클라이언트 아일랜드 + BFF 프록시**로 내려라(템플릿
302
- // BookingPanel + /api/booking/availability 가 선례다. 볼라틸한 데이터는 클라이언트 아일랜드로).
302
+ // SLOT_FULL 을 만든다 — 그땐 **클라이언트 아일랜드 + BFF 프록시**로 내려라(예약 패널을
303
+ // "use client" 로 두고 /api/booking/availability 가 대신 조회. 볼라틸한 데이터는 아일랜드로).
303
304
  const slots = await zalkera.availability({
304
305
  productId: product.id, // slug 아님 — getProduct(slug).id
305
306
  from: new Date().toISOString(),
@@ -346,7 +347,6 @@ if (booking.orderNo) { // status=PENDING
346
347
  깨므로 금지(하나라도 있으면 `tracking` 을 채우고, 전무면 undefined).
347
348
  - **BFF 필수**: 브라우저에서 `submitLead` 직호출 금지(§2). route handler 에서 `x-forwarded-for` 첫
348
349
  홉을 `{ clientIp }` 로 넘긴다(안 넘기면 방문자 전원 429 — §4.6·문의와 동일 관용구).
349
- - 선례: `components/LeadForm.tsx`(아일랜드·UTM 캡처) · `app/api/lead/route.ts`(BFF·201 관통).
350
350
 
351
351
  ### 4.8 콘텐츠 파일로 그리는 고정 페이지 (`"content": "source"` · 권장)
352
352
 
@@ -412,6 +412,208 @@ export default async function StaticPage({ params }: { params: Promise<{ slug: s
412
412
  - 홈(`app/page.tsx`)은 `loadPageContent("home")` 의 섹션이 있으면 그것을 그리고, 없으면 커머스 골격으로
413
413
  강하한다. 콘텐츠 없는 것은 **정상**이다(커머스 테넌트).
414
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
+ > 정책은 부가 정보이므로 파싱 실패가 페이지를 죽이면 안 된다.
616
+
415
617
  ## 5. 흔한 실수(하지 말 것)
416
618
 
417
619
  - ❌ 클라이언트 컴포넌트에서 `@zalkera/client` import → baseUrl 노출. ✅ 서버에서만.
@@ -444,7 +646,7 @@ export default async function StaticPage({ params }: { params: Promise<{ slug: s
444
646
  실시간·개인화 데이터(라이브 재고·개인화)는 **클라이언트 컴포넌트(아일랜드)**로 가져오고, 상태 변경
445
647
  (장바구니·주문)은 **BFF route handler** 로 한다. 신선도는 **온디맨드 revalidate**(백엔드 데이터 변경 시
446
648
  `POST /api/revalidate`)로 지킨다. 동적 SSR 이 꼭 필요하면(예: 검색) **정당화 주석**(`// zalkera-allow-dynamic:
447
- <이유>`)이 필요하다 — 서버 동적 렌더는 상시 런타임 원가라 CI validator(§5)가 SEO 라우트에서 강제 차단한다.
649
+ <이유>`)이 필요하다 — 서버 동적 렌더는 **상시 런타임 원가**라 SEO 라우트에서는 기본이 아니다.
448
650
 
449
651
  ## 5.1 산출물 규범 — 발견되는 사이트 (필수)
450
652
 
@@ -454,8 +656,8 @@ export default async function StaticPage({ params }: { params: Promise<{ slug: s
454
656
  - ✅ **SEO 페이지에 JSON-LD(schema.org) 필수.** 상품 상세 = `Product` + variant 마다 `Offer`(price·
455
657
  priceCurrency·availability) + 후기 있으면 `AggregateRating`. 홈 = `Organization`(오프라인 점포면
456
658
  `LocalBusiness`, 뷰티샵이면 `BeautySalon` 으로 좁힌다). 목록·상세엔 `BreadcrumbList`.
457
- 템플릿의 `src/components/JsonLd.tsx`(안전 직렬화 + `productJsonLd`/`organizationJsonLd`/
458
- `breadcrumbJsonLd` 헬퍼)를 **그대로 쓴다 — 재발명 금지**.
659
+ **§4.10 의 부품**(안전 직렬화 + `productJsonLd`/`organizationJsonLd`/`breadcrumbJsonLd`)을
660
+ **그대로 복사해 쓴다 — 재발명 금지**.
459
661
  - ✅ **목록 라우트도 그래프를 낸다 — `ItemList`.** 상품 목록(`/products`)·글 목록(`/blog`)은 **화면에 그리는
460
662
  바로 그 순서·그 항목**으로 `ItemList` 를 낸다(`itemListJsonLd`). 왜 목록에도 그래프가 필요한가: 상세 N건만
461
663
  있으면 "이 가게가 무엇을 파는가"를 기계가 한 번에 못 받고 상세를 하나씩 발견해야 한다 — 목록은 그 N건을
@@ -465,28 +667,26 @@ export default async function StaticPage({ params }: { params: Promise<{ slug: s
465
667
  내지 않는다**(빈 목록을 그래프로 주장하지 않는다 — "페이지에 없는 것을 쓰지 마라"의 목록판). 목록에도
466
668
  `BreadcrumbList` 를 함께 낸다. 그리고 목록은 **ISR 로 유지한다** — 정렬·필터를 `searchParams` 로 받는 순간
467
669
  라우트가 동적 렌더로 강등돼 크롤러 방문마다 서버 렌더가 돈다. 더 넓히려면 `/products/page/[n]` 같은
468
- **정적 세그먼트**로 늘려라. 본보기: `src/app/products/page.tsx` · `src/app/blog/page.tsx`.
670
+ **정적 세그먼트**로 늘려라.
469
671
  - ✅ **예약(시술) 사이트의 목록 보장은 `SERVICE_MENU` 섹션이 낸다.** 섹션 어휘 `SERVICE_MENU` 는 계약상
470
672
  `jsonLd: "ItemList"` 이고(`SECTION_CONTRACT` · `contractRev` 2 이상), config 의 상품 참조 배열
471
673
  (DB 방언 `productIds` · 소스 방언 `products` — §9.2) **순서 그대로**
472
674
  목록 그래프를 낸다 — 원장이 정한 노출 순서를 기계에도 같은 순서로 준다. 시술 목록의 정위치는 **홈의 이
473
675
  섹션**이지 별도 라우트가 아니다: 뷰티 사이트에 목록 라우트를 강제하는 것은 디자인 자유를 깎으면서 얻는
474
676
  것이 없어서, 예약 유형의 목록 보장은 이 산출로 충족한다(`FAQ_LIST`↔`FAQPage` 와 같은 선례 — 목록의 정본이
475
- 그 섹션의 배열이라 다른 데서 다시 만들면 두 벌이 되고 갈라진다). 본보기:
476
- `src/components/sections/ServiceMenuSection.tsx`.
677
+ 그 섹션의 배열이라 다른 데서 다시 만들면 두 벌이 되고 갈라진다).
477
678
  - ✅ **CMS 고정 페이지(`/[slug]`)를 비워 두지 마라 — `WebPage` + `BreadcrumbList`.** 홈은 `Organization`,
478
679
  상품은 `Product`, 글은 `BlogPosting` 을 내는데 콘솔·시드가 만든 서브페이지(회사소개·이용안내 등)만 그래프가
479
680
  비기 쉽다. 순수 마케팅 사이트는 **그 서브페이지가 콘텐츠의 전부**라, 비어 있으면 답변 엔진이 인용할 노드가
480
681
  홈 하나뿐이 된다. `name` 은 페이지가 그리는 `<h1>` 과 같은 값으로, `description` 은 SEO 오버라이드가
481
682
  **실제로 있을 때만** 붙인다(`datePublished`·`author`·`image` 는 그 페이지에 그런 것이 없으므로 내지 않는다).
482
- 서브페이지의 `BreadcrumbList` 는 홈 아래 1뎁스(`홈 > 회사소개`)다. 본보기: `src/app/[slug]/page.tsx` ·
483
- `webPageJsonLd`.
683
+ 서브페이지의 `BreadcrumbList` 는 홈 아래 1뎁스(`홈 > 회사소개`)다. 그래프는 `webPageJsonLd`(§4.10)로 낸다.
484
684
  - ✅ **`sitemap.ts`·`robots.ts` 필수.** sitemap 엔 **실제로 존재하는 공개 라우트만** — 홈 · **목록 라우트
485
685
  (`/products`·`/blog`)** · 상품 상세 · 글 상세 · 콘텐츠 페이지. 목록 라우트를 빼면 카탈로그의 허브가 크롤러에게
486
686
  안 알려져 상세 N건을 개별 발견에만 맡기게 된다. 반대로 **비어 있는 목록은 싣지 않는다** — 상품 0건에
487
687
  `/products` 를, 글 0건에 `/blog` 를 실으면 크롤러에게 빈 페이지를 색인시키는 것이라 위 `ItemList` 규칙과
488
- 같은 판단이다(둘 다 "없는 것을 있다고 말하지 않는다"). 본보기 `src/app/sitemap.ts` 목록 API 훑어
489
- 조건부 등재를 한다. robots 는 세션·쓰기 경로(`/api/`·`/cart`·`/checkout`·`/mypage`·`/orders`·`/login`·
688
+ 같은 판단이다(둘 다 "없는 것을 있다고 말하지 않는다"). **목록 API 훑어 0건이면 빼는 조건부 등재**를
689
+ `sitemap.ts` 안에서 한다. robots 는 세션·쓰기 경로(`/api/`·`/cart`·`/checkout`·`/mypage`·`/orders`·`/login`·
490
690
  `/auth`)만 막고 공개 카탈로그는 전부 연다. **AI 크롤러(GPTBot·ClaudeBot 등)를 막지 마라** — 발견 경로를
491
691
  우리 손으로 닫는 것이다.
492
692
  - ✅ **절대 URL.** JSON-LD·sitemap·robots 는 상대경로 불가. 단일 사이트는 env(`ZALKERA_SITE_URL`),
@@ -496,7 +696,7 @@ export default async function StaticPage({ params }: { params: Promise<{ slug: s
496
696
  - ❌ **페이지에 없는 것을 JSON-LD 에 쓰지 마라.** 구조화 데이터는 **보이는 내용만** 서술한다. 후기 0건인데
497
697
  `aggregateRating`, 렌더하지도 않는 `image`, 지어낸 브랜드·재고는 전부 구조화 데이터 정책 위반(리치결과 박탈).
498
698
  값이 없으면 **그 필드를 통째로 뺀다**(널·0 을 넣지 않는다).
499
- - ✅ **상품 이미지는 `/media/{id}` 안정 URL 로.** 템플릿의 `src/app/media/[id]/route.ts` `X-Tenant` 를 붙여
699
+ - ✅ **상품 이미지는 `/media/{id}` 안정 URL 로.** §4.9 프록시 라우트가 `X-Tenant` 를 붙여
500
700
  백엔드를 부르고 302 Location 만 넘긴다(바이트는 스토리지→브라우저 직행 — Next 런타임에 태우지 마라).
501
701
  `<img src={`/media/${product.coverAssetId}`} loading="lazy">` + JSON-LD `image` 둘 다 이 URL 을 쓴다.
502
702
  `next/image` 는 최적화 프록시가 바이트를 런타임에 태우므로 금지(쓰려면 `unoptimized`).
@@ -512,9 +712,7 @@ export default async function StaticPage({ params }: { params: Promise<{ slug: s
512
712
  ```bash
513
713
  npx zalkera-aeo-check https://개시된사이트 --category BOOKING # 0=통과 · 1=미충족 · 2=실행 불가
514
714
  npx zalkera-aeo-check https://개시된사이트 --site-wide-only # 보장 주장이 없는 사이트(사이트 축만)
515
- # 템플릿에서 출발한 프로젝트라면 같은 검사기를 이렇게도 부른다:
516
- npm run check:aeo -- https://개시된사이트 --category BOOKING
517
- npm run check:aeo -- --print-guarantees # 잣대 해석만 확인(크롤 없음)
715
+ npx zalkera-aeo-check --print-guarantees # 잣대 해석만 확인(크롤 없음)
518
716
  ```
519
717
 
520
718
  - ❌ 배송 전(PAID)에 후기 작성 시도 → `NOT_DELIVERED_YET`(409). ✅ 주문이 **DELIVERED 이상**일 때만
@@ -569,15 +767,83 @@ catch (e) {
569
767
 
570
768
  ## 8. 스타일 규약 — Tailwind v4 + 테마 토큰 (필수)
571
769
 
572
- 템플릿은 **Tailwind v4** 와 **테마 토큰**으로 스타일한다. 화면을 그릴 때 아래를 지킨다 — 어기면
573
- 생성물이 초라해지거나(생짜 HTML) 테넌트의 "말로 색 바꾸기"가 깨진다. CI validator(S1~S5)가 강제한다.
770
+ 잘커라 사이트는 **Tailwind v4** 와 **테마 토큰**으로 스타일한다. 화면을 그릴 때 아래를 지킨다 — 어기면
771
+ 생성물이 초라해지거나(생짜 HTML) 테넌트의 "말로 색 바꾸기"가 깨진다.
772
+
773
+ > 이 절은 **규약이지 게이트가 아니다.** 어떤 스택으로 짜든 사이트는 개시된다 — 판정 잣대는 §5.1 의
774
+ > 산출물 검사(`zalkera-aeo-check`)뿐이고 소스를 보지 않는다. 다만 테마 토큰을 안 쓰면 테넌트가
775
+ > 콘솔에서 색을 바꿔도 화면이 안 따라온다(그건 검사기가 아니라 **기능이 빠지는** 것이다).
574
776
 
575
777
  **스택 — 이것만 쓴다**
576
778
  - ✅ **Tailwind v4 유틸리티 클래스로만** 스타일한다. CSS 파일은 `src/app/globals.css` **하나뿐**이다 —
577
- 새 `.css` 파일·CSS Modules·CSS-in-JS 를 추가하지 마라(S3·S5).
578
- - ❌ 인라인 `style={{}}`(S2), 웹폰트·외부 스타일 CDN 추가, `tailwind.config.*` 생성(v4 는 config 없이 돈다).
779
+ 새 `.css` 파일·CSS Modules·CSS-in-JS 를 추가하지 마라.
780
+ - ❌ 인라인 `style={{}}`, 웹폰트·외부 스타일 CDN 추가, `tailwind.config.*` 생성(v4 는 config 없이 돈다).
579
781
  인라인 style 은 **CSS 변수 주입**(`style={{"--x":v}}`) 한 용례에만 허용된다(루트 layout 의 테마 주입이 그것).
580
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
+
581
847
  **토큰 — 색은 토큰으로만**
582
848
  | 유틸리티 | 뜻 |
583
849
  |---|---|
@@ -588,7 +854,7 @@ catch (e) {
588
854
  | `border-border` | 경계선 | `bg-surface` | 카드·필드 배경 | `text-danger` | 오류 |
589
855
 
590
856
  - ✅ 테넌트 브랜드색은 **`primary` 토큰으로만** 표현한다. ❌ hex 하드코딩(`bg-[#e91e63]`)은 콘솔의
591
- "말로 색 바꾸기"를 죽인다(S4).
857
+ "말로 색 바꾸기"를 죽인다.
592
858
  - ✅ 중립 명도 단계가 토큰 표에 없으면 **slate 스케일만**(`text-slate-400` 등). ❌ gray·zinc·stone 혼용,
593
859
  유채색 팔레트(rose·emerald 등) 직접 사용 금지 — 새 색이 필요하면 **지어내지 말고 보고**한다.
594
860
  - 간격·타이포·라운드·섀도는 **Tailwind 기본 스케일**(`p-4`·`text-2xl`·`rounded-lg`·`shadow-sm`)을 쓴다.
@@ -596,7 +862,7 @@ catch (e) {
596
862
 
597
863
  **레시피**
598
864
  - 페이지 `<main className="py-8">` · 섹션 `<section className="mb-12">` · 제목은 base 가 크기를 주므로 `<h1>` 그대로.
599
- - 버튼은 `src/components/ui/Button`(`<Link>`·`<a>` 에는 `buttonClasses(variant)` 문자열) · 카드는 `ui/Card`.
865
+ - 버튼·카드는 **프리미티브 벌**을 만들어 재사용한다(화면마다 새로 만들지 마라).
600
866
  - 폼 필드(`<input>`·`<select>`·`<textarea>`)는 base 레이어가 스타일하므로 **맨 요소 그대로** 쓴다.
601
867
 
602
868
  **절제 — 화려하게 만들지 마라**
@@ -604,7 +870,8 @@ catch (e) {
604
870
 
605
871
  **전역 토큰 knob — 말로 폰트·모서리·밀도 바꾸기 (계약버전 2)**
606
872
  색 외에 **폰트·모서리 둥글기·여백 밀도**도 테넌트가 콘솔에서 "말로" 바꾼다(`site.theme.update`). 값은 **enum**
607
- 뿐이며 `src/lib/theme.ts` 매핑 테이블이 CSS 변수로 변환해 색과 같은 경로(`<html>` inline 주입)로 전 화면에 반영된다.
873
+ 뿐이며 **이 패키지의 `parseThemeColors`**(§3 계약 헬퍼)가 CSS 변수로 변환해 색과 같은 경로
874
+ (`<html>` inline 주입)로 전 화면에 반영된다. 매핑 테이블을 직접 만들지 마라 — 그 함수가 정본이다.
608
875
 
609
876
  | knob | enum 값 | 효과 |
610
877
  |---|---|---|
@@ -617,22 +884,22 @@ catch (e) {
617
884
  - 새 knob 값(예: 다른 폰트)이 필요하면 **지어내지 말고 보고**한다 — enum 확장은 계약버전 증가를 동반한다.
618
885
 
619
886
  **테마 동작 — 배선을 건드리지 마라**
620
- - 테넌트 색은 `getSiteConfig().themeColors` → **루트 `layout` 이 `<html>` 의 CSS 변수로 주입**한다. 이 배선
621
- (layout 테마 주입·`src/lib/theme.ts`)을 제거·우회하지 마라. **색 변경에 코드 수정이 필요하면 설계 위반**이다 —
887
+ - 테넌트 색은 `getSiteConfig().themeColors` → `parseThemeColors` → **루트 `layout` 이 `<html>` 의 CSS
888
+ 변수로 주입**한다. 배선을 제거·우회하지 마라. **색 변경에 코드 수정이 필요하면 설계 위반**이다 —
622
889
  콘솔에서 색을 바꾸면 재코딩 없이 반영되는 것이 정상이다.
623
890
 
624
- **UI 프리미티브 관용구 — 있는 것을 쓴다**
891
+ **UI 프리미티브 관용구 — 벌만 만들어 재사용한다**
625
892
  - `cn(...)` = `twMerge(clsx(...))`. 조건부 클래스와 **덮어쓰기**(`cn("px-4", props.className)` 에서 `px-6` 이 이김)를
626
893
  둘 다 처리한다. 문자열 이어붙이기로 대체하지 마라 — 덮어쓰기가 조용히 안 먹는다.
627
- - 변형(variant)은 **cva** 로 선언한다(`ui/Button.tsx` 가 본보기). 조건문으로 클래스 문자열을 조립하지 마라.
628
- - `ui/Button`(버튼) · `buttonClasses(variant)`(`<Link>`·`<a>` 용 문자열) · `ui/Card`(카드) · `ui/Icon`(아이콘).
894
+ - 변형(variant)은 **cva** 로 선언한다. 조건문으로 클래스 문자열을 조립하지 마라.
895
+ - 버튼·카드·아이콘은 **프리미티브 한 벌**로 두고 화면마다 새로 만들지 마라. `<Link>`·`<a>` 에는
896
+ 같은 변형을 **클래스 문자열로 내주는 헬퍼**를 함께 둔다(버튼 컴포넌트를 앵커로 감싸지 않는다).
629
897
  - 새 프리미티브가 정말 필요하면 shadcn/ui 에서 **발췌**해 온다(CLI 상시 설치 아님). 발췌물은 아래 재작성 표를
630
898
  적용하고 `asChild`/`Slot` 을 제거해 **Radix 의존 0** 을 유지한 뒤 `src/components/ui/` 에 커밋한다.
631
899
 
632
900
  **shadcn 발췌 재작성 표 — 남의 토큰 어휘는 반입 금지**
633
901
  shadcn 소스는 자기 변수층(`--card`·`--muted-foreground` …)을 전제한다. 우리는 그 층을 들이지 않는다(토큰 레이어가
634
- 둘이 되면 테넌트 색 주입의 우선순위가 사람 머리에 얹힌다). 발췌 시 **좌변을 우변으로 기계적으로 바꾼다**.
635
- CI validator **S6** 가 좌변 잔존을 error 로 막는다.
902
+ 둘이 되면 테넌트 색 주입의 우선순위가 사람 머리에 얹힌다). 발췌 시 **좌변을 우변으로 기계적으로 바꾼다** — 아래 표가 그 규범이다.
636
903
 
637
904
  | shadcn | 우리 |
638
905
  |---|---|
@@ -665,7 +932,7 @@ JSON)뿐이고, 백엔드는 config 를 **파싱하지 않는다** — 스키마
665
932
 
666
933
  | 선언 | 의미 |
667
934
  |---|---|
668
- | `"source"` | 사이트의 얼굴(페이지·섹션·문구·섹션 이미지·내비)의 정본이 **레포 파일**. **템플릿과 v2 태생이 형상**이다(카탈로그에 v1 팩이 남아 있는 동안 그쪽 태생은 `sections-db` 다) |
935
+ | `"source"` | 사이트의 얼굴(페이지·섹션·문구·섹션 이미지·내비)의 정본이 **레포 파일**. **새로 만드는 사이트는형상을 쓴다**(v1 태생 사이트가 남아 있는 동안 그쪽은 `sections-db` 다) |
669
936
  | `"sections-db"` | 전환기 표기 — 섹션이 백엔드에 있고 `getPage(slug)` 가 준다(구 프리셋 태생 사이트) |
670
937
  | 미선언 | 안전 기본 — **선언이 없으면 이 계약을 안 쓰는 레포로 본다**(계약 검사도 걸지 않는다) |
671
938
 
@@ -782,8 +1049,17 @@ page.sections // [{ type: "HERO", sortOrder: 0, config: "{\"title\":\"…\"}" },
782
1049
  SSR 마크업에 실린다**(AI 답변엔진이 읽는다). 아코디언 라이브러리를 쓰지 마라. `FAQPage` JSON-LD 를 함께 낸다.
783
1050
  - `TESTIMONIALS` 에 **`Review`·`AggregateRating` 을 내지 마라.** 자사 사이트의 자사 후기에 별점을 붙이는 것은
784
1051
  self-serving reviews 정책 위반이라 제재 대상이다. 이건 빠뜨린 게 아니라 결정이다.
785
- - `LEAD_CTA` 템플릿의 `components/LeadForm` 그대로 쓴다(UTM·클릭ID 추적·레이트리밋 안내 동봉).
786
- 전환 부품을 두 벌 만들지 마라. 이 섹션은 **`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 를
787
1063
  `"ctaHref": "#lead"` 로 여기에 걸 수 있다. 앵커를 지우면 그 버튼이 조용히 아무 데도 안 간다.
788
1064
  - `SERVICE_MENU` 는 상품 참조가 **필수**다(rev 3). 참조가 0 이면 렌더러가 그 섹션을 안 그려서 **개시 직후
789
1065
  조용히 사라진다** — 시술 목록의 AEO 보장(`ItemList`·§5.1)이 그 섹션에 걸려 있으므로 보장까지 함께 죽는다.
@@ -792,8 +1068,9 @@ page.sections // [{ type: "HERO", sortOrder: 0, config: "{\"title\":\"…\"}" },
792
1068
  - `LOGO_WALL` 에 실존 기업 로고를 넣지 마라 — 사용 허락이 있는 것만.
793
1069
 
794
1070
  **아이콘 — 큐레이션 맵의 키 문자열만**
795
- `FEATURE_GRID` 의 `icon` 은 아래 32개 중 하나다. 렌더는 `ui/Icon` 맵 lookup 이고 **미지 이름은 아이콘 영역을
796
- 생략**한다(죽지 않는다·기본 글리프도 쓴다 — 틀린 아이콘보다 없는 아이콘이 낫다).
1071
+ `FEATURE_GRID` 의 `icon` 은 아래 32개 중 하나다. 렌더러는 **이 → 글리프 맵 lookup** 으로 짜고,
1072
+ **미지 이름은 아이콘 영역을 생략**한다(죽지 않는다·기본 글리프로 때우지도 않는다 — 틀린 아이콘보다
1073
+ 없는 아이콘이 낫다).
797
1074
 
798
1075
  ```
799
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.1",
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
  }