@roottale/cms-renderer-next 0.52.0 → 0.53.1

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 (80) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +33 -4
  3. package/dist/chunk-GVKENK6H.js +75 -0
  4. package/dist/chunk-GVKENK6H.js.map +1 -0
  5. package/dist/{chunk-U2IM6OCQ.js → chunk-HM5VP7SR.js} +1 -1
  6. package/dist/{chunk-U2IM6OCQ.js.map → chunk-HM5VP7SR.js.map} +1 -1
  7. package/dist/chunk-KSPPYX22.js +14 -0
  8. package/dist/chunk-KSPPYX22.js.map +1 -0
  9. package/dist/cms-public.css +65 -0
  10. package/dist/exposure-client.d.ts +9 -0
  11. package/dist/exposure-client.js +8 -0
  12. package/dist/routes.d.ts +10 -1
  13. package/dist/routes.js +36 -16
  14. package/dist/routes.js.map +1 -1
  15. package/dist/section-runtime.d.ts +46 -0
  16. package/dist/section-runtime.js +9 -0
  17. package/dist/server.d.ts +49 -3
  18. package/dist/server.js +1764 -34
  19. package/dist/server.js.map +1 -1
  20. package/package.json +8 -24
  21. package/dist/before-after-client.d.ts +0 -15
  22. package/dist/before-after-client.js +0 -8
  23. package/dist/chunk-27RT6B3O.js +0 -93
  24. package/dist/chunk-27RT6B3O.js.map +0 -1
  25. package/dist/chunk-2QU4JU46.js +0 -3927
  26. package/dist/chunk-2QU4JU46.js.map +0 -1
  27. package/dist/chunk-4RQ3SEDL.js +0 -106
  28. package/dist/chunk-4RQ3SEDL.js.map +0 -1
  29. package/dist/chunk-6GQCFFHU.js +0 -540
  30. package/dist/chunk-6GQCFFHU.js.map +0 -1
  31. package/dist/chunk-H46JUIHZ.js +0 -79
  32. package/dist/chunk-H46JUIHZ.js.map +0 -1
  33. package/dist/chunk-K62RR4UQ.js +0 -53
  34. package/dist/chunk-K62RR4UQ.js.map +0 -1
  35. package/dist/chunk-LEHHSI6G.js +0 -64
  36. package/dist/chunk-LEHHSI6G.js.map +0 -1
  37. package/dist/chunk-QYNEOFL7.js +0 -418
  38. package/dist/chunk-QYNEOFL7.js.map +0 -1
  39. package/dist/chunk-U5AKACPN.js +0 -113
  40. package/dist/chunk-U5AKACPN.js.map +0 -1
  41. package/dist/chunk-UM37DOIK.js +0 -961
  42. package/dist/chunk-UM37DOIK.js.map +0 -1
  43. package/dist/chunk-YLSQAT6B.js +0 -59
  44. package/dist/chunk-YLSQAT6B.js.map +0 -1
  45. package/dist/chunk-ZL2NA3QT.js +0 -313
  46. package/dist/chunk-ZL2NA3QT.js.map +0 -1
  47. package/dist/concerns-client.d.ts +0 -12
  48. package/dist/concerns-client.js +0 -8
  49. package/dist/contact-map-client.d.ts +0 -27
  50. package/dist/contact-map-client.js +0 -8
  51. package/dist/contact-map-client.js.map +0 -1
  52. package/dist/expertise-client.d.ts +0 -16
  53. package/dist/expertise-client.js +0 -9
  54. package/dist/expertise-client.js.map +0 -1
  55. package/dist/hero-carousel-client.d.ts +0 -52
  56. package/dist/hero-carousel-client.js +0 -9
  57. package/dist/hero-carousel-client.js.map +0 -1
  58. package/dist/seo-checks.d.ts +0 -87
  59. package/dist/seo-checks.js +0 -27
  60. package/dist/seo-checks.js.map +0 -1
  61. package/dist/site-config-DNpvlqA5.d.ts +0 -3961
  62. package/dist/spaces-client.d.ts +0 -10
  63. package/dist/spaces-client.js +0 -8
  64. package/dist/spaces-client.js.map +0 -1
  65. package/dist/team-expanding-stage-client.d.ts +0 -35
  66. package/dist/team-expanding-stage-client.js +0 -8
  67. package/dist/team-expanding-stage-client.js.map +0 -1
  68. package/dist/templates.css +0 -4145
  69. package/dist/templates.d.ts +0 -171
  70. package/dist/templates.js +0 -291
  71. package/dist/templates.js.map +0 -1
  72. package/dist/theme-css-vars-CD3jcEkp.d.ts +0 -7
  73. package/dist/ui-react-motion-client.d.ts +0 -76
  74. package/dist/ui-react-motion-client.js +0 -16
  75. package/dist/ui-react-motion-client.js.map +0 -1
  76. package/dist/validate.d.ts +0 -104
  77. package/dist/validate.js +0 -1204
  78. package/dist/validate.js.map +0 -1
  79. /package/dist/{before-after-client.js.map → exposure-client.js.map} +0 -0
  80. /package/dist/{concerns-client.js.map → section-runtime.js.map} +0 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # @roottale/cms-renderer-next
