@roottale/cms-renderer-next 0.53.0 → 0.55.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/CHANGELOG.md CHANGED
@@ -1,5 +1,57 @@
1
1
  # @roottale/cms-renderer-next
2
2
 
3
+ ## 0.55.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [6cb9dd5]
8
+ - @roottale/cms-client@0.55.0
9
+ - @roottale/cms-core@0.55.0
10
+
11
+ ## 0.54.0
12
+
13
+ ### Minor Changes
14
+
15
+ - ad805bd: 글 미리보기를 고객 사이트가 자기 글 템플릿으로 그릴 수 있게 합니다 — 관리자
16
+ 미리보기와 발행 결과가 다르던 문제의 해법.
17
+ - 공개 API `GET /v1/cms/public/posts/preview?token=`: 관리자 편집기가 발급한
18
+ 1시간짜리 토큰으로 초안(미발행 포함)을 발행 글과 같은 형식으로 돌려줍니다.
19
+ 없는 토큰 404, 만료 410 `preview_expired`, 응답 `cache-control: no-store`.
20
+ - `@roottale/cms-client/server`: `fetchPostPreview({ apiKey, token })`,
21
+ `isPreviewExpiredError`, `CmsPostPreviewContent`.
22
+ - `@roottale/cms-renderer-next`: `RootTaleBlogPost`에 `previewToken`·
23
+ `previewExpiredElement`(같은 템플릿으로 미리보기), `RootTalePreviewNotice`,
24
+ `buildPreviewMetadata`(항상 noindex/nofollow).
25
+ - 문서 `blog.md` "미리보기 페이지"·`api-reference.md`, 예시
26
+ `examples/nextjs/app/preview/post/[id]/page.tsx`.
27
+
28
+ ### Patch Changes
29
+
30
+ - 2a7c695: 카테고리 대표 이미지와 검색 문구를 공개 API에서 읽어 카테고리 모음 페이지의
31
+ 검색·공유 메타데이터에 사용할 수 있게 합니다.
32
+ - Updated dependencies [befa5bb]
33
+ - Updated dependencies [ad805bd]
34
+ - Updated dependencies [2a7c695]
35
+ - @roottale/cms-core@0.54.0
36
+ - @roottale/cms-client@0.54.0
37
+
38
+ ## 0.53.1
39
+
40
+ ### Patch Changes
41
+
42
+ - 1efecbb: 페이지·글·정보의 콘텐츠 모델 공개 계약과 `modelKey` 조회를 추가합니다. 개발자가 선언한 슬롯에서만 배너·팝업을 조회하고 접근성 있게 렌더하는 API, client, renderer, 예제 문서를 함께 제공합니다. 기존 collections와 collectionKey 계약은 유지합니다.
43
+ - 800a797: CMS 글 목록 API가 일시적으로 실패해도 `RootTaleBlogList`와 `RootTaleBlogCategories`가 빈 상태를 렌더하도록 바꿉니다. 신규 사이트의 최초 빌드와 공개 목록 페이지가 CMS 장애 때문에 전체 500으로 중단되지 않습니다.
44
+ - 5cd0e3b: 주소 이동 middleware가 API 장애 시 stale 규칙을 계속 사용하고, 순환 규칙은 브라우저를 왕복시키지 않도록 통과처리합니다. Site Materializer 납품물에 middleware와 회귀 검증 파일을 필수로 포함합니다.
45
+ - cf84636: ChatGPT 등에서 만든 완전한 HTML 문서의 태그 구조, class·id·`data-*`, inline style·`<style>` CSS와 반응형 레이아웃을 `importedHtml` 블록에 보존합니다. 본문의 `h1`은 글 제목과 충돌하지 않도록 `h2`로 바꾸되 원래 `h1` CSS 선택자는 변환된 제목에 이어서 적용합니다. 안전한 `!important`도 유지합니다. CSS는 블록별 고유 범위로 격리하며 스크립트·이벤트 속성·외부 CSS 로드·화면을 덮는 CSS만 제거합니다.
46
+
47
+ `html`·`body` 속성, SVG, `picture`·`srcset`, keyframes와 미러링한 CSS 이미지·woff/woff2 폰트도 보존합니다. 가져온 블록별로 문서 루트, animation과 font 이름을 격리해 다른 본문이나 사이트 UI와 충돌하지 않게 합니다.
48
+
49
+ - Updated dependencies [6df15b6]
50
+ - Updated dependencies [1efecbb]
51
+ - Updated dependencies [cf84636]
52
+ - @roottale/cms-client@0.53.1
53
+ - @roottale/cms-core@0.53.1
54
+
3
55
  ## 0.53.0
4
56
 
5
57
  ### Minor Changes
package/README.md CHANGED
@@ -227,7 +227,7 @@ The form submits via HTML POST (no custom JS). In a Next.js site, point `action`
227
227
 
228
228
  | Export | Use |
229
229
  |---|---|
230
- | `RootTaleBlogList` | RSC — fetches `/v1/cms/public/posts?type=post` and renders an `<ul>`. |
230
+ | `RootTaleBlogList` | RSC — fetches `/v1/cms/public/posts?type=post`; renders an `<ul>` or a fail-soft empty state. |
231
231
  | `RootTaleBlogPost` | RSC — fetches one post, renders Tiptap doc + optional TOC. |
232
232
  | `RootTaleLeadForm` | RSC — HTML form for `/v1/public/inquiries` forward routes. |
233
233
  | `RootTalePostCard` | Single card primitive (used internally by `RootTaleBlogList`). |
@@ -239,7 +239,15 @@ The form submits via HTML POST (no custom JS). In a Next.js site, point `action`
239
239
  ## Security model
240
240
 
241
241
  - `@roottale/cms-client` throws if you import it into a browser bundle (`assertServer()`). Never expose `rtlk_cust_*` to the client.
242
- - Block JSON is server-rendered with hardcoded mark/node mappings — no `dangerouslySetInnerHTML` from authored content. Links pass an `isSafeHref()` allowlist (http/https/mailto/tel/root-relative/fragment/query).
242
+ - Block JSON is server-rendered with hardcoded mark/node mappings. The `importedHtml`
243
+ node is the only authored raw-HTML boundary: the renderer sanitizes its semantic
244
+ markup and imported CSS again before `dangerouslySetInnerHTML`. Imported selectors
245
+ are isolated to a per-block scope. Body `h1` elements become `h2` so the page title
246
+ remains the only `h1`; original `h1` selectors are mapped to the converted heading.
247
+ `data-*` selectors and safe `!important` declarations are preserved. Scripts, event
248
+ handlers, unsafe URL schemes, external CSS loads, and layout-overlay CSS are removed.
249
+ Links in ordinary Tiptap nodes pass an `isSafeHref()` allowlist
250
+ (http/https/mailto/tel/root-relative/fragment/query).
243
251
  - The default `target="_blank"` on link marks gets `rel="noopener noreferrer"`.
