@roottale/cms-renderer-next 0.65.1 → 0.67.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 +34 -0
- package/dist/{blog-filter-qE9Lj49C.d.ts → blog-filter-CS4GZws8.d.ts} +7 -0
- package/dist/chunk-22MB5YER.js +122 -0
- package/dist/chunk-22MB5YER.js.map +1 -0
- package/dist/{chunk-GDP2VGJR.js → chunk-3UBTE7BV.js} +1 -1
- package/dist/chunk-3UBTE7BV.js.map +1 -0
- package/dist/cms-public.css +376 -228
- package/dist/fonts/LICENSE +94 -0
- package/dist/fonts/PretendardVariable.woff2 +0 -0
- package/dist/image-lightbox.d.ts +10 -0
- package/dist/image-lightbox.js +8 -0
- package/dist/image-lightbox.js.map +1 -0
- package/dist/routes.d.ts +94 -11
- package/dist/routes.js +117 -39
- package/dist/routes.js.map +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.js +17 -11
- package/dist/server.js.map +1 -1
- package/package.json +11 -6
- package/dist/chunk-GDP2VGJR.js.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# @roottale/cms-renderer-next
|
|
2
2
|
|
|
3
|
+
## 0.67.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 30d26ac: 공통 메타데이터 해석기·고유 경로 조회·Next SSR 빌더와 캐시 태그를 추가합니다. 기존 canonical과 강제 robots 정책을 보존하고 공유 이미지 대체 텍스트를 OG·X에 동일하게 적용합니다. 분류의 공유 전용 설정과 메타데이터 조회·수정 MCP 도구, Pro 하향 후 공개 적용 유지 계약을 문서와 예제에 반영합니다. 분류 색인 전환을 선언한 사이트가 공개 분류 응답의 `category_index_activated`를 확인한 뒤에만 분류 검색 제외를 적용하도록 안내합니다.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies [30d26ac]
|
|
12
|
+
- @roottale/cms-core@0.67.0
|
|
13
|
+
- @roottale/cms-client@0.67.0
|
|
14
|
+
|
|
15
|
+
## 0.66.0
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- da23c9e: AVCD 본문 기준으로 기본 읽기 폭을 720px, 행간을 1.7로 맞추고 제목 위계와 문단 간격을 정리합니다. 가져온 HTML 내부 문단에도 24px 간격을 적용하며 모바일 제목 크기를 조정합니다. 기존 테마 변수 재정의는 유지합니다.
|
|
20
|
+
|
|
21
|
+
글머리표·번호는 li에 적용되는 CSS reset에서도 유지됩니다. 강조·기울임·인용·코드·이미지·표까지 AVCD의 서식 체계를 적용하며 독립 본문과 가져온 HTML에도 적용합니다. 번호 목록의 시작 번호도 보존합니다. 모바일 제목은 AVCD 실측값인 22/20/18px입니다.
|
|
22
|
+
|
|
23
|
+
Pretendard(SIL OFL)를 포함하고 헤더 높이를 고려한 sticky 목차 오프셋과 hover/focus 링크 밑줄을 적용합니다. 제목 내부 고정 크기 마크도 반응형 제목 크기를 따릅니다.
|
|
24
|
+
|
|
25
|
+
글 제목은 데스크톱 44px/700/1.3, 모바일 28px/600/1.3으로 통일하고 제목 아래 12px 간격을 적용합니다. 문단에 저장된 고정 글자 크기·행간도 모바일 본문과 소제목 크기를 따르게 합니다. 데스크톱의 문단 서식과 모바일 색상·강조는 유지합니다. 모바일 목차는 닫힌 높이를 52px로 줄입니다.
|
|
26
|
+
|
|
27
|
+
본문 이미지를 읽기 폭까지 크게 표시하고 클릭·Enter·Space로 확대할 수 있습니다. 확대 화면은 2배 확대, Esc·닫기, 포커스 복귀를 지원합니다. 독립 본문용 `image-lightbox` 진입점도 제공합니다.
|
|
28
|
+
|
|
29
|
+
- 1d2be30: 빈 분류 변경도 전체 분류 캐시와 레이아웃을 갱신합니다. 미디어 삭제의 CDN 정리·자동 재시도와 삭제 완료 응답 계약을 문서화합니다.
|
|
30
|
+
- Updated dependencies [a58c5d2]
|
|
31
|
+
- Updated dependencies [d5ef48e]
|
|
32
|
+
- Updated dependencies [716dc9b]
|
|
33
|
+
- Updated dependencies [bb7b516]
|
|
34
|
+
- @roottale/cms-core@0.66.0
|
|
35
|
+
- @roottale/cms-client@0.66.0
|
|
36
|
+
|
|
3
37
|
## 0.65.1
|
|
4
38
|
|
|
5
39
|
### Patch Changes
|
|
@@ -17,6 +17,13 @@ interface BlogCategoryWithCount extends BlogCategory {
|
|
|
17
17
|
description?: string;
|
|
18
18
|
seoTitle?: string;
|
|
19
19
|
seoDescription?: string;
|
|
20
|
+
seoOgImage?: string;
|
|
21
|
+
seoOgImageAlt?: string;
|
|
22
|
+
seoNofollow?: boolean;
|
|
23
|
+
/** 서버 집계가 없으면 undefined. API 장애를 미승격(false)으로 바꾸지 않는다. */
|
|
24
|
+
hubPromoted?: boolean;
|
|
25
|
+
/** 공개 분류 API가 계산한 정규 경로. */
|
|
26
|
+
path?: string;
|
|
20
27
|
imageUrl?: string;
|
|
21
28
|
imageAlt?: string;
|
|
22
29
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/image-lightbox.tsx
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
|
+
import { createPortal } from "react-dom";
|
|
6
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
+
function RootTaleImageLightbox({ children, imageSelector = "img" }) {
|
|
8
|
+
const containerRef = useRef(null);
|
|
9
|
+
const dialogRef = useRef(null);
|
|
10
|
+
const closeButtonRef = useRef(null);
|
|
11
|
+
const triggerRef = useRef(null);
|
|
12
|
+
const [selected, setSelected] = useState(null);
|
|
13
|
+
const [enlarged, setEnlarged] = useState(false);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const container = containerRef.current;
|
|
16
|
+
if (!container) return;
|
|
17
|
+
const cleanups = /* @__PURE__ */ new Map();
|
|
18
|
+
function enhance() {
|
|
19
|
+
if (!container) return;
|
|
20
|
+
for (const image of container.querySelectorAll(imageSelector)) {
|
|
21
|
+
if (image.tagName !== "IMG" || cleanups.has(image) || image.hasAttribute("data-no-zoom") || image.closest("a, button") || image.parentElement?.closest('[role="button"]')) continue;
|
|
22
|
+
const attributes = ["role", "tabindex", "aria-label", "aria-haspopup", "data-rt-image-zoom"];
|
|
23
|
+
const previous = attributes.map((name) => image.getAttribute(name));
|
|
24
|
+
image.setAttribute("role", "button");
|
|
25
|
+
image.setAttribute("tabindex", "0");
|
|
26
|
+
image.setAttribute("aria-label", image.alt ? `${image.alt} \uD655\uB300 \uBCF4\uAE30` : "\uC774\uBBF8\uC9C0 \uD655\uB300 \uBCF4\uAE30");
|
|
27
|
+
image.setAttribute("aria-haspopup", "dialog");
|
|
28
|
+
image.setAttribute("data-rt-image-zoom", "");
|
|
29
|
+
const open = () => {
|
|
30
|
+
triggerRef.current = image;
|
|
31
|
+
setEnlarged(false);
|
|
32
|
+
setSelected({
|
|
33
|
+
src: image.dataset.fullsizeSrc || image.src,
|
|
34
|
+
alt: image.alt,
|
|
35
|
+
zoomWidth: Math.max(image.naturalWidth, image.getBoundingClientRect().width) * 2
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
const keydown = (event) => {
|
|
39
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
40
|
+
event.preventDefault();
|
|
41
|
+
open();
|
|
42
|
+
};
|
|
43
|
+
image.addEventListener("click", open);
|
|
44
|
+
image.addEventListener("keydown", keydown);
|
|
45
|
+
cleanups.set(image, () => {
|
|
46
|
+
image.removeEventListener("click", open);
|
|
47
|
+
image.removeEventListener("keydown", keydown);
|
|
48
|
+
attributes.forEach((name, index) => {
|
|
49
|
+
const value = previous[index];
|
|
50
|
+
if (value === null || value === void 0) image.removeAttribute(name);
|
|
51
|
+
else image.setAttribute(name, value);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
enhance();
|
|
57
|
+
const observer = new MutationObserver(enhance);
|
|
58
|
+
observer.observe(container, { childList: true, subtree: true });
|
|
59
|
+
return () => {
|
|
60
|
+
observer.disconnect();
|
|
61
|
+
for (const cleanup of cleanups.values()) cleanup();
|
|
62
|
+
};
|
|
63
|
+
}, [children, imageSelector]);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const dialog = dialogRef.current;
|
|
66
|
+
if (!selected || !dialog) return;
|
|
67
|
+
const overflow = document.body.style.overflow;
|
|
68
|
+
document.body.style.overflow = "hidden";
|
|
69
|
+
dialog.showModal();
|
|
70
|
+
closeButtonRef.current?.focus();
|
|
71
|
+
const closeOnBackdrop = (event) => {
|
|
72
|
+
const target = event.target;
|
|
73
|
+
if (target === dialog || target?.classList.contains("rt-cms-image-dialog__viewport")) setSelected(null);
|
|
74
|
+
};
|
|
75
|
+
dialog.addEventListener("click", closeOnBackdrop);
|
|
76
|
+
return () => {
|
|
77
|
+
dialog.removeEventListener("click", closeOnBackdrop);
|
|
78
|
+
if (dialog.open) dialog.close();
|
|
79
|
+
document.body.style.overflow = overflow;
|
|
80
|
+
triggerRef.current?.focus({ preventScroll: true });
|
|
81
|
+
};
|
|
82
|
+
}, [selected]);
|
|
83
|
+
return /* @__PURE__ */ jsxs("div", { ref: containerRef, "data-rt-image-gallery": "", style: { display: "contents" }, children: [
|
|
84
|
+
children,
|
|
85
|
+
selected && createPortal(
|
|
86
|
+
/* @__PURE__ */ jsxs(
|
|
87
|
+
"dialog",
|
|
88
|
+
{
|
|
89
|
+
ref: dialogRef,
|
|
90
|
+
className: "rt-cms-image-dialog",
|
|
91
|
+
"aria-label": "\uC774\uBBF8\uC9C0 \uD655\uB300 \uBCF4\uAE30",
|
|
92
|
+
onClose: () => setSelected(null),
|
|
93
|
+
onCancel: () => setSelected(null),
|
|
94
|
+
children: [
|
|
95
|
+
/* @__PURE__ */ jsxs("div", { className: "rt-cms-image-dialog__toolbar", children: [
|
|
96
|
+
/* @__PURE__ */ jsx("button", { type: "button", "aria-pressed": enlarged, onClick: () => setEnlarged(!enlarged), children: enlarged ? "\uD654\uBA74\uC5D0 \uB9DE\uCD94\uAE30" : "2\uBC30 \uD655\uB300" }),
|
|
97
|
+
/* @__PURE__ */ jsxs("button", { ref: closeButtonRef, type: "button", onClick: () => setSelected(null), "aria-label": "\uC774\uBBF8\uC9C0 \uD655\uB300 \uB2EB\uAE30", children: [
|
|
98
|
+
"\uB2EB\uAE30 ",
|
|
99
|
+
/* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: "\xD7" })
|
|
100
|
+
] })
|
|
101
|
+
] }),
|
|
102
|
+
/* @__PURE__ */ jsx("div", { className: "rt-cms-image-dialog__viewport", "data-enlarged": enlarged, children: /* @__PURE__ */ jsx(
|
|
103
|
+
"img",
|
|
104
|
+
{
|
|
105
|
+
src: selected.src,
|
|
106
|
+
alt: selected.alt,
|
|
107
|
+
referrerPolicy: "no-referrer",
|
|
108
|
+
style: enlarged ? { width: selected.zoomWidth } : void 0
|
|
109
|
+
}
|
|
110
|
+
) })
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
),
|
|
114
|
+
document.body
|
|
115
|
+
)
|
|
116
|
+
] });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export {
|
|
120
|
+
RootTaleImageLightbox
|
|
121
|
+
};
|
|
122
|
+
//# sourceMappingURL=chunk-22MB5YER.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/image-lightbox.tsx"],"sourcesContent":["\"use client\";\n\nimport { useEffect, useRef, useState, type ReactNode } from 'react';\nimport { createPortal } from 'react-dom';\n\ntype ImageSource = { src: string; alt: string; zoomWidth: number };\n\n/** HTML·Tiptap 본문 모두에 적용한다. 이미 링크나 버튼인 이미지는 원래 동작을 유지한다. */\nexport function RootTaleImageLightbox({ children, imageSelector = 'img' }: {\n children: ReactNode;\n imageSelector?: string;\n}) {\n const containerRef = useRef<HTMLDivElement>(null);\n const dialogRef = useRef<HTMLDialogElement>(null);\n const closeButtonRef = useRef<HTMLButtonElement>(null);\n const triggerRef = useRef<HTMLImageElement | null>(null);\n const [selected, setSelected] = useState<ImageSource | null>(null);\n const [enlarged, setEnlarged] = useState(false);\n\n useEffect(() => {\n const container = containerRef.current;\n if (!container) return;\n const cleanups = new Map<HTMLImageElement, () => void>();\n\n function enhance() {\n if (!container) return;\n for (const image of container.querySelectorAll<HTMLImageElement>(imageSelector)) {\n if (image.tagName !== 'IMG' || cleanups.has(image) || image.hasAttribute('data-no-zoom') ||\n image.closest('a, button') || image.parentElement?.closest('[role=\"button\"]')) continue;\n const attributes = ['role', 'tabindex', 'aria-label', 'aria-haspopup', 'data-rt-image-zoom'];\n const previous = attributes.map((name) => image.getAttribute(name));\n image.setAttribute('role', 'button');\n image.setAttribute('tabindex', '0');\n image.setAttribute('aria-label', image.alt ? `${image.alt} 확대 보기` : '이미지 확대 보기');\n image.setAttribute('aria-haspopup', 'dialog');\n image.setAttribute('data-rt-image-zoom', '');\n\n const open = () => {\n triggerRef.current = image;\n setEnlarged(false);\n setSelected({\n src: image.dataset.fullsizeSrc || image.src,\n alt: image.alt,\n zoomWidth: Math.max(image.naturalWidth, image.getBoundingClientRect().width) * 2,\n });\n };\n const keydown = (event: KeyboardEvent) => {\n if (event.key !== 'Enter' && event.key !== ' ') return;\n event.preventDefault();\n open();\n };\n image.addEventListener('click', open);\n image.addEventListener('keydown', keydown);\n cleanups.set(image, () => {\n image.removeEventListener('click', open);\n image.removeEventListener('keydown', keydown);\n attributes.forEach((name, index) => {\n const value = previous[index];\n if (value === null || value === undefined) image.removeAttribute(name);\n else image.setAttribute(name, value);\n });\n });\n }\n }\n\n enhance();\n const observer = new MutationObserver(enhance);\n observer.observe(container, { childList: true, subtree: true });\n return () => {\n observer.disconnect();\n for (const cleanup of cleanups.values()) cleanup();\n };\n }, [children, imageSelector]);\n\n useEffect(() => {\n const dialog = dialogRef.current;\n if (!selected || !dialog) return;\n const overflow = document.body.style.overflow;\n document.body.style.overflow = 'hidden';\n dialog.showModal();\n closeButtonRef.current?.focus();\n // 배경(dialog 자체·여백 영역) 클릭 닫기는 마우스 보조 동작이다. 키보드는 Esc(cancel)·닫기 버튼으로 같은 결과를 얻는다.\n const closeOnBackdrop = (event: MouseEvent) => {\n const target = event.target as HTMLElement | null;\n if (target === dialog || target?.classList.contains('rt-cms-image-dialog__viewport')) setSelected(null);\n };\n dialog.addEventListener('click', closeOnBackdrop);\n return () => {\n dialog.removeEventListener('click', closeOnBackdrop);\n if (dialog.open) dialog.close();\n document.body.style.overflow = overflow;\n triggerRef.current?.focus({ preventScroll: true });\n };\n }, [selected]);\n\n return (\n <div ref={containerRef} data-rt-image-gallery=\"\" style={{ display: 'contents' }}>\n {children}\n {selected && createPortal(\n <dialog ref={dialogRef} className=\"rt-cms-image-dialog\" aria-label=\"이미지 확대 보기\"\n onClose={() => setSelected(null)}\n onCancel={() => setSelected(null)}>\n <div className=\"rt-cms-image-dialog__toolbar\">\n <button type=\"button\" aria-pressed={enlarged} onClick={() => setEnlarged(!enlarged)}>\n {enlarged ? '화면에 맞추기' : '2배 확대'}\n </button>\n <button ref={closeButtonRef} type=\"button\" onClick={() => setSelected(null)} aria-label=\"이미지 확대 닫기\">\n 닫기 <span aria-hidden=\"true\">×</span>\n </button>\n </div>\n <div className=\"rt-cms-image-dialog__viewport\" data-enlarged={enlarged}>\n <img src={selected.src} alt={selected.alt} referrerPolicy=\"no-referrer\"\n style={enlarged ? { width: selected.zoomWidth } : undefined} />\n </div>\n </dialog>, document.body,\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/blog-filter.ts","../src/post-public-url.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 seoOgImage?: string;\n seoOgImageAlt?: string;\n seoNofollow?: boolean;\n /** 서버 집계가 없으면 undefined. API 장애를 미승격(false)으로 바꾸지 않는다. */\n hubPromoted?: boolean;\n /** 공개 분류 API가 계산한 정규 경로. */\n path?: 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 makeCategoryHref(\"/blog\", \"namespaced\")(slug);\n}\n\n/**\n * 카테고리 링크 함수 — 목록·카테고리 허브·빵부스러기의 `categoryHref` 로 넘긴다.\n * `\"direct\"` 는 RootTale 표준(`/blog/{category}` 허브, 글은 `/blog/{category}/{slug}`),\n * `\"namespaced\"` 는 종전 `/blog/categories/{slug}`. 전체(`null`)는 언제나 목록.\n */\nexport function makeCategoryHref(\n basePath: string,\n categoryPath: \"namespaced\" | \"direct\",\n): (slug: string | null) => string {\n const base = basePath.replace(/\\/+$/, \"\") || \"\";\n const list = base || \"/\";\n return (slug) => {\n if (!slug) return list;\n const encoded = encodeURIComponent(slug);\n return categoryPath === \"direct\" ? `${base}/${encoded}` : `${base}/categories/${encoded}`;\n };\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 * 글의 공개 URL — ADR-0105 Amendment 1.\n *\n * 플랫폼이 저장한 정규 공개 경로(`post.path`, 공개 API `path`)가 있으면 그것을 쓴다.\n * 없으면(구 서버·물질화 전) 지금까지처럼 컬렉션 규칙(`resolvePostUrl`)이나 레거시\n * `blogBasePath` 로 계산한다. 어느 유형에도 속하지 않는 레거시 글은 사이트 설정의\n * `blogBasePath` 가 진실이라 저장 경로보다 우선한다(플랫폼 폴백은 `/blog` 고정).\n */\nimport { resolvePostUrl, type RoutablePost, type RouteCollection } from \"@roottale/cms-core\";\n\nfunction trimSlash(value: string): string {\n return value.replace(/\\/+$/, \"\");\n}\n\nexport interface PostPublicUrlOptions {\n readonly siteUrl: string;\n readonly collections: RouteCollection[] | undefined;\n readonly inCollectionMode: boolean;\n readonly blogBasePath: string;\n}\n\n/** 공개 API 글의 구조적 최소형 — 구 cms-client 에는 `path`·`modelKey` 가 없어도 된다. */\nexport interface PublicUrlPost extends RoutablePost {\n readonly path?: string | null;\n readonly modelKey?: string | null;\n}\n\n/** 소속(모델·컬렉션)이 있는 글의 저장 경로. 레거시 미소속 글은 사이트 설정을 따른다. */\nexport function storedPostPath(\n post: Pick<PublicUrlPost, \"path\" | \"collectionKey\" | \"modelKey\">,\n): string | null {\n if (typeof post.path !== \"string\" || !post.path.startsWith(\"/\")) return null;\n if (!post.collectionKey && !post.modelKey) return null;\n return post.path;\n}\n\n/** 사이트맵·피드·llms 가 함께 쓰는 글 URL. 저장 경로 → 컬렉션 규칙 → 레거시 순. */\nexport function postPublicUrl(\n post: PublicUrlPost,\n options: PostPublicUrlOptions,\n): string | null {\n const siteUrl = trimSlash(options.siteUrl);\n const stored = storedPostPath(post);\n if (stored) return `${siteUrl}${stored}`;\n if (options.inCollectionMode) {\n return resolvePostUrl(siteUrl, post, options.collections!);\n }\n return `${siteUrl}${trimSlash(options.blogBasePath)}/${post.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 * 카테고리 허브 주소 형식(레거시 단일 스트림용, `collections` 미사용 시).\n * `\"namespaced\"`(기본) = `{blogBasePath}/categories/{slug}`,\n * `\"direct\"` = `{blogBasePath}/{slug}` — RootTale 표준(`/blog/{category}/{slug}` 글 주소와\n * 짝). collection 모드는 각 컬렉션의 `categoryPath` 를 따른다.\n */\n categoryPath?: \"namespaced\" | \"direct\";\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":"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","names":["postsForCollection","resolvePostUrl","resolvePostCollection","resolvePostUrl","trimSlash"]}
|