2
2
 
3
+ ## 0.53.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 1efecbb: 페이지·글·정보의 콘텐츠 모델 공개 계약과 `modelKey` 조회를 추가합니다. 개발자가 선언한 슬롯에서만 배너·팝업을 조회하고 접근성 있게 렌더하는 API, client, renderer, 예제 문서를 함께 제공합니다. 기존 collections와 collectionKey 계약은 유지합니다.
8
+ - 800a797: CMS 글 목록 API가 일시적으로 실패해도 `RootTaleBlogList`와 `RootTaleBlogCategories`가 빈 상태를 렌더하도록 바꿉니다. 신규 사이트의 최초 빌드와 공개 목록 페이지가 CMS 장애 때문에 전체 500으로 중단되지 않습니다.
9
+ - 5cd0e3b: 주소 이동 middleware가 API 장애 시 stale 규칙을 계속 사용하고, 순환 규칙은 브라우저를 왕복시키지 않도록 통과처리합니다. Site Materializer 납품물에 middleware와 회귀 검증 파일을 필수로 포함합니다.
10
+ - cf84636: ChatGPT 등에서 만든 완전한 HTML 문서의 태그 구조, class·id·`data-*`, inline style·`<style>` CSS와 반응형 레이아웃을 `importedHtml` 블록에 보존합니다. 본문의 `h1`은 글 제목과 충돌하지 않도록 `h2`로 바꾸되 원래 `h1` CSS 선택자는 변환된 제목에 이어서 적용합니다. 안전한 `!important`도 유지합니다. CSS는 블록별 고유 범위로 격리하며 스크립트·이벤트 속성·외부 CSS 로드·화면을 덮는 CSS만 제거합니다.
11
+
12
+ `html`·`body` 속성, SVG, `picture`·`srcset`, keyframes와 미러링한 CSS 이미지·woff/woff2 폰트도 보존합니다. 가져온 블록별로 문서 루트, animation과 font 이름을 격리해 다른 본문이나 사이트 UI와 충돌하지 않게 합니다.
13
+
14
+ - Updated dependencies [6df15b6]
15
+ - Updated dependencies [1efecbb]
16
+ - Updated dependencies [cf84636]
17
+ - @roottale/cms-client@0.53.1
18
+ - @roottale/cms-core@0.53.1
19
+
20
+ ## 0.53.0
21
+
22
+ ### Minor Changes
23
+
24
+ - a133f27: 블로그 상세 글의 작성·출력 기준을 하나로 맞췄습니다.
25
+ - 이미지 대체 텍스트를 한 줄, 최대 160자로 정규화하는 공용 계약을 추가했습니다.
26
+ - 상세 글 위에 카테고리를 연결하고, 발행일과 실제로 다른 수정일을 구분해 표시합니다.
27
+ - 본문의 공식 출처 블록을 제목과 목록이 있는 시맨틱 구간으로 렌더합니다.
28
+ - 블로그 전용 SEO 점검에서 목차, 외부 링크가 있는 공식 출처, 내용이 채워진 FAQ를 확인합니다.
29
+ - Next.js 상세 라우트가 글을 먼저 조회한 뒤 `notFound()`를 호출해야 실제 404가 된다는 통합 예제를 보강했습니다.
30
+
31
+ - e3e7ba8: 블로그 상세 상단에 CMS에서 기록한 내용 검토일과 검토자를 표시합니다.
32
+ - 7d1a551: 글 메타데이터가 OG 이미지 대체 텍스트와 Google의 큰 이미지 미리보기 지시자를 함께 출력합니다.
33
+ - 3dc6e1d: 공개 CMS runtime에서 RootTale의 private 마케팅 섹션 공장을 분리했습니다.
34
+ - `templates`, `templates/styles`, `validate`, `templates/seo-checks` 공개 subpath를 제거했습니다.
35
+ - 글·페이지 본문, 테마 bridge, 문의, feed·sitemap·webhook runtime은 그대로 유지합니다.
36
+ - `section` 노드는 공개 renderer가 자동으로 해석하지 않으며, 내부 제작 경로가 명시적으로 renderer를 등록해야 합니다.
37
+ - 같은 릴리스 묶음의 `@roottale/cms-core` 변경과 함께 배포해야 합니다.
38
+
39
+ ### Patch Changes
40
+
41
+ - 2eb82d3: 블로그 작성자 프로필의 기준을 글에 지정된 사이트 작성자 한 곳으로 통합했습니다.
42
+ - 작성자가 없는 글은 전역 설정으로 작성자 카드가 만들어지지 않습니다.
43
+ - 작성자 이름·사진·소개는 글 응답의 작성자 프로필만 사용합니다.
44
+ - 레거시 전역 작성자 응답 필드는 호환을 위해 남지만 항상 `null`입니다.
45
+
46
+ - 3ab5570: RSS 생성기에 선택적인 WebSub `atom:hub` 링크를 추가했다. `createFeedRoute`는
47
+ `webSubHubUrl`을 받아 이를 피드에 전달하며, 값을 주지 않은 기존 사이트의 출력은
48
+ 바뀌지 않는다. Site Kit은 Google WebSub hub를 기본 연결하고 IndexNow 소유권 확인용
49
+ `/indexnow-key.txt` 라우트를 제공한다.
50
+ - Updated dependencies [a133f27]
51
+ - Updated dependencies [f743a06]
52
+ - Updated dependencies [3ab5570]
53
+ - @roottale/cms-core@0.53.0
54
+ - @roottale/cms-client@0.53.0
55
+
3
56
  ## 0.52.0