244
252
 
245
253
  ## Compatibility
@@ -14,6 +14,11 @@ interface BlogCategory {
14
14
  }
15
15
  interface BlogCategoryWithCount extends BlogCategory {
16
16
  count: number;
17
+ description?: string;
18
+ seoTitle?: string;
19
+ seoDescription?: string;
20
+ imageUrl?: string;
21
+ imageAlt?: string;
17
22
  }
18
23
  /**
19
24
  * 글 목록에 등장하는 카테고리를 slug 기준 unique 로 수집. 서버가 보내는 정렬
@@ -101,4 +101,4 @@ export {
101
101
  resolvePostCollection2 as resolvePostCollection,
102
102
  resolvePostUrl2 as resolvePostUrl
103
103
  };
104
- //# sourceMappingURL=chunk-HM5VP7SR.js.map
104
+ //# sourceMappingURL=chunk-FJANIAZN.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/blog-filter.ts","../src/routes-shared.ts"],"sourcesContent":["/**\n * `RootTaleBlogList` 카테고리 필터 — 순수 헬퍼(서버 필터, client JS 불요).\n *\n * SSR-only 정책 유지: 필터는 `?category=<slug>` URL 파라미터로 동작하고(칩 =\n * 서버 렌더 `<a>` 링크), consumer 가 searchParams 의 category 를 `activeCategory`\n * 로 주입한다. hydration 없이 SEO 친화 + 공유 가능한 URL.\n */\n\nimport type { CmsPostContent, CmsTermRef } from \"@roottale/cms-client/server\";\n\nexport interface BlogCategory {\n name: string;\n slug: string;\n}\n\nexport interface BlogCategoryWithCount extends BlogCategory {\n count: number;\n description?: string;\n seoTitle?: string;\n seoDescription?: string;\n imageUrl?: string;\n imageAlt?: string;\n}\n\nfunction categoryTerms(post: CmsPostContent): CmsTermRef[] {\n return (post.terms ?? []).filter((t) => t.taxonomy === \"category\");\n}\n\n/**\n * 글 목록에 등장하는 카테고리를 slug 기준 unique 로 수집. 서버가 보내는 정렬\n * (category → name asc)을 보존하기 위해 처음 등장 순서를 유지한다.\n */\nexport function collectCategories(posts: CmsPostContent[]): BlogCategory[] {\n return collectCategoriesWithCounts(posts).map(({ name, slug }) => ({\n name,\n slug,\n }));\n}\n\nexport function collectCategoriesWithCounts(\n posts: CmsPostContent[],\n): BlogCategoryWithCount[] {\n const bySlug = new Map<string, BlogCategoryWithCount>();\n const out: BlogCategoryWithCount[] = [];\n for (const post of posts) {\n for (const t of categoryTerms(post)) {\n const current = bySlug.get(t.slug);\n if (current) {\n current.count += 1;\n } else {\n const category = { name: t.name, slug: t.slug, count: 1 };\n bySlug.set(t.slug, category);\n out.push(category);\n }\n }\n }\n return out;\n}\n\n/** activeSlug 가 비면 전체 반환. 아니면 그 카테고리를 가진 글만. */\nexport function filterPostsByCategory(\n posts: CmsPostContent[],\n activeSlug: string | null | undefined,\n): CmsPostContent[] {\n if (!activeSlug) return posts;\n return posts.filter((p) => categoryTerms(p).some((t) => t.slug === activeSlug));\n}\n\n/** 기본 카테고리 링크: 전체 → `/blog`, 특정 → `/blog/categories/<slug>`. */\nexport function defaultCategoryHref(slug: string | null): string {\n return slug ? `/blog/categories/${encodeURIComponent(slug)}` : \"/blog\";\n}\n\n// ── Collections (ADR-0060) — cms-core 라우팅 프리미티브 재export + 렌더 헬퍼 ──────\n//\n// 라우팅 코어(RouteCollection/resolvePostCollection/resolvePostUrl/postsForCollection/\n// validateCollections)는 `@roottale/cms-core` 에 있어 next/astro 가 공유한다\n// (ADR-0029 parity — codex consult `019ef577` 반영). 여기서는 next 소비자 편의를\n// 위해 재export + CmsPostContent 기반 스트림 스코프 카테고리 집계만 추가한다.\nimport {\n type RouteCollection,\n postsForCollection,\n} from \"@roottale/cms-core\";\n\nexport {\n type RouteCollection,\n type RoutablePost,\n isInCollectionMode,\n resolvePostCollection,\n resolvePostUrl,\n postsForCollection,\n postsInAnyCollection,\n validateCollections,\n} from \"@roottale/cms-core\";\n\n/**\n * 특정 collection 안에서 등장하는 카테고리 집계 — 그 스트림의 카테고리 칩/아카이브용.\n * **전체 정렬 리스트로 first-wins 해석**해 그 스트림에 최종 귀속된 글만 대상(한 글이\n * 여러 스트림 리스트에 중복되는 것을 방지 — codex 지적).\n */\nexport function collectCollectionCategories(\n posts: CmsPostContent[],\n collections: readonly RouteCollection[],\n key: string,\n): BlogCategoryWithCount[] {\n const owned = postsForCollection(posts, collections, key);\n const collection = collections.find((c) => c.key === key);\n const allowed = new Set(collection?.categories ?? []);\n return collectCategoriesWithCounts(owned).filter((c) => allowed.has(c.slug));\n}\n","/**\n * `routes` 공통 — config 타입, collections resolve, fetch 헬퍼. feed/sitemap/llms/\n * revalidate/og 모듈이 공유한다(순환 import 회피). 공개 표면은 `routes.ts` 배럴.\n */\n\nimport { fetchPosts, type CmsPostContent } from \"@roottale/cms-client/server\";\nimport type { RouteCollection } from \"@roottale/cms-core\";\n\nexport type { RouteCollection };\n// 소비자 가드용 재export (`/blog/[slug]` 에서 스트림 누출 차단 등) — ADR-0060.\nexport { resolvePostCollection, resolvePostUrl } from \"@roottale/cms-core\";\n\nexport interface RootTaleSiteConfig {\n /** `rtlk_cust_*` — 블로그 조회와 같은 키. 서버에서만(절대 브라우저 금지). */\n apiKey: string;\n /** API base 오버라이드. 기본 `https://api.roottale.com`. */\n apiBase?: string;\n /** 사이트 절대 URL (뒤 슬래시 없이). 예: `https://kjmtax.roottale.app`. */\n siteUrl: string;\n /** 피드/사이트 제목. */\n title: string;\n description?: string;\n /** 기본 \"ko-KR\". */\n language?: string;\n /** RSS의 WebSub discovery link. 미지정이면 기존 RSS 출력 유지. */\n webSubHubUrl?: string;\n /** 블로그 base path. 기본 \"/blog\". 레거시 단일 스트림 — `collections` 미사용 시. */\n blogBasePath?: string;\n /**\n * 다중 스트림 라우팅 (ADR-0060). **길이 > 0 이면 collection 모드** — feed/sitemap/\n * llms.txt 가 글을 소속 스트림 basePath 로 라우팅하고, 어느 스트림에도 안 속한 글은\n * 제외한다. 비우거나 미전달 = 레거시 단일 `blogBasePath`(/blog) 동작(하위호환).\n * 선언 순서 = 우선순위(first-wins).\n *\n * 배열 또는 **async resolver**(`() => Promise<RouteCollection[]>`)를 받는다 — 후자는\n * 사이트가 `@roottale/cms-client` `fetchCollections()` 로 admin 설정을 가져올 때 쓴다.\n * resolver 가 throw 하면 fail-soft(레거시 단일 /blog).\n */\n collections?: CollectionsInput;\n}\n\n/** collections 입력 — 정적 배열 또는 async resolver(admin 설정 fetch 용). */\nexport type CollectionsInput =\n | RouteCollection[]\n | (() => RouteCollection[] | Promise<RouteCollection[]>);\n\n/** resolve 결과 — `failed` 로 \"resolver throw\" 를 \"collections 미설정\" 과 구분한다. */\nexport async function resolveCollectionsResult(\n input: CollectionsInput | undefined,\n): Promise<{ collections: RouteCollection[] | undefined; failed: boolean }> {\n if (typeof input === \"function\") {\n try {\n return { collections: await input(), failed: false };\n } catch (err) {\n console.error(\n \"[@roottale/cms-renderer-next/routes] collections resolver 실패:\",\n err,\n );\n return { collections: undefined, failed: true };\n }\n }\n return { collections: input, failed: false };\n}\n\n/**\n * resolver 면 호출(실패 시 undefined → 레거시), 배열이면 그대로. GET 팩토리(feed/\n * sitemap/llms)는 always-200 철학상 실패를 레거시 /blog 로 흡수 — revalidate 만\n * `resolveCollectionsResult` 로 실패를 구분해 under-invalidation 을 막는다.\n */\nexport async function resolveCollections(\n input: CollectionsInput | undefined,\n): Promise<RouteCollection[] | undefined> {\n return (await resolveCollectionsResult(input)).collections;\n}\n\nexport function trimSlash(u: string): string {\n return u.replace(/\\/+$/, \"\");\n}\n\nexport async function safeFetchPosts(\n config: RootTaleSiteConfig,\n limit: number,\n): Promise<CmsPostContent[]> {\n try {\n const page = await fetchPosts({\n apiKey: config.apiKey,\n baseUrl: config.apiBase,\n type: \"post\",\n limit,\n });\n return page.items;\n } catch (err) {\n // 빌드/런타임 fetch 실패 시 빈 목록 — 라우트는 항상 정상 응답(404 방지).\n console.error(\"[@roottale/cms-renderer-next/routes] fetchPosts 실패:\", err);\n return [];\n }\n}\n"],"mappings":";AA+EA;AAAA,EAEE;AAAA,OACK;AAEP;AAAA,EAGE;AAAA,EACA;AAAA,EACA;AAAA,EACA,sBAAAA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AArEP,SAAS,cAAc,MAAoC;AACzD,UAAQ,KAAK,SAAS,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,aAAa,UAAU;AACnE;AAMO,SAAS,kBAAkB,OAAyC;AACzE,SAAO,4BAA4B,KAAK,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,OAAO;AAAA,IACjE;AAAA,IACA;AAAA,EACF,EAAE;AACJ;AAEO,SAAS,4BACd,OACyB;AACzB,QAAM,SAAS,oBAAI,IAAmC;AACtD,QAAM,MAA+B,CAAC;AACtC,aAAW,QAAQ,OAAO;AACxB,eAAW,KAAK,cAAc,IAAI,GAAG;AACnC,YAAM,UAAU,OAAO,IAAI,EAAE,IAAI;AACjC,UAAI,SAAS;AACX,gBAAQ,SAAS;AAAA,MACnB,OAAO;AACL,cAAM,WAAW,EAAE,MAAM,EAAE,MAAM,MAAM,EAAE,MAAM,OAAO,EAAE;AACxD,eAAO,IAAI,EAAE,MAAM,QAAQ;AAC3B,YAAI,KAAK,QAAQ;AAAA,MACnB;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AAGO,SAAS,sBACd,OACA,YACkB;AAClB,MAAI,CAAC,WAAY,QAAO;AACxB,SAAO,MAAM,OAAO,CAAC,MAAM,cAAc,CAAC,EAAE,KAAK,CAAC,MAAM,EAAE,SAAS,UAAU,CAAC;AAChF;AAGO,SAAS,oBAAoB,MAA6B;AAC/D,SAAO,OAAO,oBAAoB,mBAAmB,IAAI,CAAC,KAAK;AACjE;AA6BO,SAAS,4BACd,OACA,aACA,KACyB;AACzB,QAAM,QAAQ,mBAAmB,OAAO,aAAa,GAAG;AACxD,QAAM,aAAa,YAAY,KAAK,CAAC,MAAM,EAAE,QAAQ,GAAG;AACxD,QAAM,UAAU,IAAI,IAAI,YAAY,cAAc,CAAC,CAAC;AACpD,SAAO,4BAA4B,KAAK,EAAE,OAAO,CAAC,MAAM,QAAQ,IAAI,EAAE,IAAI,CAAC;AAC7E;;;ACxGA,SAAS,kBAAuC;AAKhD,SAAS,yBAAAC,wBAAuB,kBAAAC,uBAAsB;AAqCtD,eAAsB,yBACpB,OAC0E;AAC1E,MAAI,OAAO,UAAU,YAAY;AAC/B,QAAI;AACF,aAAO,EAAE,aAAa,MAAM,MAAM,GAAG,QAAQ,MAAM;AAAA,IACrD,SAAS,KAAK;AACZ,cAAQ;AAAA,QACN;AAAA,QACA;AAAA,MACF;AACA,aAAO,EAAE,aAAa,QAAW,QAAQ,KAAK;AAAA,IAChD;AAAA,EACF;AACA,SAAO,EAAE,aAAa,OAAO,QAAQ,MAAM;AAC7C;AAOA,eAAsB,mBACpB,OACwC;AACxC,UAAQ,MAAM,yBAAyB,KAAK,GAAG;AACjD;AAEO,SAAS,UAAU,GAAmB;AAC3C,SAAO,EAAE,QAAQ,QAAQ,EAAE;AAC7B;AAEA,eAAsB,eACpB,QACA,OAC2B;AAC3B,MAAI;AACF,UAAM,OAAO,MAAM,WAAW;AAAA,MAC5B,QAAQ,OAAO;AAAA,MACf,SAAS,OAAO;AAAA,MAChB,MAAM;AAAA,MACN;AAAA,IACF,CAAC;AACD,WAAO,KAAK;AAAA,EACd,SAAS,KAAK;AAEZ,YAAQ,MAAM,iEAAuD,GAAG;AACxE,WAAO,CAAC;AAAA,EACV;AACF;","names":["postsForCollection","resolvePostCollection","resolvePostUrl"]}
@@ -0,0 +1,75 @@
1
+ "use client";
2
+
3
+ // src/exposure-client.tsx
4
+ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ function storageKey(campaign) {
7
+ return `rt-exposure-dismissed:${campaign.id}`;
8
+ }
9
+ function wasDismissed(campaign) {
10
+ if (campaign.repeat === "always") return false;
11
+ try {
12
+ const storage = campaign.repeat === "session" ? sessionStorage : localStorage;
13
+ const value = storage.getItem(storageKey(campaign));
14
+ if (!value) return false;
15
+ if (campaign.repeat === "day") return Date.now() - Number(value) < 864e5;
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+ function rememberDismissal(campaign) {
22
+ if (campaign.repeat === "always") return;
23
+ try {
24
+ const storage = campaign.repeat === "session" ? sessionStorage : localStorage;
25
+ storage.setItem(storageKey(campaign), String(Date.now()));
26
+ } catch {
27
+ }
28
+ }
29
+ function RootTaleExposure({
30
+ campaign,
31
+ allowedVariants
32
+ }) {
33
+ const [dismissed, setDismissed] = useState(false);
34
+ const closeRef = useRef(null);
35
+ const knownVariant = allowedVariants.includes(campaign.content.variant);
36
+ const eligible = useSyncExternalStore(
37
+ () => () => void 0,
38
+ () => !wasDismissed(campaign),
39
+ () => false
40
+ );
41
+ const visible = knownVariant && eligible && !dismissed;
42
+ useEffect(() => {
43
+ if (!visible || campaign.kind !== "popup") return;
44
+ closeRef.current?.focus();
45
+ const onKeyDown = (event) => {
46
+ if (event.key === "Escape") dismiss();
47
+ };
48
+ window.addEventListener("keydown", onKeyDown);
49
+ return () => window.removeEventListener("keydown", onKeyDown);
50
+ });
51
+ function dismiss() {
52
+ rememberDismissal(campaign);
53
+ setDismissed(true);
54
+ }
55
+ if (!knownVariant || !visible) return null;
56
+ const body = /* @__PURE__ */ jsxs("div", { className: "rt-exposure__body", children: [
57
+ campaign.content.assetUrl ? /* @__PURE__ */ jsx("img", { className: "rt-exposure__asset", src: campaign.content.assetUrl, alt: campaign.content.assetAlt ?? "" }) : null,
58
+ /* @__PURE__ */ jsxs("div", { className: "rt-exposure__copy", children: [
59
+ campaign.content.eyebrow ? /* @__PURE__ */ jsx("p", { className: "rt-exposure__eyebrow", children: campaign.content.eyebrow }) : null,
60
+ /* @__PURE__ */ jsx("h2", { className: "rt-exposure__title", children: campaign.content.title }),
61
+ campaign.content.description ? /* @__PURE__ */ jsx("p", { className: "rt-exposure__description", children: campaign.content.description }) : null,
62
+ campaign.content.linkUrl && campaign.content.linkLabel ? /* @__PURE__ */ jsx("a", { className: "rt-exposure__link", href: campaign.content.linkUrl, children: campaign.content.linkLabel }) : null
63
+ ] }),
64
+ /* @__PURE__ */ jsx("button", { ref: closeRef, type: "button", className: "rt-exposure__close", "aria-label": "\uB2EB\uAE30", onClick: dismiss, children: "\xD7" })
65
+ ] });
66
+ if (campaign.kind === "banner") {
67
+ return /* @__PURE__ */ jsx("aside", { className: `rt-exposure rt-exposure--banner rt-exposure--${campaign.content.variant}`, "aria-label": "\uC548\uB0B4", children: body });
68
+ }
69
+ return /* @__PURE__ */ jsx("div", { className: "rt-exposure-backdrop", role: "presentation", children: /* @__PURE__ */ jsx("section", { className: `rt-exposure rt-exposure--popup rt-exposure--${campaign.content.variant}`, role: "dialog", "aria-modal": "true", "aria-label": campaign.content.title, children: body }) });
70
+ }
71
+
72
+ export {
73
+ RootTaleExposure
74
+ };
75
+ //# sourceMappingURL=chunk-GVKENK6H.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/exposure-client.tsx"],"sourcesContent":["\"use client\";\n\nimport { useEffect, useRef, useState, useSyncExternalStore } from \"react\";\n\nimport type { CmsExposureCampaign } from \"@roottale/cms-client/server\";\n\nfunction storageKey(campaign: CmsExposureCampaign): string {\n return `rt-exposure-dismissed:${campaign.id}`;\n}\n\nfunction wasDismissed(campaign: CmsExposureCampaign): boolean {\n if (campaign.repeat === \"always\") return false;\n try {\n const storage = campaign.repeat === \"session\" ? sessionStorage : localStorage;\n const value = storage.getItem(storageKey(campaign));\n if (!value) return false;\n if (campaign.repeat === \"day\") return Date.now() - Number(value) < 86_400_000;\n return true;\n } catch {\n return false;\n }\n}\n\nfunction rememberDismissal(campaign: CmsExposureCampaign): void {\n if (campaign.repeat === \"always\") return;\n try {\n const storage = campaign.repeat === \"session\" ? sessionStorage : localStorage;\n storage.setItem(storageKey(campaign), String(Date.now()));\n } catch {\n // 저장소 차단 환경에서는 현재 화면에서만 닫는다.\n }\n}\n\nexport function RootTaleExposure({\n campaign,\n allowedVariants,\n}: {\n readonly campaign: CmsExposureCampaign;\n readonly allowedVariants: readonly string[];\n}) {\n const [dismissed, setDismissed] = useState(false);\n const closeRef = useRef<HTMLButtonElement>(null);\n const knownVariant = allowedVariants.includes(campaign.content.variant);\n const eligible = useSyncExternalStore(\n () => () => undefined,\n () => !wasDismissed(campaign),\n () => false,\n );\n const visible = knownVariant && eligible && !dismissed;\n\n useEffect(() => {\n if (!visible || campaign.kind !== \"popup\") return;\n closeRef.current?.focus();\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key === \"Escape\") dismiss();\n };\n window.addEventListener(\"keydown\", onKeyDown);\n return () => window.removeEventListener(\"keydown\", onKeyDown);\n });\n\n function dismiss() {\n rememberDismissal(campaign);\n setDismissed(true);\n }\n\n if (!knownVariant || !visible) return null;\n const body = <div className=\"rt-exposure__body\">\n {campaign.content.assetUrl ? <img className=\"rt-exposure__asset\" src={campaign.content.assetUrl} alt={campaign.content.assetAlt ?? \"\"} /> : null}\n <div className=\"rt-exposure__copy\">\n {campaign.content.eyebrow ? <p className=\"rt-exposure__eyebrow\">{campaign.content.eyebrow}</p> : null}\n <h2 className=\"rt-exposure__title\">{campaign.content.title}</h2>\n {campaign.content.description ? <p className=\"rt-exposure__description\">{campaign.content.description}</p> : null}\n {campaign.content.linkUrl && campaign.content.linkLabel ? <a className=\"rt-exposure__link\" href={campaign.content.linkUrl}>{campaign.content.linkLabel}</a> : null}\n </div>\n <button ref={closeRef} type=\"button\" className=\"rt-exposure__close\" aria-label=\"닫기\" onClick={dismiss}>×</button>\n </div>;\n\n if (campaign.kind === \"banner\") {\n return <aside className={`rt-exposure rt-exposure--banner rt-exposure--${campaign.content.variant}`} aria-label=\"안내\">{body}</aside>;\n }\n return <div className=\"rt-exposure-backdrop\" role=\"presentation\">\n <section className={`rt-exposure rt-exposure--popup rt-exposure--${campaign.content.variant}`} role=\"dialog\" aria-modal=\"true\" aria-label={campaign.content.title}>\n {body}\n </section>\n </div>;\n}\n"],"mappings":";AAEA,SAAS,WAAW,QAAQ,UAAU,4BAA4B;AAiEjC,cAC7B,YAD6B;AA7DjC,SAAS,WAAW,UAAuC;AACzD,SAAO,yBAAyB,SAAS,EAAE;AAC7C;AAEA,SAAS,aAAa,UAAwC;AAC5D,MAAI,SAAS,WAAW,SAAU,QAAO;AACzC,MAAI;AACF,UAAM,UAAU,SAAS,WAAW,YAAY,iBAAiB;AACjE,UAAM,QAAQ,QAAQ,QAAQ,WAAW,QAAQ,CAAC;AAClD,QAAI,CAAC,MAAO,QAAO;AACnB,QAAI,SAAS,WAAW,MAAO,QAAO,KAAK,IAAI,IAAI,OAAO,KAAK,IAAI;AACnE,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,kBAAkB,UAAqC;AAC9D,MAAI,SAAS,WAAW,SAAU;AAClC,MAAI;AACF,UAAM,UAAU,SAAS,WAAW,YAAY,iBAAiB;AACjE,YAAQ,QAAQ,WAAW,QAAQ,GAAG,OAAO,KAAK,IAAI,CAAC,CAAC;AAAA,EAC1D,QAAQ;AAAA,EAER;AACF;AAEO,SAAS,iBAAiB;AAAA,EAC/B;AAAA,EACA;AACF,GAGG;AACD,QAAM,CAAC,WAAW,YAAY,IAAI,SAAS,KAAK;AAChD,QAAM,WAAW,OAA0B,IAAI;AAC/C,QAAM,eAAe,gBAAgB,SAAS,SAAS,QAAQ,OAAO;AACtE,QAAM,WAAW;AAAA,IACf,MAAM,MAAM;AAAA,IACZ,MAAM,CAAC,aAAa,QAAQ;AAAA,IAC5B,MAAM;AAAA,EACR;AACA,QAAM,UAAU,gBAAgB,YAAY,CAAC;AAE7C,YAAU,MAAM;AACd,QAAI,CAAC,WAAW,SAAS,SAAS,QAAS;AAC3C,aAAS,SAAS,MAAM;AACxB,UAAM,YAAY,CAAC,UAAyB;AAC1C,UAAI,MAAM,QAAQ,SAAU,SAAQ;AAAA,IACtC;AACA,WAAO,iBAAiB,WAAW,SAAS;AAC5C,WAAO,MAAM,OAAO,oBAAoB,WAAW,SAAS;AAAA,EAC9D,CAAC;AAED,WAAS,UAAU;AACjB,sBAAkB,QAAQ;AAC1B,iBAAa,IAAI;AAAA,EACnB;AAEA,MAAI,CAAC,gBAAgB,CAAC,QAAS,QAAO;AACtC,QAAM,OAAO,qBAAC,SAAI,WAAU,qBACzB;AAAA,aAAS,QAAQ,WAAW,oBAAC,SAAI,WAAU,sBAAqB,KAAK,SAAS,QAAQ,UAAU,KAAK,SAAS,QAAQ,YAAY,IAAI,IAAK;AAAA,IAC5I,qBAAC,SAAI,WAAU,qBACZ;AAAA,eAAS,QAAQ,UAAU,oBAAC,OAAE,WAAU,wBAAwB,mBAAS,QAAQ,SAAQ,IAAO;AAAA,MACjG,oBAAC,QAAG,WAAU,sBAAsB,mBAAS,QAAQ,OAAM;AAAA,MAC1D,SAAS,QAAQ,cAAc,oBAAC,OAAE,WAAU,4BAA4B,mBAAS,QAAQ,aAAY,IAAO;AAAA,MAC5G,SAAS,QAAQ,WAAW,SAAS,QAAQ,YAAY,oBAAC,OAAE,WAAU,qBAAoB,MAAM,SAAS,QAAQ,SAAU,mBAAS,QAAQ,WAAU,IAAO;AAAA,OAChK;AAAA,IACA,oBAAC,YAAO,KAAK,UAAU,MAAK,UAAS,WAAU,sBAAqB,cAAW,gBAAK,SAAS,SAAS,kBAAC;AAAA,KACzG;AAEA,MAAI,SAAS,SAAS,UAAU;AAC9B,WAAO,oBAAC,WAAM,WAAW,gDAAgD,SAAS,QAAQ,OAAO,IAAI,cAAW,gBAAM,gBAAK;AAAA,EAC7H;AACA,SAAO,oBAAC,SAAI,WAAU,wBAAuB,MAAK,gBAChD,8BAAC,aAAQ,WAAW,+CAA+C,SAAS,QAAQ,OAAO,IAAI,MAAK,UAAS,cAAW,QAAO,cAAY,SAAS,QAAQ,OACzJ,gBACH,GACF;AACF;","names":[]}
@@ -23,6 +23,21 @@
23
23
  list-style: none;
24
24
  }
25
25
 
26
+ .rt-sr-only { position: absolute; inline-size: 1px; block-size: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
27
+ .rt-exposure { color: var(--rt-color-foreground, #111827); background: var(--rt-color-background, #fff); border: 1px solid var(--rt-color-border, #e5e7eb); }
28
+ .rt-exposure__body { position: relative; display: grid; gap: 1rem; padding: 1rem; }
29
+ .rt-exposure__asset { max-inline-size: 100%; block-size: auto; border-radius: .5rem; }
30
+ .rt-exposure__copy { display: grid; gap: .5rem; }
31
+ .rt-exposure__eyebrow, .rt-exposure__title, .rt-exposure__description { margin: 0; }
32
+ .rt-exposure__eyebrow { font-size: .75rem; font-weight: 700; }
33
+ .rt-exposure__title { font-size: 1.25rem; line-height: 1.3; }
34
+ .rt-exposure__link { inline-size: fit-content; font-weight: 700; color: var(--rt-color-primary, #1d4ed8); }
35
+ .rt-exposure__close { position: absolute; inset-block-start: .5rem; inset-inline-end: .5rem; inline-size: 2.75rem; block-size: 2.75rem; border: 0; border-radius: 999px; background: color-mix(in srgb, var(--rt-color-background, #fff) 85%, transparent); color: inherit; cursor: pointer; font-size: 1.5rem; }
36
+ .rt-exposure-backdrop { position: fixed; inset: 0; z-index: 9999; display: grid; place-items: center; padding: 1rem; background: rgb(0 0 0 / .55); }
37
+ .rt-exposure--popup { inline-size: min(100%, 34rem); max-block-size: calc(100dvh - 2rem); overflow: auto; border-radius: 1rem; box-shadow: 0 1.5rem 4rem rgb(0 0 0 / .25); }
38
+ @media (min-width: 40rem) { .rt-exposure--banner .rt-exposure__body { grid-template-columns: minmax(0, 12rem) 1fr; align-items: center; } }
39
+ @media (prefers-reduced-motion: reduce) { .rt-exposure, .rt-exposure-backdrop { scroll-behavior: auto; animation: none !important; transition: none !important; } }
40
+
26
41
  /* Category filter — chips are <a> links (/blog/categories/slug), SSR-only, no JS. */
27
42
  [data-roottale-cms] :where(.rt-cms-filter) {
28
43
  display: flex;
@@ -308,6 +323,29 @@
308
323
  font-style: italic;
309
324
  }
310
325
 
326
+ /* 미리보기 안내 띠 — 본문 템플릿은 발행 화면과 같게 두고 이 띠만 덧붙인다. */
327
+ [data-roottale-cms] :where(.rt-cms-preview-notice) {
328
+ display: flex;
329
+ flex-wrap: wrap;
330
+ gap: 0.25rem 0.75rem;
331
+ align-items: center;
332
+ margin-block-end: 1rem;
333
+ padding: 0.5rem 0.75rem;
334
+ border-radius: 0.5rem;
335
+ border: 1px solid color-mix(in srgb, #b45309 35%, transparent);
336
+ background: color-mix(in srgb, #fbbf24 22%, var(--rt-color-background, #fff));
337
+ color: #78350f;
338
+ font-family: var(--rt-font-body, system-ui, sans-serif);
339
+ font-size: 0.875rem;
340
+ font-weight: 600;
341
+ line-height: 1.5;
342
+ }
343
+
344
+ [data-roottale-cms] :where(.rt-cms-preview-notice__expiry) {
345
+ font-weight: 400;
346
+ opacity: 0.85;
347
+ }
348
+
311
349
  [data-roottale-cms] :where(.rt-cms-article) {
312
350
  font-family: var(--rt-font-body, system-ui, sans-serif);
313
351
  color: var(--rt-color-foreground, #06162f);
@@ -370,6 +408,18 @@
370
408
  overflow-wrap: anywhere;
371
409
  }
372
410
 
411
+ /* 외부 게시판 HTML 가져오기. 원문의 안전한 inline style은 유지하되,
412
+ 넓은 표·이미지가 본문 measure 밖으로 페이지를 밀지 않게 경계를 둔다. */
413
+ [data-roottale-cms] :where(.rt-imported-html) {
414
+ min-inline-size: 0;
415
+ overflow-x: auto;
416
+ }
417
+
418
+ [data-roottale-cms] :where(.rt-imported-html img) {
419
+ max-inline-size: 100%;
420
+ block-size: auto;
421
+ }
422
+
373
423
  /* 시각 브레드크럼 (avcd 구조, opt-in — RootTaleBlogPost `breadcrumb` prop). */
374
424
  [data-roottale-cms] :where(.rt-cms-breadcrumb) {
375
425
  max-inline-size: var(--rt-cms-measure, 42.5rem);
@@ -0,0 +1,9 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { CmsExposureCampaign } from '@roottale/cms-client/server';
3
+
4
+ declare function RootTaleExposure({ campaign, allowedVariants, }: {
5
+ readonly campaign: CmsExposureCampaign;
6
+ readonly allowedVariants: readonly string[];
7
+ }): react_jsx_runtime.JSX.Element | null;
8
+
9
+ export { RootTaleExposure };
@@ -0,0 +1,8 @@
1
+ "use client";
2
+ import {
3
+ RootTaleExposure
4
+ } from "./chunk-GVKENK6H.js";
5
+ export {
6
+ RootTaleExposure
7
+ };
8
+ //# sourceMappingURL=exposure-client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
package/dist/routes.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { CmsPostContent, RootTaleRedirect, CmsPostTranslationRef } from '@roottale/cms-client/server';
2
2
  import { RouteCollection } from '@roottale/cms-core';
3
3
  export { RouteCollection, resolvePostCollection, resolvePostUrl } from '@roottale/cms-core';
4
- import { B as BlogCategoryWithCount } from './blog-filter-BKCGM0qd.js';
4
+ import { B as BlogCategoryWithCount } from './blog-filter-BQNQL3Mk.js';
5
5
 
6
6
  /**
7
7
  * `routes` 공통 — config 타입, collections resolve, fetch 헬퍼. feed/sitemap/llms/
@@ -389,7 +389,7 @@ declare function normalizeRedirectPath(path: string): string;
389
389
  * 매칭하지 않는다. 절대 URL 도착지는 체인 종단(다른 origin 위임). 상태 코드는
390
390
  * 실제 요청 경로에 매칭된 첫 규칙을 따른다. null = 해당 없음.
391
391
  */
392
- declare function matchRedirect(redirects: readonly RootTaleRedirect[], pathname: string): {
392
+ declare function matchRedirect(redirects: readonly RootTaleRedirect[], pathname: string, origin?: string): {
393
393
  toTarget: string;
394
394
  statusCode: 301 | 302;
395
395
  } | null;
@@ -545,6 +545,21 @@ declare function resolveRobotsMeta(flags: {
545
545
  noindex?: boolean;
546
546
  nofollow?: boolean;
547
547
  }): RobotsMeta | undefined;
548
+ /** `buildPreviewMetadata` 반환 — Next `Metadata` 의 구조적 부분집합. */
549
+ interface PreviewMetadata {
550
+ title: string;
551
+ robots: RobotsMeta;
552
+ }
553
+ /**
554
+ * 글 미리보기 페이지(`/preview/post/[id]?token=`) 의 metadata. 미리보기는 초안이므로
555
+ * **항상 noindex/nofollow** — 검색엔진이 미리보기 주소를 색인하면 비공개 글이
556
+ * 새고, 발행 주소와 중복 콘텐츠가 된다. 제목만 받아 `(미리보기)` 접미를 붙인다.
557
+ * canonical·og 는 일부러 내지 않는다(공유 카드로 초안이 캐시되지 않게).
558
+ */
559
+ declare function buildPreviewMetadata(input: {
560
+ title?: string | null;
561
+ locale?: string;
562
+ }): PreviewMetadata;
548
563
  /** Next `Metadata` 의 구조적 부분집합 — 소비자는 그대로 return 가능. */
549
564
  interface PostMetadata {
550
565
  title: string;
@@ -974,4 +989,4 @@ interface FleetInfoRouteConfig {
974
989
  */
975
990
  declare function createFleetInfoRoute(config?: FleetInfoRouteConfig): () => Promise<Response>;
976
991
 
977
- export { ARCHIVE_POST_COUNT_LIMIT, type AlwaysNoindexArchiveKind, type ArchiveCategoriesConfig, type ArchiveCategoriesOptions, type ArchiveIndexDecision, type ArchiveIndexInput, type ArchiveKind, type ArchiveMetadata, type ArchiveMetadataFields, type ArchiveMetadataInput, type ArchivePromotion, type BuildArchiveMetadataOptions, type BuildPostMetadataOptions, type CollectionsInput, type FleetInfoRouteConfig, type ImageResponseLike, type LlmsTxtLink, type LlmsTxtRouteConfig, type LlmsTxtSection, OG_IMAGE_CONTENT_TYPE, OG_IMAGE_SIZE, type OgImageRouteConfig, type PostLocaleOptions, type PostMetadata, type PostMetadataInput, type PostSeoOverrides, type PromotableArchiveKind, type RedirectMiddlewareConfig, type RevalidateRouteConfig, type RobotsMeta, type RootTaleSiteConfig, type SitemapEntry, type SitemapIndexOptions, type SitemapSectionId, buildArchiveMetadata, buildHreflangLanguages, buildPostMetadata, createFeedRoute, createFleetInfoRoute, createLlmsTxtRoute, createPostOgImage, createRedirectMiddleware, createRevalidateRoute, createSitemap, createSitemapIndex, decideArchiveIndex, fetchBlogArchiveCategories, fetchBlogArchivePosts, matchRedirect, normalizeRedirectPath, postRedirectPath, resolveAuthorPromotion, resolveCategoryPromotion, resolveCollections, resolveCollectionsResult, resolveRobotsMeta, safeFetchPosts, trimSlash };
992
+ export { ARCHIVE_POST_COUNT_LIMIT, type AlwaysNoindexArchiveKind, type ArchiveCategoriesConfig, type ArchiveCategoriesOptions, type ArchiveIndexDecision, type ArchiveIndexInput, type ArchiveKind, type ArchiveMetadata, type ArchiveMetadataFields, type ArchiveMetadataInput, type ArchivePromotion, type BuildArchiveMetadataOptions, type BuildPostMetadataOptions, type CollectionsInput, type FleetInfoRouteConfig, type ImageResponseLike, type LlmsTxtLink, type LlmsTxtRouteConfig, type LlmsTxtSection, OG_IMAGE_CONTENT_TYPE, OG_IMAGE_SIZE, type OgImageRouteConfig, type PostLocaleOptions, type PostMetadata, type PostMetadataInput, type PostSeoOverrides, type PreviewMetadata, type PromotableArchiveKind, type RedirectMiddlewareConfig, type RevalidateRouteConfig, type RobotsMeta, type RootTaleSiteConfig, type SitemapEntry, type SitemapIndexOptions, type SitemapSectionId, buildArchiveMetadata, buildHreflangLanguages, buildPostMetadata, buildPreviewMetadata, createFeedRoute, createFleetInfoRoute, createLlmsTxtRoute, createPostOgImage, createRedirectMiddleware, createRevalidateRoute, createSitemap, createSitemapIndex, decideArchiveIndex, fetchBlogArchiveCategories, fetchBlogArchivePosts, matchRedirect, normalizeRedirectPath, postRedirectPath, resolveAuthorPromotion, resolveCategoryPromotion, resolveCollections, resolveCollectionsResult, resolveRobotsMeta, safeFetchPosts, trimSlash };
package/dist/routes.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  resolvePostUrl,
8
8
  safeFetchPosts,
9
9
  trimSlash
10
- } from "./chunk-HM5VP7SR.js";
10
+ } from "./chunk-FJANIAZN.js";
11
11
 
12
12
  // src/routes.ts
13
13
  import { CMS_CLIENT_VERSION } from "@roottale/cms-client/server";
@@ -29,6 +29,14 @@ function resolveRobotsMeta(flags) {
29
29
  if (!flags.noindex && !flags.nofollow) return void 0;
30
30
  return { index: !flags.noindex, follow: !flags.nofollow };
31
31
  }
32
+ function buildPreviewMetadata(input) {
33
+ const suffix = input.locale === "en" ? "(Preview)" : "(\uBBF8\uB9AC\uBCF4\uAE30)";
34
+ const base = input.title?.trim();
35
+ return {
36
+ title: base ? `${base} ${suffix}` : suffix,
37
+ robots: { index: false, follow: false }
38
+ };
39
+ }
32
40
  function buildPostMetadata(post, options = {}) {
33
41
  const seo = post.seo ?? void 0;
34
42
  const title = seo?.title || post.title;
@@ -225,7 +233,12 @@ async function fetchServerCategoryCounts(config, options) {
225
233
  return result.categories.filter((category) => category.publishedPostCount > 0).map((category) => ({
226
234
  name: category.name,
227
235
  slug: category.slug,
228
- count: category.publishedPostCount
236
+ count: category.publishedPostCount,
237
+ ...category.description ? { description: category.description } : {},
238
+ ...category.seoTitle ? { seoTitle: category.seoTitle } : {},
239
+ ...category.seoDescription ? { seoDescription: category.seoDescription } : {},
240
+ ...category.imageUrl ? { imageUrl: category.imageUrl } : {},
241
+ ...category.imageAlt ? { imageAlt: category.imageAlt } : {}
229
242
  }));
230
243
  } catch (err) {
231
244
  console.warn(
@@ -791,7 +804,17 @@ function normalizeRedirectPath(path) {
791
804
  function isAbsoluteUrl(target) {
792
805
  return /^https?:\/\//i.test(target);
793
806
  }
794
- function matchRedirect(redirects, pathname) {
807
+ function internalTargetPath(target, origin) {
808
+ if (!isAbsoluteUrl(target)) return normalizeRedirectPath(target);
809
+ if (!origin) return null;
810
+ try {
811
+ const url = new URL(target);
812
+ return url.origin === origin ? normalizeRedirectPath(url.pathname) : null;
813
+ } catch {
814
+ return null;
815
+ }
816
+ }
817
+ function matchRedirect(redirects, pathname, origin) {
795
818
  const byFrom = /* @__PURE__ */ new Map();
796
819
  for (const r of redirects) {
797
820
  const key = normalizeRedirectPath(r.fromPath);
@@ -802,21 +825,18 @@ function matchRedirect(redirects, pathname) {
802
825
  let current = start;
803
826
  let finalTarget = null;
804
827
  let firstStatus = null;
805
- for (let hop = 0; hop < 10; hop++) {
828
+ for (; ; ) {
806
829
  const rule = byFrom.get(current);
807
830
  if (!rule) break;
808
- if (hop === 0) firstStatus = rule.statusCode;
809
- if (isAbsoluteUrl(rule.toTarget)) {
831
+ if (firstStatus === null) firstStatus = rule.statusCode;
832
+ const nextPath = internalTargetPath(rule.toTarget, origin);
833
+ if (nextPath === null) {
810
834
  finalTarget = rule.toTarget;
811
835
  break;
812
836
  }
813
- const nextPath = normalizeRedirectPath(rule.toTarget);
814
- if (nextPath === start) {
815
- if (hop === 0) return null;
816
- break;
817
- }
837
+ if (nextPath === start) return null;
818
838
  finalTarget = rule.toTarget;
819
- if (visited.has(nextPath)) break;
839
+ if (visited.has(nextPath)) return null;
820
840
  visited.add(nextPath);
821
841
  current = nextPath;
822
842
  }
@@ -860,7 +880,7 @@ function createRedirectMiddleware(config) {
860
880
  }
861
881
  const now = Date.now();
862
882
  const redirects = await loadRedirects(now);
863
- const hit = matchRedirect(redirects, pathname);
883
+ const hit = matchRedirect(redirects, pathname, origin);
864
884
  if (!hit) return null;
865
885
  const dest = new URL(hit.toTarget, origin);
866
886
  if (dest.origin === origin && normalizeRedirectPath(dest.pathname) === normalizeRedirectPath(pathname)) {
@@ -888,7 +908,7 @@ function createFleetInfoRoute(config = {}) {
888
908
  site: config.site ?? env.VERCEL_PROJECT_PRODUCTION_URL ?? null,
889
909
  framework: "next",
890
910
  cms_client: CMS_CLIENT_VERSION,
891
- cms_renderer: true ? "0.53.0" : "dev",
911
+ cms_renderer: true ? "0.55.0" : "dev",
892
912
  build_sha: env.VERCEL_GIT_COMMIT_SHA?.slice(0, 7) ?? null
893
913
  },
894
914
  { headers: { "cache-control": "public, max-age=300" } }
@@ -902,6 +922,7 @@ export {
902
922
  buildArchiveMetadata,
903
923
  buildHreflangLanguages,
904
924
  buildPostMetadata,
925
+ buildPreviewMetadata,
905
926
  createFeedRoute,
906
927
  createFleetInfoRoute,
907
928
  createLlmsTxtRoute,