4
57
 
5
58
  ### Minor Changes
package/README.md CHANGED
@@ -6,7 +6,9 @@ Drop-in `<RootTaleBlogList>` / `<RootTaleBlogPost>` / `<RootTaleLeadForm>` for N
6
6
 
7
7
  Pair with [`@roottale/cms-client`](https://www.npmjs.com/package/@roottale/cms-client) (the fetch client) and the RootTale CMS admin (`mysite.roottale.com`) where you author the content.
8
8
 
9
- Astro counterpart: [`@roottale/cms-renderer-astro`](https://www.npmjs.com/package/@roottale/cms-renderer-astro) — equivalent surface (ADR-0034 §1.5).
9
+ 이 public package는 글·페이지 본문과 CMS 연동 runtime만 제공한다. RootTale의
10
+ 전문직·B2B 마케팅 섹션, variants와 `SectionSchema`는 private 제작 공장에 있으며
11
+ 고객 사이트에는 선택된 결과만 local source로 전달한다.
10
12
 
11
13
  ## Install
12
14
 
@@ -146,7 +148,9 @@ export default async function CategoryPage({
146
148
  ## Blog post — `app/blog/[slug]/page.tsx`
147
149
 
148
150
  ```tsx
151
+ import { fetchPost } from "@roottale/cms-client/server";
149
152
  import { RootTaleBlogPost } from "@roottale/cms-renderer-next/server";
153
+ import { notFound } from "next/navigation";
150
154
 
151
155
  export default async function PostPage({
152
156
  params,
@@ -154,13 +158,21 @@ export default async function PostPage({
154
158
  params: Promise<{ slug: string }>;
155
159
  }) {
156
160
  const { slug } = await params;
161
+ const post = await fetchPost({
162
+ apiKey: process.env.ROOTTALE_API_KEY!,
163
+ slugOrId: slug,
164
+ });
165
+ if (!post) notFound();
166
+
157
167
  return (
158
168
  <main>
159
169
  <RootTaleBlogPost
160
170
  apiKey={process.env.ROOTTALE_API_KEY!}
161
- slugOrId={slug}
171
+ slugOrId={post.id}
162
172
  showTableOfContents
163
173
  tableOfContentsTitle="목차"
174
+ breadcrumb={{ siteUrl: process.env.NEXT_PUBLIC_SITE_URL }}
175
+ relatedPostsCount={3}
164
176
  />
165
177
  </main>
166
178
  );
@@ -171,6 +183,15 @@ export default async function PostPage({
171
183
  already renders that title, pass `showTitle={false}` to avoid a duplicate
172
184
  heading. The excerpt, post metadata, and body remain visible.
173
185
 
186
+ Fetch the post in the route and call Next.js `notFound()` before rendering, as
187
+ shown above. `notFoundElement` only changes the component fallback; it cannot
188
+ turn a successful route response into an HTTP 404.
189
+
190
+ When present, category links render above the `<h1>`. The metadata row labels
191
+ the published date and displays a separate modified date only when its calendar
192
+ date differs. Body images always receive a one-line alt value capped at 160
193
+ characters.
194
+
174
195
  ## Lead form — `app/contact/page.tsx`
175
196
 
176
197
  ```tsx
@@ -206,7 +227,7 @@ The form submits via HTML POST (no custom JS). In a Next.js site, point `action`
206
227
 
207
228
  | Export | Use |
208
229
  |---|---|
209
- | `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. |
210
231
  | `RootTaleBlogPost` | RSC — fetches one post, renders Tiptap doc + optional TOC. |
211
232
  | `RootTaleLeadForm` | RSC — HTML form for `/v1/public/inquiries` forward routes. |
212
233
  | `RootTalePostCard` | Single card primitive (used internally by `RootTaleBlogList`). |
@@ -218,7 +239,15 @@ The form submits via HTML POST (no custom JS). In a Next.js site, point `action`
218
239
  ## Security model
219
240
 
220
241
  - `@roottale/cms-client` throws if you import it into a browser bundle (`assertServer()`). Never expose `rtlk_cust_*` to the client.
221
- - 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).
222
251
  - The default `target="_blank"` on link marks gets `rel="noopener noreferrer"`.
223
252
 
224
253
  ## Compatibility
@@ -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":[]}
@@ -101,4 +101,4 @@ export {
101
101
  resolvePostCollection2 as resolvePostCollection,
102
102
  resolvePostUrl2 as resolvePostUrl
103
103
  };
104
- //# sourceMappingURL=chunk-U2IM6OCQ.js.map
104
+ //# sourceMappingURL=chunk-HM5VP7SR.js.map
@@ -1 +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}\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 /** 블로그 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":";AA0EA;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;;;ACnGA,SAAS,kBAAuC;AAKhD,SAAS,yBAAAC,wBAAuB,kBAAAC,uBAAsB;AAmCtD,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"]}
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}\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":";AA0EA;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;;;ACnGA,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,14 @@
1
+ // src/section-render-context.ts
2
+ var sectionNodeRenderer = null;
3
+ function setSectionNodeRenderer(renderer) {
4
+ sectionNodeRenderer = renderer;
5
+ }
6
+ function getSectionNodeRenderer() {
7
+ return sectionNodeRenderer;
8
+ }
9
+
10
+ export {
11
+ setSectionNodeRenderer,
12
+ getSectionNodeRenderer
13
+ };
14
+ //# sourceMappingURL=chunk-KSPPYX22.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/section-render-context.ts"],"sourcesContent":["/**\n * 섹션 렌더러 주입 지점 — ADR-0040 P2 단방향 계약(코어 ◄ site-sections).\n *\n * 본문(tiptap) 안의 `section` 노드는 비공개 `site-sections` 컴포넌트로 그리지만,\n * 공개 코어가 비공개 패키지를 직접 import 하면 순환·번들\n * 오염이 된다(dependency-cruiser `no-renderer-core-to-templates`). 그래서 코어는\n * \"섹션을 그리는 함수\" 의 자리만 비워두고, private\n * `@roottale/site-sections`를 import 하는 내부 제작 경로에서만 채운다.\n *\n * 등록 없이 `section` 노드를 만나면 dev 경고 후 skip — 미지 섹션 타입을 만났을\n * 때와 같은 fail-soft 동작이다.\n */\nimport type { ReactElement, ReactNode } from \"react\";\nimport type { CmsPostContent } from \"@roottale/cms-client/server\";\n\n/** 발행 API 호출에 필요한 최소 컨텍스트(공개 키 + 베이스 URL). */\nexport type CmsBoundContext = { apiKey: string; baseUrl?: string };\n\nexport type BlogListProps = {\n apiKey: string;\n baseUrl?: string;\n limit?: number;\n emptyMessage?: string;\n postHref?: (post: CmsPostContent) => string;\n};\n\nexport type SectionRenderContext = {\n cms: CmsBoundContext | null;\n renderBlogList: (props: BlogListProps) => ReactNode;\n isDev: () => boolean;\n};\n\n/** `section` 노드 하나를 그리는 함수 — templates 쪽 구현이 주입된다. */\nexport type SectionNodeRenderer = (\n node: { type?: string; attrs?: Record<string, unknown> },\n key: number,\n context: SectionRenderContext,\n) => ReactElement | null;\n\nlet sectionNodeRenderer: SectionNodeRenderer | null = null;\n\n/**\n * private 섹션 공장이 로드되며 호출한다. 공개 CMS runtime만 쓰는 고객 FRONT는\n * 이 함수를 호출하지 않으며 `section` 노드를 마케팅 페이지 구조로 사용하지 않는다.\n */\nexport function setSectionNodeRenderer(renderer: SectionNodeRenderer): void {\n sectionNodeRenderer = renderer;\n}\n\nexport function getSectionNodeRenderer(): SectionNodeRenderer | null {\n return sectionNodeRenderer;\n}\n"],"mappings":";AAuCA,IAAI,sBAAkD;AAM/C,SAAS,uBAAuB,UAAqC;AAC1E,wBAAsB;AACxB;AAEO,SAAS,yBAAqD;AACnE,SAAO;AACT;","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;
@@ -370,6 +385,18 @@
370
385
  overflow-wrap: anywhere;
371
386
  }
372
387
 
388
+ /* 외부 게시판 HTML 가져오기. 원문의 안전한 inline style은 유지하되,
389
+ 넓은 표·이미지가 본문 measure 밖으로 페이지를 밀지 않게 경계를 둔다. */
390
+ [data-roottale-cms] :where(.rt-imported-html) {
391
+ min-inline-size: 0;
392
+ overflow-x: auto;
393
+ }
394
+
395
+ [data-roottale-cms] :where(.rt-imported-html img) {
396
+ max-inline-size: 100%;
397
+ block-size: auto;
398
+ }
399
+
373
400
  /* 시각 브레드크럼 (avcd 구조, opt-in — RootTaleBlogPost `breadcrumb` prop). */
374
401
  [data-roottale-cms] :where(.rt-cms-breadcrumb) {
375
402
  max-inline-size: var(--rt-cms-measure, 42.5rem);
@@ -426,6 +453,27 @@
426
453
  border-block-end: 1px solid color-mix(in srgb, var(--rt-color-border, #dceef3) 72%, transparent);
427
454
  }
428
455
 
456
+ [data-roottale-cms] :where(.rt-cms-post-categories) {
457
+ display: flex;
458
+ flex-wrap: wrap;
459
+ gap: var(--rt-spacing-2, 0.5rem);
460
+ margin-block-end: var(--rt-spacing-3, 0.75rem);
461
+ }
462
+
463
+ [data-roottale-cms] :where(.rt-cms-post-category) {
464
+ display: inline-flex;
465
+ align-items: center;
466
+ min-block-size: 1.75rem;
467
+ padding-inline: 0.625rem;
468
+ border-radius: 999px;
469
+ background: color-mix(in srgb, var(--rt-color-primary, #06162f) 9%, transparent);
470
+ color: var(--rt-color-primary, #06162f);
471
+ font-size: var(--rt-font-size-sm, 0.8125rem);
472
+ font-weight: var(--rt-font-weight-bold, 700);
473
+ line-height: 1.2;
474
+ text-decoration: none;
475
+ }
476
+
429
477
  [data-roottale-cms] :where(.rt-cms-post-header .rt-cms-title) {
430
478
  font-size: clamp(1.75rem, 1.35rem + 1.9vw, 2.375rem);
431
479
  font-weight: var(--rt-font-weight-bold, 800);
@@ -837,6 +885,23 @@
837
885
  font-weight: 600;
838
886
  }
839
887
 
888
+ /* 공식 출처 — 일반 목록과 구분되는 근거 묶음. */
889
+ [data-roottale-cms] :where(.rt-cms-references) {
890
+ padding: var(--rt-spacing-5, 1.25rem);
891
+ border: 1px solid color-mix(in srgb, var(--rt-color-border, #dceef3) 88%, transparent);
892
+ border-radius: var(--rt-radius-md, 0.5rem);
893
+ background: color-mix(in srgb, var(--rt-color-muted, #f7f8f7) 34%, transparent);
894
+ }
895
+
896
+ [data-roottale-cms] :where(.rt-cms-references .rt-cms-references__title) {
897
+ margin: 0 0 var(--rt-spacing-3, 0.75rem);
898
+ font-size: var(--rt-font-size-xl, 1.375rem);
899
+ }
900
+
901
+ [data-roottale-cms] :where(.rt-cms-references > ul) {
902
+ margin: 0;
903
+ }
904
+
840
905
  /* 글 하단 고정 CTA — 본문 끝 리드 전환 블록. */
841
906
  [data-roottale-cms] :where(.rt-cms-post-cta) {
842
907
  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
package/dist/routes.d.ts CHANGED
@@ -20,6 +20,8 @@ interface RootTaleSiteConfig {
20
20
  description?: string;
21
21
  /** 기본 "ko-KR". */
22
22
  language?: string;
23
+ /** RSS의 WebSub discovery link. 미지정이면 기존 RSS 출력 유지. */
24
+ webSubHubUrl?: string;
23
25
  /** 블로그 base path. 기본 "/blog". 레거시 단일 스트림 — `collections` 미사용 시. */
24
26
  blogBasePath?: string;
25
27
  /**
@@ -387,7 +389,7 @@ declare function normalizeRedirectPath(path: string): string;
387
389
  * 매칭하지 않는다. 절대 URL 도착지는 체인 종단(다른 origin 위임). 상태 코드는
388
390
  * 실제 요청 경로에 매칭된 첫 규칙을 따른다. null = 해당 없음.
389
391
  */
390
- declare function matchRedirect(redirects: readonly RootTaleRedirect[], pathname: string): {
392
+ declare function matchRedirect(redirects: readonly RootTaleRedirect[], pathname: string, origin?: string): {
391
393
  toTarget: string;
392
394
  statusCode: 301 | 302;
393
395
  } | null;
@@ -422,6 +424,8 @@ interface PostSeoOverrides {
422
424
  canonical?: string;
423
425
  /** OG 이미지 — 비면 글 대표 이미지 사용. */
424
426
  ogImage?: string;
427
+ /** OG 이미지 대체 텍스트 — 비면 글 제목 사용. */
428
+ ogImageAlt?: string;
425
429
  /** 색인 제외. */
426
430
  noindex?: boolean;
427
431
  /** 링크 추적 제외. */
@@ -523,6 +527,11 @@ interface PostLocaleOptions {
523
527
  interface RobotsMeta {
524
528
  index: boolean;
525
529
  follow: boolean;
530
+ googleBot?: {
531
+ index: boolean;
532
+ follow: boolean;
533
+ "max-image-preview": "large";
534
+ };
526
535
  }
527
536
  /**
528
537
  * noindex/nofollow 플래그 → `Metadata.robots`. **글과 아카이브가 공유하는 단
package/dist/routes.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  resolvePostUrl,
8
8
  safeFetchPosts,
9
9
  trimSlash
10
- } from "./chunk-U2IM6OCQ.js";
10
+ } from "./chunk-HM5VP7SR.js";
11
11
 
12
12
  // src/routes.ts
13
13
  import { CMS_CLIENT_VERSION } from "@roottale/cms-client/server";
@@ -56,7 +56,9 @@ function buildPostMetadata(post, options = {}) {
56
56
  if (post.section) openGraph.section = post.section;
57
57
  if (post.tags && post.tags.length > 0) openGraph.tags = [...post.tags];
58
58
  if (post.authors && post.authors.length > 0) openGraph.authors = [...post.authors];
59
- if (ogImage) openGraph.images = [{ url: ogImage, alt: title }];
59
+ if (ogImage) {
60
+ openGraph.images = [{ url: ogImage, alt: seo?.ogImageAlt || title }];
61
+ }
60
62
  const meta = { title, openGraph };
61
63
  if (description) meta.description = description;
62
64
  if (canonical) {
@@ -80,11 +82,21 @@ function buildPostMetadata(post, options = {}) {
80
82
  }
81
83
  };
82
84
  }
83
- const robots = resolveRobotsMeta({
85
+ const baseRobots = resolveRobotsMeta({
84
86
  ...seo?.noindex !== void 0 && { noindex: seo.noindex },
85
87
  ...seo?.nofollow !== void 0 && { nofollow: seo.nofollow }
86
88
  });
87
- if (robots) meta.robots = robots;
89
+ const index = baseRobots?.index ?? true;
90
+ const follow = baseRobots?.follow ?? true;
91
+ meta.robots = {
92
+ index,
93
+ follow,
94
+ googleBot: {
95
+ index,
96
+ follow,
97
+ "max-image-preview": "large"
98
+ }
99
+ };
88
100
  if (post.id) meta.other = { "rt:content-id": post.id };
89
101
  const twitter = { card: "summary_large_image", title };
90
102
  if (description) twitter.description = description;
@@ -405,6 +417,7 @@ function createFeedRoute(config) {
405
417
  feedUrl: `${siteUrl}/feed.xml`,
406
418
  description: config.description,
407
419
  language: config.language,
420
+ hubUrl: config.webSubHubUrl,
408
421
  items: rssItemsFromPosts(posts, {
409
422
  postUrl: (p) => inCollectionMode ? resolvePostUrl3(siteUrl, p, collections) ?? `${siteUrl}${blogBase}/${p.slug}` : `${siteUrl}${blogBase}/${p.slug}`
410
423
  })
@@ -778,7 +791,17 @@ function normalizeRedirectPath(path) {
778
791
  function isAbsoluteUrl(target) {
779
792
  return /^https?:\/\//i.test(target);
780
793
  }
781
- function matchRedirect(redirects, pathname) {
794
+ function internalTargetPath(target, origin) {
795
+ if (!isAbsoluteUrl(target)) return normalizeRedirectPath(target);
796
+ if (!origin) return null;
797
+ try {
798
+ const url = new URL(target);
799
+ return url.origin === origin ? normalizeRedirectPath(url.pathname) : null;
800
+ } catch {
801
+ return null;
802
+ }
803
+ }
804
+ function matchRedirect(redirects, pathname, origin) {
782
805
  const byFrom = /* @__PURE__ */ new Map();
783
806
  for (const r of redirects) {
784
807
  const key = normalizeRedirectPath(r.fromPath);
@@ -789,21 +812,18 @@ function matchRedirect(redirects, pathname) {
789
812
  let current = start;
790
813
  let finalTarget = null;
791
814
  let firstStatus = null;
792
- for (let hop = 0; hop < 10; hop++) {
815
+ for (; ; ) {
793
816
  const rule = byFrom.get(current);
794
817
  if (!rule) break;
795
- if (hop === 0) firstStatus = rule.statusCode;
796
- if (isAbsoluteUrl(rule.toTarget)) {
818
+ if (firstStatus === null) firstStatus = rule.statusCode;
819
+ const nextPath = internalTargetPath(rule.toTarget, origin);
820
+ if (nextPath === null) {
797
821
  finalTarget = rule.toTarget;
798
822
  break;
799
823
  }
800
- const nextPath = normalizeRedirectPath(rule.toTarget);
801
- if (nextPath === start) {
802
- if (hop === 0) return null;
803
- break;
804
- }
824
+ if (nextPath === start) return null;
805
825
  finalTarget = rule.toTarget;
806
- if (visited.has(nextPath)) break;
826
+ if (visited.has(nextPath)) return null;
807
827
  visited.add(nextPath);
808
828
  current = nextPath;
809
829
  }
@@ -847,7 +867,7 @@ function createRedirectMiddleware(config) {
847
867
  }
848
868
  const now = Date.now();
849
869
  const redirects = await loadRedirects(now);
850
- const hit = matchRedirect(redirects, pathname);
870
+ const hit = matchRedirect(redirects, pathname, origin);
851
871
  if (!hit) return null;
852
872
  const dest = new URL(hit.toTarget, origin);
853
873
  if (dest.origin === origin && normalizeRedirectPath(dest.pathname) === normalizeRedirectPath(pathname)) {
@@ -875,7 +895,7 @@ function createFleetInfoRoute(config = {}) {
875
895
  site: config.site ?? env.VERCEL_PROJECT_PRODUCTION_URL ?? null,
876
896
  framework: "next",
877
897
  cms_client: CMS_CLIENT_VERSION,
878
- cms_renderer: true ? "0.52.0" : "dev",
898
+ cms_renderer: true ? "0.53.1" : "dev",
879
899
  build_sha: env.VERCEL_GIT_COMMIT_SHA?.slice(0, 7) ?? null
880
900
  },
881
901
  { headers: { "cache-control": "public, max-age=300" } }