@roottale/cms-renderer-next 0.29.0 → 0.31.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 +38 -0
- package/dist/routes.d.ts +10 -3
- package/dist/routes.js +44 -15
- package/dist/routes.js.map +1 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# @roottale/cms-renderer-next
|
|
2
2
|
|
|
3
|
+
## 0.31.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 3c8451a: collections: async resolver 지원 (ADR-0060 P2.1b)
|
|
8
|
+
|
|
9
|
+
route 팩토리(createSitemap/createFeedRoute/createLlmsTxtRoute/createRevalidateRoute)의
|
|
10
|
+
`collections` 가 배열뿐 아니라 **async resolver**(`() => Promise<RouteCollection[]>`)도
|
|
11
|
+
받는다. 사이트가 `@roottale/cms-client` `fetchCollections()` 로 admin 설정을 요청 시
|
|
12
|
+
가져와 그대로 넘길 수 있다 — collections SSoT 가 사이트 코드 상수에서 플랫폼 admin 으로.
|
|
13
|
+
resolver 가 throw 하면 fail-soft(레거시 단일 /blog). 기존 배열 사용은 그대로(하위호환).
|
|
14
|
+
새 export: `CollectionsInput`.
|
|
15
|
+
|
|
16
|
+
codex 리뷰(session 019ef6e4) 반영:
|
|
17
|
+
- `createRevalidateRoute`: 명시 `alsoRevalidate` 가 있으면 resolver 를 호출하지 않는다(불필요한 fetch 방지). resolver **실패** 시 스트림을 추측하지 않고 불변 경로(`/feed.xml`·`/sitemap.xml`·`/llms.txt`)만 갱신하며 응답에 `warning:"collections_resolve_failed"` 를 노출(under-invalidation 방지 + 플랫폼 재시도 신호). GET 팩토리(feed/sitemap/llms)는 always-200 철학상 실패를 레거시 /blog 로 흡수.
|
|
18
|
+
- **타입 주의**: `RootTaleSiteConfig.collections`/`RevalidateRouteConfig.collections` 가 `RouteCollection[]` → `CollectionsInput`(배열 | 함수)로 넓어졌다. `config.collections?.map(...)` 처럼 _읽는_ 소비자는 TS 에러가 날 수 있다(이 필드는 write-only config 입력). 런타임 하위호환은 유지.
|
|
19
|
+
- 권장: resolver 는 사이트 경계에서 캐시(예: Next `fetch(url, { next: { revalidate: 300 } })`) — 매 요청 fetch 방지.
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- Updated dependencies [b68ed00]
|
|
24
|
+
- @roottale/cms-client@0.31.0
|
|
25
|
+
|
|
26
|
+
## 0.30.0
|
|
27
|
+
|
|
28
|
+
### Patch Changes
|
|
29
|
+
|
|
30
|
+
- 8de6895: collections: catch-all 스트림 (빈 `categories`) 지원 (ADR-0060)
|
|
31
|
+
|
|
32
|
+
`RouteCollection.categories` 가 빈 배열이면 **catch-all** — 앞선 스트림이 잡지 않은 글을
|
|
33
|
+
전부 흡수한다. "공지=[notice] 먼저, 블로그=[] catch-all 뒤" 처럼 _블로그 카테고리가
|
|
34
|
+
개방형(admin이 계속 추가)인 사이트_ 가 카테고리를 일일이 열거하지 않고 공지+블로그를
|
|
35
|
+
분리할 수 있다(kjmtax 등 가장 흔한 패턴). `validateCollections` 가 catch-all 뒤의 도달
|
|
36
|
+
불가 스트림을 경고한다. 선언 순서 = first-wins 라 catch-all 은 맨 뒤에 둔다.
|
|
37
|
+
|
|
38
|
+
- Updated dependencies [8de6895]
|
|
39
|
+
- @roottale/cms-core@0.30.0
|
|
40
|
+
|
|
3
41
|
## 0.29.0
|
|
4
42
|
|
|
5
43
|
### Minor Changes
|
package/dist/routes.d.ts
CHANGED
|
@@ -38,9 +38,15 @@ interface RootTaleSiteConfig {
|
|
|
38
38
|
* llms.txt 가 글을 소속 스트림 basePath 로 라우팅하고, 어느 스트림에도 안 속한 글은
|
|
39
39
|
* 제외한다. 비우거나 미전달 = 레거시 단일 `blogBasePath`(/blog) 동작(하위호환).
|
|
40
40
|
* 선언 순서 = 우선순위(first-wins).
|
|
41
|
+
*
|
|
42
|
+
* 배열 또는 **async resolver**(`() => Promise<RouteCollection[]>`)를 받는다 — 후자는
|
|
43
|
+
* 사이트가 `@roottale/cms-client` `fetchCollections()` 로 admin 설정을 가져올 때 쓴다.
|
|
44
|
+
* resolver 가 throw 하면 fail-soft(레거시 단일 /blog).
|
|
41
45
|
*/
|
|
42
|
-
collections?:
|
|
46
|
+
collections?: CollectionsInput;
|
|
43
47
|
}
|
|
48
|
+
/** collections 입력 — 정적 배열 또는 async resolver(admin 설정 fetch 용). */
|
|
49
|
+
type CollectionsInput = RouteCollection[] | (() => RouteCollection[] | Promise<RouteCollection[]>);
|
|
44
50
|
/**
|
|
45
51
|
* `app/feed.xml/route.ts` 용 GET 핸들러. RSS 2.0.
|
|
46
52
|
*
|
|
@@ -123,8 +129,9 @@ interface RevalidateRouteConfig {
|
|
|
123
129
|
/**
|
|
124
130
|
* 다중 스트림 (ADR-0060). 지정 시 `alsoRevalidate` 기본값을 각 스트림 basePath
|
|
125
131
|
* (+ `archives:true` 면 `${basePath}/categories`)로 파생. 미지정 = 레거시 단일 blog.
|
|
132
|
+
* 배열 또는 async resolver(`fetchCollections()`) — resolver 실패 시 레거시로 fail-soft.
|
|
126
133
|
*/
|
|
127
|
-
collections?:
|
|
134
|
+
collections?: CollectionsInput;
|
|
128
135
|
/**
|
|
129
136
|
* 모든 글/분류/미디어 변경에 대해 *항상 함께* revalidate 할 파생 경로.
|
|
130
137
|
* 피드·사이트맵·블로그 인덱스처럼 "글 목록"에 의존하는 경로 — admin 변경이
|
|
@@ -254,4 +261,4 @@ declare function createPostOgImage(config: OgImageRouteConfig, deps: {
|
|
|
254
261
|
};
|
|
255
262
|
}) => Promise<Response>;
|
|
256
263
|
|
|
257
|
-
export { type FleetInfoRouteConfig, type ImageResponseLike, type LlmsTxtLink, type LlmsTxtRouteConfig, type LlmsTxtSection, OG_IMAGE_CONTENT_TYPE, OG_IMAGE_SIZE, type OgImageRouteConfig, type RevalidateRouteConfig, type RootTaleSiteConfig, type SitemapEntry, createFeedRoute, createFleetInfoRoute, createLlmsTxtRoute, createPostOgImage, createRevalidateRoute, createSitemap, postRedirectPath };
|
|
264
|
+
export { type CollectionsInput, type FleetInfoRouteConfig, type ImageResponseLike, type LlmsTxtLink, type LlmsTxtRouteConfig, type LlmsTxtSection, OG_IMAGE_CONTENT_TYPE, OG_IMAGE_SIZE, type OgImageRouteConfig, type RevalidateRouteConfig, type RootTaleSiteConfig, type SitemapEntry, createFeedRoute, createFleetInfoRoute, createLlmsTxtRoute, createPostOgImage, createRevalidateRoute, createSitemap, postRedirectPath };
|
package/dist/routes.js
CHANGED
|
@@ -20,6 +20,23 @@ import {
|
|
|
20
20
|
resolvePostUrl
|
|
21
21
|
} from "@roottale/cms-core";
|
|
22
22
|
import { resolvePostCollection as resolvePostCollection2, resolvePostUrl as resolvePostUrl2 } from "@roottale/cms-core";
|
|
23
|
+
async function resolveCollectionsResult(input) {
|
|
24
|
+
if (typeof input === "function") {
|
|
25
|
+
try {
|
|
26
|
+
return { collections: await input(), failed: false };
|
|
27
|
+
} catch (err) {
|
|
28
|
+
console.error(
|
|
29
|
+
"[@roottale/cms-renderer-next/routes] collections resolver \uC2E4\uD328:",
|
|
30
|
+
err
|
|
31
|
+
);
|
|
32
|
+
return { collections: void 0, failed: true };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { collections: input, failed: false };
|
|
36
|
+
}
|
|
37
|
+
async function resolveCollections(input) {
|
|
38
|
+
return (await resolveCollectionsResult(input)).collections;
|
|
39
|
+
}
|
|
23
40
|
function trimSlash(u) {
|
|
24
41
|
return u.replace(/\/+$/, "");
|
|
25
42
|
}
|
|
@@ -40,7 +57,7 @@ async function safeFetchPosts(config, limit) {
|
|
|
40
57
|
function createFeedRoute(config) {
|
|
41
58
|
return async function GET() {
|
|
42
59
|
const siteUrl = trimSlash(config.siteUrl);
|
|
43
|
-
const collections = config.collections;
|
|
60
|
+
const collections = await resolveCollections(config.collections);
|
|
44
61
|
const allPosts = await safeFetchPosts(config, 50);
|
|
45
62
|
const inCollectionMode = isInCollectionMode(collections);
|
|
46
63
|
const blogBase = config.blogBasePath ?? "/blog";
|
|
@@ -70,7 +87,7 @@ function createSitemap(config, staticRoutes = []) {
|
|
|
70
87
|
return async function sitemap() {
|
|
71
88
|
const siteUrl = trimSlash(config.siteUrl);
|
|
72
89
|
const blogBase = config.blogBasePath ?? "/blog";
|
|
73
|
-
const collections = config.collections;
|
|
90
|
+
const collections = await resolveCollections(config.collections);
|
|
74
91
|
const posts = await safeFetchPosts(config, 200);
|
|
75
92
|
if (isInCollectionMode(collections)) {
|
|
76
93
|
const postEntries2 = posts.flatMap((p) => {
|
|
@@ -132,7 +149,7 @@ function createLlmsTxtRoute(config) {
|
|
|
132
149
|
return async function GET() {
|
|
133
150
|
const siteUrl = trimSlash(config.siteUrl);
|
|
134
151
|
const blogBase = config.blogBasePath ?? "/blog";
|
|
135
|
-
const collections = config.collections;
|
|
152
|
+
const collections = await resolveCollections(config.collections);
|
|
136
153
|
const posts = await safeFetchPosts(config, 100);
|
|
137
154
|
const lines = [`# ${config.title}`];
|
|
138
155
|
if (config.description) {
|
|
@@ -177,16 +194,6 @@ function pathsFromBody(rawBody) {
|
|
|
177
194
|
}
|
|
178
195
|
function createRevalidateRoute(config) {
|
|
179
196
|
const blogBase = config.blogBasePath ?? "/blog";
|
|
180
|
-
const derivedFromCollections = isInCollectionMode(config.collections) ? config.collections.flatMap(
|
|
181
|
-
(c) => c.archives ? [c.basePath, `${c.basePath}/categories`] : [c.basePath]
|
|
182
|
-
) : [blogBase, `${blogBase}/categories`];
|
|
183
|
-
const alsoRevalidate = config.alsoRevalidate ?? [
|
|
184
|
-
"/feed.xml",
|
|
185
|
-
"/sitemap.xml",
|
|
186
|
-
// 글 변경 시 AI 크롤러용 llms.txt 인덱스도 함께 갱신 (AEO/GEO).
|
|
187
|
-
"/llms.txt",
|
|
188
|
-
...derivedFromCollections
|
|
189
|
-
];
|
|
190
197
|
return async function POST(req) {
|
|
191
198
|
const rawBody = await req.text();
|
|
192
199
|
const result = await verifyRootTaleWebhook({
|
|
@@ -199,6 +206,27 @@ function createRevalidateRoute(config) {
|
|
|
199
206
|
if (!result.ok) {
|
|
200
207
|
return Response.json({ ok: false, reason: result.reason }, { status: 401 });
|
|
201
208
|
}
|
|
209
|
+
const INVARIANTS = ["/feed.xml", "/sitemap.xml", "/llms.txt"];
|
|
210
|
+
let alsoRevalidate;
|
|
211
|
+
let warning;
|
|
212
|
+
if (config.alsoRevalidate) {
|
|
213
|
+
alsoRevalidate = config.alsoRevalidate;
|
|
214
|
+
} else {
|
|
215
|
+
const { collections, failed } = await resolveCollectionsResult(config.collections);
|
|
216
|
+
if (failed) {
|
|
217
|
+
alsoRevalidate = INVARIANTS;
|
|
218
|
+
warning = "collections_resolve_failed";
|
|
219
|
+
} else if (isInCollectionMode(collections)) {
|
|
220
|
+
alsoRevalidate = [
|
|
221
|
+
...INVARIANTS,
|
|
222
|
+
...collections.flatMap(
|
|
223
|
+
(c) => c.archives ? [c.basePath, `${c.basePath}/categories`] : [c.basePath]
|
|
224
|
+
)
|
|
225
|
+
];
|
|
226
|
+
} else {
|
|
227
|
+
alsoRevalidate = [...INVARIANTS, blogBase, `${blogBase}/categories`];
|
|
228
|
+
}
|
|
229
|
+
}
|
|
202
230
|
const paths = [.../* @__PURE__ */ new Set([...pathsFromBody(rawBody), ...alsoRevalidate])];
|
|
203
231
|
for (const p of paths) {
|
|
204
232
|
await config.revalidate(p);
|
|
@@ -207,7 +235,8 @@ function createRevalidateRoute(config) {
|
|
|
207
235
|
ok: true,
|
|
208
236
|
event: result.event,
|
|
209
237
|
deliveryId: result.deliveryId,
|
|
210
|
-
revalidated: paths
|
|
238
|
+
revalidated: paths,
|
|
239
|
+
...warning ? { warning } : {}
|
|
211
240
|
});
|
|
212
241
|
};
|
|
213
242
|
}
|
|
@@ -228,7 +257,7 @@ function createFleetInfoRoute(config = {}) {
|
|
|
228
257
|
site: config.site ?? env.VERCEL_PROJECT_PRODUCTION_URL ?? null,
|
|
229
258
|
framework: "next",
|
|
230
259
|
cms_client: CMS_CLIENT_VERSION,
|
|
231
|
-
cms_renderer: true ? "0.
|
|
260
|
+
cms_renderer: true ? "0.31.0" : "dev",
|
|
232
261
|
build_sha: env.VERCEL_GIT_COMMIT_SHA?.slice(0, 7) ?? null
|
|
233
262
|
},
|
|
234
263
|
{ headers: { "cache-control": "public, max-age=300" } }
|
package/dist/routes.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/routes.ts"],"sourcesContent":["/**\n * `@roottale/cms-renderer-next/routes` — Next.js App Router 통합 라우트 팩토리.\n *\n * 외부 고객사 사이트가 매번 직접 작성하던 공통 라우트(RSS feed / sitemap /\n * revalidate webhook)를 1줄 팩토리로 표준화. 블로그 조회와 *동일한* 단일\n * customer API key(`ROOTTALE_API_KEY`)를 사용한다.\n *\n * 설계 원칙:\n * - feed/sitemap 은 `fetchPosts` 실패를 swallow → 라우트가 *항상 200* (빌드 시\n * 정적 생성이 throw 해서 404 나는 사고 방지).\n * - revalidate 는 admin(mysite.roottale.com)에서 글/카테고리/미디어 변경 시 보내는\n * 서명 webhook 을 검증하고, 본문 paths + 파생 경로(feed·sitemap·블로그 인덱스)를\n * 모두 revalidate → 각 사이트에 자동 반영.\n * - `next` 에 직접 의존하지 않음: revalidate 콜백(`revalidatePath`)을 주입받는다.\n *\n * 사용 예는 roottale-docs `services/external-site-integration.md` 참조.\n */\n\nimport {\n CMS_CLIENT_VERSION,\n fetchPost,\n fetchPosts,\n generateRssXml,\n rssItemsFromPosts,\n type CmsPostContent,\n} from \"@roottale/cms-client/server\";\nimport {\n verifyRootTaleWebhook,\n type VerifyRootTaleWebhookResult,\n} from \"@roottale/cms-client/webhook\";\nimport {\n type RouteCollection,\n isInCollectionMode,\n resolvePostCollection,\n resolvePostUrl,\n} from \"@roottale/cms-core\";\nimport { collectCategories, collectCollectionCategories } from \"./blog-filter.js\";\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 collections?: RouteCollection[];\n}\n\nfunction trimSlash(u: string): string {\n return u.replace(/\\/+$/, \"\");\n}\n\nasync 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\n/**\n * `app/feed.xml/route.ts` 용 GET 핸들러. RSS 2.0.\n *\n * ```ts\n * import { createFeedRoute } from \"@roottale/cms-renderer-next/routes\";\n * export const dynamic = \"force-dynamic\";\n * export const GET = createFeedRoute({\n * apiKey: process.env.ROOTTALE_API_KEY!,\n * siteUrl: \"https://example.com\",\n * title: \"Example 블로그\",\n * description: \"...\",\n * });\n * ```\n */\nexport function createFeedRoute(\n config: RootTaleSiteConfig,\n): () => Promise<Response> {\n return async function GET(): Promise<Response> {\n const siteUrl = trimSlash(config.siteUrl);\n const collections = config.collections;\n const allPosts = await safeFetchPosts(config, 50);\n\n // collection 모드: /feed.xml = `feed:true` 스트림들의 합집합(aggregate). RSS\n // 리더는 안정적 사이트 피드를 기대하므로 스트림별 분리 대신 통합 피드(codex).\n // 각 글 링크는 소속 스트림 basePath 로. 레거시: 단일 blogBasePath.\n const inCollectionMode = isInCollectionMode(collections);\n const blogBase = config.blogBasePath ?? \"/blog\";\n const posts = inCollectionMode\n ? allPosts.filter((p) => {\n const c = resolvePostCollection(p, collections!);\n return c ? c.feed === true : false;\n })\n : allPosts;\n\n const xml = generateRssXml({\n title: config.title,\n link: inCollectionMode ? siteUrl : `${siteUrl}${blogBase}`,\n feedUrl: `${siteUrl}/feed.xml`,\n description: config.description,\n language: config.language,\n items: rssItemsFromPosts(posts, {\n postUrl: (p) =>\n inCollectionMode\n ? (resolvePostUrl(siteUrl, p, collections!) ?? `${siteUrl}${blogBase}/${p.slug}`)\n : `${siteUrl}${blogBase}/${p.slug}`,\n }),\n });\n return new Response(xml, {\n headers: {\n \"content-type\": \"application/rss+xml; charset=utf-8\",\n \"cache-control\": \"public, max-age=1800, s-maxage=1800\",\n },\n });\n };\n}\n\n/** Next `MetadataRoute.Sitemap[number]` 와 구조 호환(타입 결합 회피). */\nexport interface SitemapEntry {\n url: string;\n lastModified?: string | Date;\n changeFrequency?:\n | \"always\"\n | \"hourly\"\n | \"daily\"\n | \"weekly\"\n | \"monthly\"\n | \"yearly\"\n | \"never\";\n priority?: number;\n}\n\n/**\n * `app/sitemap.ts` default export. 정적 경로 + 글 경로를 합친다.\n *\n * ```ts\n * import { createSitemap } from \"@roottale/cms-renderer-next/routes\";\n * export default createSitemap(\n * { apiKey: process.env.ROOTTALE_API_KEY!, siteUrl: \"https://example.com\", title: \"...\" },\n * [{ url: \"https://example.com\", changeFrequency: \"weekly\", priority: 1 }],\n * );\n * ```\n */\nexport function createSitemap(\n config: RootTaleSiteConfig,\n staticRoutes: SitemapEntry[] = [],\n): () => Promise<SitemapEntry[]> {\n return async function sitemap(): Promise<SitemapEntry[]> {\n const siteUrl = trimSlash(config.siteUrl);\n const blogBase = config.blogBasePath ?? \"/blog\";\n const collections = config.collections;\n const posts = await safeFetchPosts(config, 200);\n\n if (isInCollectionMode(collections)) {\n // 글마다 소속 스트림 basePath 로(공지→/notice, 블로그→/blog). 어느 스트림에도\n // 안 속한 글(상세 라우트 없음)은 제외. 아카이브는 `archives:true` 스트림만.\n const postEntries: SitemapEntry[] = posts.flatMap((p) => {\n const url = resolvePostUrl(siteUrl, p, collections!);\n if (!url) return [];\n return [\n {\n url,\n lastModified: p.updatedAt ?? p.publishedAt ?? undefined,\n changeFrequency: \"weekly\" as const,\n priority: 0.6,\n },\n ];\n });\n\n const categoryEntries: SitemapEntry[] = collections!\n .filter((c) => c.archives)\n .flatMap((c) => {\n const cats = collectCollectionCategories(posts, collections!, c.key);\n if (!cats.length) return [];\n const base = `${siteUrl}${trimSlash(c.basePath)}`;\n return [\n { url: `${base}/categories`, changeFrequency: \"weekly\" as const, priority: 0.5 },\n ...cats.map((category) => ({\n url: `${base}/categories/${category.slug}`,\n changeFrequency: \"weekly\" as const,\n priority: 0.5,\n })),\n ];\n });\n\n return [...staticRoutes, ...postEntries, ...categoryEntries];\n }\n\n // 레거시: 단일 blogBasePath.\n const postEntries: SitemapEntry[] = posts.map((p) => ({\n url: `${siteUrl}${blogBase}/${p.slug}`,\n lastModified: p.updatedAt ?? p.publishedAt ?? undefined,\n changeFrequency: \"weekly\",\n priority: 0.6,\n }));\n const categories = collectCategories(posts);\n const categoryEntries: SitemapEntry[] = categories.length\n ? [\n {\n url: `${siteUrl}${blogBase}/categories`,\n changeFrequency: \"weekly\",\n priority: 0.5,\n },\n ...categories.map((category) => ({\n url: `${siteUrl}${blogBase}/categories/${category.slug}`,\n changeFrequency: \"weekly\" as const,\n priority: 0.5,\n })),\n ]\n : [];\n return [...staticRoutes, ...postEntries, ...categoryEntries];\n };\n}\n\n// ── llms.txt (AEO/GEO — AI 검색·생성엔진용 마크다운 인덱스) ──────────────────\n\n/** llms.txt 정적 섹션의 링크 1개. */\nexport interface LlmsTxtLink {\n title: string;\n url: string;\n /** 링크 뒤 \": {note}\" 로 붙는 한 줄 설명 (선택). */\n note?: string;\n}\n\n/** llms.txt 의 정적 페이지 링크 그룹 — 블로그 목록 앞에 출력. */\nexport interface LlmsTxtSection {\n title: string;\n links: LlmsTxtLink[];\n}\n\nexport type LlmsTxtRouteConfig = RootTaleSiteConfig & {\n /** (선택) 서비스 소개 등 정적 페이지 링크 그룹. */\n sections?: LlmsTxtSection[];\n};\n\n/** 마크다운 링크 텍스트를 깨뜨리는 문자([, ], (, )) 제거. */\nfunction mdLinkSafe(text: string): string {\n return text.replace(/[[\\]()]/g, \"\").trim();\n}\n\n/** excerpt 를 1줄로 — 개행·연속 공백을 공백 1개로 정리 후 200자 절단. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, \" \").trim().slice(0, 200);\n}\n\n/**\n * `app/llms.txt/route.ts` 용 GET 핸들러 — AEO/GEO(AI 검색·생성엔진 최적화).\n *\n * AI 크롤러(ChatGPT/Claude/Perplexity 등)가 사이트 구조·콘텐츠를 빠르게\n * 파악하도록 llms.txt 스펙(https://llmstxt.org)의 마크다운 인덱스를 제공한다.\n * 발행 글 최대 100개를 \"## 블로그\" 섹션으로 자동 포함 — feed/sitemap 처럼\n * fetch 실패도 swallow 하여 *항상 200* (헤더 부분만 출력).\n *\n * ```ts\n * import { createLlmsTxtRoute } from \"@roottale/cms-renderer-next/routes\";\n * export const dynamic = \"force-dynamic\";\n * export const GET = createLlmsTxtRoute({\n * apiKey: process.env.ROOTTALE_API_KEY!,\n * siteUrl: \"https://example.com\",\n * title: \"Example\",\n * description: \"...\",\n * sections: [{ title: \"주요 페이지\", links: [{ title: \"서비스\", url: \"https://example.com/services\" }] }],\n * });\n * ```\n */\nexport function createLlmsTxtRoute(\n config: LlmsTxtRouteConfig,\n): () => Promise<Response> {\n return async function GET(): Promise<Response> {\n const siteUrl = trimSlash(config.siteUrl);\n const blogBase = config.blogBasePath ?? \"/blog\";\n const collections = config.collections;\n const posts = await safeFetchPosts(config, 100);\n\n const lines: string[] = [`# ${config.title}`];\n if (config.description) {\n lines.push(\"\", `> ${oneLine(config.description)}`);\n }\n for (const section of config.sections ?? []) {\n lines.push(\"\", `## ${section.title}`);\n for (const link of section.links) {\n const note = link.note ? `: ${oneLine(link.note)}` : \"\";\n lines.push(`- [${mdLinkSafe(link.title)}](${link.url})${note}`);\n }\n }\n if (posts.length > 0) {\n const inCollectionMode = isInCollectionMode(collections);\n // 레거시 헤딩(\"## 블로그\")은 하위호환 유지, 다중 스트림은 \"## 콘텐츠\".\n lines.push(\"\", inCollectionMode ? \"## 콘텐츠\" : \"## 블로그\");\n for (const p of posts) {\n // collection 모드: 소속 스트림 URL, 미소속 글 제외. 레거시: 단일 blogBase.\n const url = inCollectionMode\n ? resolvePostUrl(siteUrl, p, collections!)\n : `${siteUrl}${blogBase}/${p.slug}`;\n if (!url) continue;\n const excerpt = p.excerpt ? `: ${oneLine(p.excerpt)}` : \"\";\n lines.push(`- [${mdLinkSafe(p.title)}](${url})${excerpt}`);\n }\n }\n lines.push(\"\");\n\n return new Response(lines.join(\"\\n\"), {\n headers: {\n \"content-type\": \"text/plain; charset=utf-8\",\n // feed 와 동일한 30분 캐시 — webhook alsoRevalidate 로 즉시 갱신됨.\n \"cache-control\": \"public, max-age=1800, s-maxage=1800\",\n },\n });\n };\n}\n\nexport interface RevalidateRouteConfig {\n /** `rtlk_cust_*` — 블로그 조회/webhook 검증 공통 키. */\n apiKey: string;\n apiBase?: string;\n /** Next 의 `revalidatePath` (또는 동등 콜백) 주입. */\n revalidate: (path: string) => void | Promise<void>;\n /** 블로그 base path. 기본 \"/blog\". */\n blogBasePath?: string;\n /**\n * 다중 스트림 (ADR-0060). 지정 시 `alsoRevalidate` 기본값을 각 스트림 basePath\n * (+ `archives:true` 면 `${basePath}/categories`)로 파생. 미지정 = 레거시 단일 blog.\n */\n collections?: RouteCollection[];\n /**\n * 모든 글/분류/미디어 변경에 대해 *항상 함께* revalidate 할 파생 경로.\n * 피드·사이트맵·블로그 인덱스처럼 \"글 목록\"에 의존하는 경로 — admin 변경이\n * 자동 반영되려면 필수. 명시 시 collections 파생을 덮어쓴다.\n * 기본(레거시): [\"/feed.xml\", \"/sitemap.xml\", \"/llms.txt\", blogBase, `${blogBase}/categories`].\n */\n alsoRevalidate?: string[];\n /** (옵션) jti replay 방지 hook (cms-client verify 로 전달). */\n consumeJti?: (jti: string, exp: number) => Promise<\"new\" | \"seen\">;\n}\n\n/** webhook 본문에서 paths[] 추출(서명 검증 후 호출 — body_hash 가 binding). */\nfunction pathsFromBody(rawBody: string): string[] {\n try {\n const body = JSON.parse(rawBody) as { paths?: unknown };\n if (Array.isArray(body.paths)) {\n return body.paths.filter((p): p is string => typeof p === \"string\");\n }\n } catch {\n /* 비-JSON 본문 — 파생 경로만 revalidate */\n }\n return [];\n}\n\n/**\n * `app/api/revalidate/route.ts` 용 POST 핸들러.\n *\n * admin(mysite.roottale.com)이 글/카테고리/미디어 변경 시 보내는 서명 webhook 을\n * 검증하고, 본문 paths + 파생 경로(feed·sitemap·블로그 인덱스)를 revalidate.\n *\n * ```ts\n * import { revalidatePath } from \"next/cache\";\n * import { createRevalidateRoute } from \"@roottale/cms-renderer-next/routes\";\n * export const POST = createRevalidateRoute({\n * apiKey: process.env.ROOTTALE_API_KEY!,\n * revalidate: revalidatePath,\n * });\n * ```\n */\nexport function createRevalidateRoute(\n config: RevalidateRouteConfig,\n): (req: Request) => Promise<Response> {\n const blogBase = config.blogBasePath ?? \"/blog\";\n // 글 목록 의존 경로는 항상 함께 — collection 모드면 각 스트림 basePath(+아카이브).\n const derivedFromCollections = isInCollectionMode(config.collections)\n ? config.collections!.flatMap((c) =>\n c.archives ? [c.basePath, `${c.basePath}/categories`] : [c.basePath],\n )\n : [blogBase, `${blogBase}/categories`];\n const alsoRevalidate = config.alsoRevalidate ?? [\n \"/feed.xml\",\n \"/sitemap.xml\",\n // 글 변경 시 AI 크롤러용 llms.txt 인덱스도 함께 갱신 (AEO/GEO).\n \"/llms.txt\",\n ...derivedFromCollections,\n ];\n\n return async function POST(req: Request): Promise<Response> {\n const rawBody = await req.text();\n const result: VerifyRootTaleWebhookResult = await verifyRootTaleWebhook({\n rawBody,\n headers: req.headers,\n apiKey: config.apiKey,\n apiBase: config.apiBase,\n consumeJti: config.consumeJti,\n });\n if (!result.ok) {\n return Response.json({ ok: false, reason: result.reason }, { status: 401 });\n }\n\n // 본문 paths + 파생 경로(항상) — 중복 제거.\n const paths = [...new Set([...pathsFromBody(rawBody), ...alsoRevalidate])];\n for (const p of paths) {\n await config.revalidate(p);\n }\n\n return Response.json({\n ok: true,\n event: result.event,\n deliveryId: result.deliveryId,\n revalidated: paths,\n });\n };\n}\n\n// ── slug 변경 301 (WP permalink 패리티) ─────────────────────────────────────\n\n/**\n * 옛 slug → 현재 slug 301 redirect 판단 (순수 함수, `next` 비의존).\n *\n * Public API 는 slug 변경 후에도 옛 slug 로 글을 찾아 *현재* slug 를 응답한다\n * (post_slug_history fallback). 요청 slug 와 응답 slug 가 다르면 새 경로를\n * 반환 — 페이지에서 `permanentRedirect()` 로 301 처리한다:\n *\n * ```ts\n * import { permanentRedirect, notFound } from \"next/navigation\";\n * import { postRedirectPath } from \"@roottale/cms-renderer-next/routes\";\n *\n * const post = await getPost(slug);\n * if (!post) notFound();\n * const redirect = postRedirectPath(post, slug);\n * if (redirect) permanentRedirect(redirect);\n * ```\n *\n * Next App Router 의 `params` 는 percent-encoding 된 채 들어올 수 있어\n * (한글 slug) 디코딩 후 비교한다. redirect 가 불필요하면 null.\n */\nexport function postRedirectPath(\n post: { slug: string },\n requestedSlug: string,\n blogBasePath = \"/blog\",\n): string | null {\n let requested = requestedSlug;\n try {\n requested = decodeURIComponent(requestedSlug);\n } catch {\n /* 잘못된 escape — 원본 그대로 비교 */\n }\n if (post.slug === requested) return null;\n // blogBasePath \"/\" 또는 \"\" = 루트 배치 → \"/{slug}\".\n return `${trimSlash(blogBasePath)}/${encodeURIComponent(post.slug)}`;\n}\n\n// ── fleet well-known (사이트 버전 노출 컨벤션) ───────────────────────────────\n\ndeclare const __CMS_RENDERER_VERSION__: string | undefined;\n\nexport interface FleetInfoRouteConfig {\n /** 사이트 식별 라벨 (선택 — 기본 Vercel project URL 또는 null). */\n site?: string;\n}\n\n/**\n * `GET /.well-known/roottale.json` — fleet 가시성 컨벤션 라우트.\n *\n * 플랫폼 internal fleet API 의 `?probe=1` 이 본 엔드포인트를 읽어 \"배포된\"\n * 패키지 버전을 트래픽 없이도 확인한다 (런타임 check-in 의 보조 신호).\n * 노출은 의도적으로 최소: 패키지 버전 + 짧은 build sha — 비밀 없음.\n *\n * 사용 (Next App Router):\n * ```ts\n * // app/.well-known/roottale.json/route.ts\n * import { createFleetInfoRoute } from \"@roottale/cms-renderer-next/routes\";\n * export const GET = createFleetInfoRoute();\n * ```\n */\nexport function createFleetInfoRoute(\n config: FleetInfoRouteConfig = {},\n): () => Promise<Response> {\n // dts 빌드는 node types 없이 돈다 — process 는 globalThis 경유로 안전 접근.\n const env =\n (globalThis as { process?: { env?: Record<string, string | undefined> } })\n .process?.env ?? {};\n return async function GET(): Promise<Response> {\n return Response.json(\n {\n site: config.site ?? env.VERCEL_PROJECT_PRODUCTION_URL ?? null,\n framework: \"next\",\n cms_client: CMS_CLIENT_VERSION,\n cms_renderer:\n typeof __CMS_RENDERER_VERSION__ === \"string\"\n ? __CMS_RENDERER_VERSION__\n : \"dev\",\n build_sha: env.VERCEL_GIT_COMMIT_SHA?.slice(0, 7) ?? null,\n },\n { headers: { \"cache-control\": \"public, max-age=300\" } },\n );\n };\n}\n\n// ── 동적 OG 이미지 (글별 1200×630) ──────────────────────────────────────────\n\n/** Next `opengraph-image` export 컨벤션과 호환되는 크기 상수. */\nexport const OG_IMAGE_SIZE = { width: 1200, height: 630 } as const;\nexport const OG_IMAGE_CONTENT_TYPE = \"image/png\";\n\nexport interface OgImageRouteConfig extends RootTaleSiteConfig {\n /** 배경색. 기본 진네이비 그라데이션 베이스. */\n backgroundColor?: string;\n /** 본문 텍스트 색. 기본 흰색. */\n foregroundColor?: string;\n /** 상단 액센트 바 + 브랜드 라벨 색. 기본 하늘색. */\n accentColor?: string;\n /** 하단 브랜드 라벨. 기본 = `title`. */\n brandLabel?: string;\n}\n\n/** `next/og` 의 ImageResponse 와 구조 호환(직접 의존 회피 — revalidate 콜백 주입과 동일 원칙). */\nexport interface ImageResponseLike {\n new (\n element: unknown,\n options?: {\n width?: number;\n height?: number;\n fonts?: { name: string; data: ArrayBuffer; weight?: number; style?: \"normal\" }[];\n },\n ): Response;\n}\n\n/**\n * 제목에 쓰인 글자만 부분셋(subset)으로 받아 한글을 렌더 — satori 기본 폰트는\n * 한글 글리프가 없다. 구형 UA 로 css2 를 호출하면 TTF(satori 호환) URL 을 준다.\n * 실패 시 null — 폰트 없이도 이미지는 생성(라틴 fallback).\n */\nasync function loadKoreanFontSubset(text: string): Promise<ArrayBuffer | null> {\n try {\n const unique = Array.from(new Set(Array.from(text))).join(\"\");\n const cssUrl = `https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@700&text=${encodeURIComponent(unique)}`;\n const cssRes = await fetch(cssUrl, {\n headers: { \"user-agent\": \"Mozilla/5.0 (Windows NT 6.1; rv:10.0)\" },\n });\n if (!cssRes.ok) return null;\n const css = await cssRes.text();\n const match = css.match(\n /src:\\s*url\\(([^)]+)\\)\\s*format\\(['\"](?:truetype|opentype)['\"]\\)/,\n );\n if (!match?.[1]) return null;\n const fontRes = await fetch(match[1]);\n if (!fontRes.ok) return null;\n return await fontRes.arrayBuffer();\n } catch (err) {\n console.error(\"[@roottale/cms-renderer-next/routes] OG 폰트 로딩 실패:\", err);\n return null;\n }\n}\n\n/**\n * `app/blog/[slug]/opengraph-image.tsx` 용 동적 OG 이미지 (1200×630).\n *\n * `next/og` 의 ImageResponse 를 주입받는다 — 본 패키지는 `next` 에 직접\n * 의존하지 않는다. 글이 없거나 fetch 실패면 사이트 제목만으로 fail-soft 렌더\n * (소셜 크롤러에 빈 카드 대신 브랜드 카드).\n *\n * ```tsx\n * // app/blog/[slug]/opengraph-image.tsx\n * import { ImageResponse } from \"next/og\";\n * import {\n * createPostOgImage,\n * OG_IMAGE_SIZE,\n * OG_IMAGE_CONTENT_TYPE,\n * } from \"@roottale/cms-renderer-next/routes\";\n * import { roottale } from \"@/lib/roottale\"; // RootTaleSiteConfig\n *\n * export const size = OG_IMAGE_SIZE;\n * export const contentType = OG_IMAGE_CONTENT_TYPE;\n * export default createPostOgImage(roottale, { ImageResponse });\n * ```\n */\nexport function createPostOgImage(\n config: OgImageRouteConfig,\n deps: { ImageResponse: ImageResponseLike },\n): (props: { params: Promise<{ slug: string }> | { slug: string } }) => Promise<Response> {\n const background = config.backgroundColor ?? \"#10172a\";\n const foreground = config.foregroundColor ?? \"#f8fafc\";\n const accent = config.accentColor ?? \"#38bdf8\";\n const brand = config.brandLabel ?? config.title;\n\n return async function OgImage(props): Promise<Response> {\n const { slug } = await props.params;\n\n let title = config.title;\n let dateLabel: string | null = null;\n try {\n const post = await fetchPost({\n apiKey: config.apiKey,\n baseUrl: config.apiBase,\n slugOrId: slug,\n });\n if (post) {\n title = post.title;\n dateLabel = new Intl.DateTimeFormat(\"ko-KR\", { dateStyle: \"long\" }).format(\n new Date(post.publishedAt),\n );\n }\n } catch (err) {\n console.error(\"[@roottale/cms-renderer-next/routes] OG fetchPost 실패:\", err);\n }\n\n // satori 줄바꿈에 맡기되 3줄을 넘는 초장문 제목만 자른다.\n const display = title.length > 64 ? `${title.slice(0, 63)}…` : title;\n const fontData = await loadKoreanFontSubset(`${display}${brand}${dateLabel ?? \"\"}`);\n\n const { createElement: h } = await import(\"react\");\n const element = h(\n \"div\",\n {\n style: {\n width: \"100%\",\n height: \"100%\",\n display: \"flex\",\n flexDirection: \"column\",\n justifyContent: \"space-between\",\n padding: \"72px 80px\",\n backgroundColor: background,\n backgroundImage: `linear-gradient(135deg, ${background} 60%, rgba(56,189,248,0.25))`,\n color: foreground,\n fontFamily: fontData ? \"NotoSansKR\" : \"sans-serif\",\n },\n },\n h(\"div\", {\n style: { width: 96, height: 10, backgroundColor: accent, borderRadius: 5 },\n }),\n h(\n \"div\",\n {\n style: {\n display: \"flex\",\n fontSize: display.length > 36 ? 56 : 68,\n fontWeight: 700,\n lineHeight: 1.25,\n letterSpacing: \"-0.02em\",\n wordBreak: \"keep-all\",\n },\n },\n display,\n ),\n h(\n \"div\",\n {\n style: {\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n fontSize: 28,\n },\n },\n h(\"div\", { style: { display: \"flex\", color: accent, fontWeight: 700 } }, brand),\n h(\n \"div\",\n { style: { display: \"flex\", color: \"rgba(248,250,252,0.72)\" } },\n dateLabel ?? trimSlash(config.siteUrl).replace(/^https?:\\/\\//, \"\"),\n ),\n ),\n );\n\n return new deps.ImageResponse(element, {\n width: OG_IMAGE_SIZE.width,\n height: OG_IMAGE_SIZE.height,\n ...(fontData\n ? { fonts: [{ name: \"NotoSansKR\", data: fontData, weight: 700 as const, style: \"normal\" as const }] }\n : {}),\n });\n };\n}\n"],"mappings":"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","names":["resolvePostCollection","resolvePostUrl","postEntries","categoryEntries"]}
|
|
1
|
+
{"version":3,"sources":["../src/routes.ts"],"sourcesContent":["/**\n * `@roottale/cms-renderer-next/routes` — Next.js App Router 통합 라우트 팩토리.\n *\n * 외부 고객사 사이트가 매번 직접 작성하던 공통 라우트(RSS feed / sitemap /\n * revalidate webhook)를 1줄 팩토리로 표준화. 블로그 조회와 *동일한* 단일\n * customer API key(`ROOTTALE_API_KEY`)를 사용한다.\n *\n * 설계 원칙:\n * - feed/sitemap 은 `fetchPosts` 실패를 swallow → 라우트가 *항상 200* (빌드 시\n * 정적 생성이 throw 해서 404 나는 사고 방지).\n * - revalidate 는 admin(mysite.roottale.com)에서 글/카테고리/미디어 변경 시 보내는\n * 서명 webhook 을 검증하고, 본문 paths + 파생 경로(feed·sitemap·블로그 인덱스)를\n * 모두 revalidate → 각 사이트에 자동 반영.\n * - `next` 에 직접 의존하지 않음: revalidate 콜백(`revalidatePath`)을 주입받는다.\n *\n * 사용 예는 roottale-docs `services/external-site-integration.md` 참조.\n */\n\nimport {\n CMS_CLIENT_VERSION,\n fetchPost,\n fetchPosts,\n generateRssXml,\n rssItemsFromPosts,\n type CmsPostContent,\n} from \"@roottale/cms-client/server\";\nimport {\n verifyRootTaleWebhook,\n type VerifyRootTaleWebhookResult,\n} from \"@roottale/cms-client/webhook\";\nimport {\n type RouteCollection,\n isInCollectionMode,\n resolvePostCollection,\n resolvePostUrl,\n} from \"@roottale/cms-core\";\nimport { collectCategories, collectCollectionCategories } from \"./blog-filter.js\";\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 미설정\" 과 구분한다. */\nasync 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 */\nasync function resolveCollections(\n input: CollectionsInput | undefined,\n): Promise<RouteCollection[] | undefined> {\n return (await resolveCollectionsResult(input)).collections;\n}\n\nfunction trimSlash(u: string): string {\n return u.replace(/\\/+$/, \"\");\n}\n\nasync 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\n/**\n * `app/feed.xml/route.ts` 용 GET 핸들러. RSS 2.0.\n *\n * ```ts\n * import { createFeedRoute } from \"@roottale/cms-renderer-next/routes\";\n * export const dynamic = \"force-dynamic\";\n * export const GET = createFeedRoute({\n * apiKey: process.env.ROOTTALE_API_KEY!,\n * siteUrl: \"https://example.com\",\n * title: \"Example 블로그\",\n * description: \"...\",\n * });\n * ```\n */\nexport function createFeedRoute(\n config: RootTaleSiteConfig,\n): () => Promise<Response> {\n return async function GET(): Promise<Response> {\n const siteUrl = trimSlash(config.siteUrl);\n const collections = await resolveCollections(config.collections);\n const allPosts = await safeFetchPosts(config, 50);\n\n // collection 모드: /feed.xml = `feed:true` 스트림들의 합집합(aggregate). RSS\n // 리더는 안정적 사이트 피드를 기대하므로 스트림별 분리 대신 통합 피드(codex).\n // 각 글 링크는 소속 스트림 basePath 로. 레거시: 단일 blogBasePath.\n const inCollectionMode = isInCollectionMode(collections);\n const blogBase = config.blogBasePath ?? \"/blog\";\n const posts = inCollectionMode\n ? allPosts.filter((p) => {\n const c = resolvePostCollection(p, collections!);\n return c ? c.feed === true : false;\n })\n : allPosts;\n\n const xml = generateRssXml({\n title: config.title,\n link: inCollectionMode ? siteUrl : `${siteUrl}${blogBase}`,\n feedUrl: `${siteUrl}/feed.xml`,\n description: config.description,\n language: config.language,\n items: rssItemsFromPosts(posts, {\n postUrl: (p) =>\n inCollectionMode\n ? (resolvePostUrl(siteUrl, p, collections!) ?? `${siteUrl}${blogBase}/${p.slug}`)\n : `${siteUrl}${blogBase}/${p.slug}`,\n }),\n });\n return new Response(xml, {\n headers: {\n \"content-type\": \"application/rss+xml; charset=utf-8\",\n \"cache-control\": \"public, max-age=1800, s-maxage=1800\",\n },\n });\n };\n}\n\n/** Next `MetadataRoute.Sitemap[number]` 와 구조 호환(타입 결합 회피). */\nexport interface SitemapEntry {\n url: string;\n lastModified?: string | Date;\n changeFrequency?:\n | \"always\"\n | \"hourly\"\n | \"daily\"\n | \"weekly\"\n | \"monthly\"\n | \"yearly\"\n | \"never\";\n priority?: number;\n}\n\n/**\n * `app/sitemap.ts` default export. 정적 경로 + 글 경로를 합친다.\n *\n * ```ts\n * import { createSitemap } from \"@roottale/cms-renderer-next/routes\";\n * export default createSitemap(\n * { apiKey: process.env.ROOTTALE_API_KEY!, siteUrl: \"https://example.com\", title: \"...\" },\n * [{ url: \"https://example.com\", changeFrequency: \"weekly\", priority: 1 }],\n * );\n * ```\n */\nexport function createSitemap(\n config: RootTaleSiteConfig,\n staticRoutes: SitemapEntry[] = [],\n): () => Promise<SitemapEntry[]> {\n return async function sitemap(): Promise<SitemapEntry[]> {\n const siteUrl = trimSlash(config.siteUrl);\n const blogBase = config.blogBasePath ?? \"/blog\";\n const collections = await resolveCollections(config.collections);\n const posts = await safeFetchPosts(config, 200);\n\n if (isInCollectionMode(collections)) {\n // 글마다 소속 스트림 basePath 로(공지→/notice, 블로그→/blog). 어느 스트림에도\n // 안 속한 글(상세 라우트 없음)은 제외. 아카이브는 `archives:true` 스트림만.\n const postEntries: SitemapEntry[] = posts.flatMap((p) => {\n const url = resolvePostUrl(siteUrl, p, collections!);\n if (!url) return [];\n return [\n {\n url,\n lastModified: p.updatedAt ?? p.publishedAt ?? undefined,\n changeFrequency: \"weekly\" as const,\n priority: 0.6,\n },\n ];\n });\n\n const categoryEntries: SitemapEntry[] = collections!\n .filter((c) => c.archives)\n .flatMap((c) => {\n const cats = collectCollectionCategories(posts, collections!, c.key);\n if (!cats.length) return [];\n const base = `${siteUrl}${trimSlash(c.basePath)}`;\n return [\n { url: `${base}/categories`, changeFrequency: \"weekly\" as const, priority: 0.5 },\n ...cats.map((category) => ({\n url: `${base}/categories/${category.slug}`,\n changeFrequency: \"weekly\" as const,\n priority: 0.5,\n })),\n ];\n });\n\n return [...staticRoutes, ...postEntries, ...categoryEntries];\n }\n\n // 레거시: 단일 blogBasePath.\n const postEntries: SitemapEntry[] = posts.map((p) => ({\n url: `${siteUrl}${blogBase}/${p.slug}`,\n lastModified: p.updatedAt ?? p.publishedAt ?? undefined,\n changeFrequency: \"weekly\",\n priority: 0.6,\n }));\n const categories = collectCategories(posts);\n const categoryEntries: SitemapEntry[] = categories.length\n ? [\n {\n url: `${siteUrl}${blogBase}/categories`,\n changeFrequency: \"weekly\",\n priority: 0.5,\n },\n ...categories.map((category) => ({\n url: `${siteUrl}${blogBase}/categories/${category.slug}`,\n changeFrequency: \"weekly\" as const,\n priority: 0.5,\n })),\n ]\n : [];\n return [...staticRoutes, ...postEntries, ...categoryEntries];\n };\n}\n\n// ── llms.txt (AEO/GEO — AI 검색·생성엔진용 마크다운 인덱스) ──────────────────\n\n/** llms.txt 정적 섹션의 링크 1개. */\nexport interface LlmsTxtLink {\n title: string;\n url: string;\n /** 링크 뒤 \": {note}\" 로 붙는 한 줄 설명 (선택). */\n note?: string;\n}\n\n/** llms.txt 의 정적 페이지 링크 그룹 — 블로그 목록 앞에 출력. */\nexport interface LlmsTxtSection {\n title: string;\n links: LlmsTxtLink[];\n}\n\nexport type LlmsTxtRouteConfig = RootTaleSiteConfig & {\n /** (선택) 서비스 소개 등 정적 페이지 링크 그룹. */\n sections?: LlmsTxtSection[];\n};\n\n/** 마크다운 링크 텍스트를 깨뜨리는 문자([, ], (, )) 제거. */\nfunction mdLinkSafe(text: string): string {\n return text.replace(/[[\\]()]/g, \"\").trim();\n}\n\n/** excerpt 를 1줄로 — 개행·연속 공백을 공백 1개로 정리 후 200자 절단. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, \" \").trim().slice(0, 200);\n}\n\n/**\n * `app/llms.txt/route.ts` 용 GET 핸들러 — AEO/GEO(AI 검색·생성엔진 최적화).\n *\n * AI 크롤러(ChatGPT/Claude/Perplexity 등)가 사이트 구조·콘텐츠를 빠르게\n * 파악하도록 llms.txt 스펙(https://llmstxt.org)의 마크다운 인덱스를 제공한다.\n * 발행 글 최대 100개를 \"## 블로그\" 섹션으로 자동 포함 — feed/sitemap 처럼\n * fetch 실패도 swallow 하여 *항상 200* (헤더 부분만 출력).\n *\n * ```ts\n * import { createLlmsTxtRoute } from \"@roottale/cms-renderer-next/routes\";\n * export const dynamic = \"force-dynamic\";\n * export const GET = createLlmsTxtRoute({\n * apiKey: process.env.ROOTTALE_API_KEY!,\n * siteUrl: \"https://example.com\",\n * title: \"Example\",\n * description: \"...\",\n * sections: [{ title: \"주요 페이지\", links: [{ title: \"서비스\", url: \"https://example.com/services\" }] }],\n * });\n * ```\n */\nexport function createLlmsTxtRoute(\n config: LlmsTxtRouteConfig,\n): () => Promise<Response> {\n return async function GET(): Promise<Response> {\n const siteUrl = trimSlash(config.siteUrl);\n const blogBase = config.blogBasePath ?? \"/blog\";\n const collections = await resolveCollections(config.collections);\n const posts = await safeFetchPosts(config, 100);\n\n const lines: string[] = [`# ${config.title}`];\n if (config.description) {\n lines.push(\"\", `> ${oneLine(config.description)}`);\n }\n for (const section of config.sections ?? []) {\n lines.push(\"\", `## ${section.title}`);\n for (const link of section.links) {\n const note = link.note ? `: ${oneLine(link.note)}` : \"\";\n lines.push(`- [${mdLinkSafe(link.title)}](${link.url})${note}`);\n }\n }\n if (posts.length > 0) {\n const inCollectionMode = isInCollectionMode(collections);\n // 레거시 헤딩(\"## 블로그\")은 하위호환 유지, 다중 스트림은 \"## 콘텐츠\".\n lines.push(\"\", inCollectionMode ? \"## 콘텐츠\" : \"## 블로그\");\n for (const p of posts) {\n // collection 모드: 소속 스트림 URL, 미소속 글 제외. 레거시: 단일 blogBase.\n const url = inCollectionMode\n ? resolvePostUrl(siteUrl, p, collections!)\n : `${siteUrl}${blogBase}/${p.slug}`;\n if (!url) continue;\n const excerpt = p.excerpt ? `: ${oneLine(p.excerpt)}` : \"\";\n lines.push(`- [${mdLinkSafe(p.title)}](${url})${excerpt}`);\n }\n }\n lines.push(\"\");\n\n return new Response(lines.join(\"\\n\"), {\n headers: {\n \"content-type\": \"text/plain; charset=utf-8\",\n // feed 와 동일한 30분 캐시 — webhook alsoRevalidate 로 즉시 갱신됨.\n \"cache-control\": \"public, max-age=1800, s-maxage=1800\",\n },\n });\n };\n}\n\nexport interface RevalidateRouteConfig {\n /** `rtlk_cust_*` — 블로그 조회/webhook 검증 공통 키. */\n apiKey: string;\n apiBase?: string;\n /** Next 의 `revalidatePath` (또는 동등 콜백) 주입. */\n revalidate: (path: string) => void | Promise<void>;\n /** 블로그 base path. 기본 \"/blog\". */\n blogBasePath?: string;\n /**\n * 다중 스트림 (ADR-0060). 지정 시 `alsoRevalidate` 기본값을 각 스트림 basePath\n * (+ `archives:true` 면 `${basePath}/categories`)로 파생. 미지정 = 레거시 단일 blog.\n * 배열 또는 async resolver(`fetchCollections()`) — resolver 실패 시 레거시로 fail-soft.\n */\n collections?: CollectionsInput;\n /**\n * 모든 글/분류/미디어 변경에 대해 *항상 함께* revalidate 할 파생 경로.\n * 피드·사이트맵·블로그 인덱스처럼 \"글 목록\"에 의존하는 경로 — admin 변경이\n * 자동 반영되려면 필수. 명시 시 collections 파생을 덮어쓴다.\n * 기본(레거시): [\"/feed.xml\", \"/sitemap.xml\", \"/llms.txt\", blogBase, `${blogBase}/categories`].\n */\n alsoRevalidate?: string[];\n /** (옵션) jti replay 방지 hook (cms-client verify 로 전달). */\n consumeJti?: (jti: string, exp: number) => Promise<\"new\" | \"seen\">;\n}\n\n/** webhook 본문에서 paths[] 추출(서명 검증 후 호출 — body_hash 가 binding). */\nfunction pathsFromBody(rawBody: string): string[] {\n try {\n const body = JSON.parse(rawBody) as { paths?: unknown };\n if (Array.isArray(body.paths)) {\n return body.paths.filter((p): p is string => typeof p === \"string\");\n }\n } catch {\n /* 비-JSON 본문 — 파생 경로만 revalidate */\n }\n return [];\n}\n\n/**\n * `app/api/revalidate/route.ts` 용 POST 핸들러.\n *\n * admin(mysite.roottale.com)이 글/카테고리/미디어 변경 시 보내는 서명 webhook 을\n * 검증하고, 본문 paths + 파생 경로(feed·sitemap·블로그 인덱스)를 revalidate.\n *\n * ```ts\n * import { revalidatePath } from \"next/cache\";\n * import { createRevalidateRoute } from \"@roottale/cms-renderer-next/routes\";\n * export const POST = createRevalidateRoute({\n * apiKey: process.env.ROOTTALE_API_KEY!,\n * revalidate: revalidatePath,\n * });\n * ```\n */\nexport function createRevalidateRoute(\n config: RevalidateRouteConfig,\n): (req: Request) => Promise<Response> {\n const blogBase = config.blogBasePath ?? \"/blog\";\n\n return async function POST(req: Request): Promise<Response> {\n const rawBody = await req.text();\n const result: VerifyRootTaleWebhookResult = await verifyRootTaleWebhook({\n rawBody,\n headers: req.headers,\n apiKey: config.apiKey,\n apiBase: config.apiBase,\n consumeJti: config.consumeJti,\n });\n if (!result.ok) {\n return Response.json({ ok: false, reason: result.reason }, { status: 401 });\n }\n\n // 글 목록 의존 경로. 명시 alsoRevalidate 가 있으면 collections 를 resolve 하지 않는다\n // (불필요한 fetch/지연 방지 — codex#1). 없으면 요청 시 resolve 해 파생.\n // 항상 갱신하는 불변 경로 — feed/sitemap/llms 는 글 목록에 의존.\n const INVARIANTS = [\"/feed.xml\", \"/sitemap.xml\", \"/llms.txt\"];\n let alsoRevalidate: string[];\n let warning: string | undefined;\n if (config.alsoRevalidate) {\n alsoRevalidate = config.alsoRevalidate;\n } else {\n const { collections, failed } = await resolveCollectionsResult(config.collections);\n if (failed) {\n // resolver 실패: 스트림을 추측하지 않는다(잘못된 /blog 무효화 방지 — codex#2).\n // 불변 경로 + 본문 paths 만 갱신하고, 플랫폼이 재시도/알림하도록 warning 노출.\n alsoRevalidate = INVARIANTS;\n warning = \"collections_resolve_failed\";\n } else if (isInCollectionMode(collections)) {\n alsoRevalidate = [\n ...INVARIANTS,\n ...collections!.flatMap((c) =>\n c.archives ? [c.basePath, `${c.basePath}/categories`] : [c.basePath],\n ),\n ];\n } else {\n alsoRevalidate = [...INVARIANTS, blogBase, `${blogBase}/categories`];\n }\n }\n\n // 본문 paths + 파생 경로(항상) — 중복 제거.\n const paths = [...new Set([...pathsFromBody(rawBody), ...alsoRevalidate])];\n for (const p of paths) {\n await config.revalidate(p);\n }\n\n return Response.json({\n ok: true,\n event: result.event,\n deliveryId: result.deliveryId,\n revalidated: paths,\n ...(warning ? { warning } : {}),\n });\n };\n}\n\n// ── slug 변경 301 (WP permalink 패리티) ─────────────────────────────────────\n\n/**\n * 옛 slug → 현재 slug 301 redirect 판단 (순수 함수, `next` 비의존).\n *\n * Public API 는 slug 변경 후에도 옛 slug 로 글을 찾아 *현재* slug 를 응답한다\n * (post_slug_history fallback). 요청 slug 와 응답 slug 가 다르면 새 경로를\n * 반환 — 페이지에서 `permanentRedirect()` 로 301 처리한다:\n *\n * ```ts\n * import { permanentRedirect, notFound } from \"next/navigation\";\n * import { postRedirectPath } from \"@roottale/cms-renderer-next/routes\";\n *\n * const post = await getPost(slug);\n * if (!post) notFound();\n * const redirect = postRedirectPath(post, slug);\n * if (redirect) permanentRedirect(redirect);\n * ```\n *\n * Next App Router 의 `params` 는 percent-encoding 된 채 들어올 수 있어\n * (한글 slug) 디코딩 후 비교한다. redirect 가 불필요하면 null.\n */\nexport function postRedirectPath(\n post: { slug: string },\n requestedSlug: string,\n blogBasePath = \"/blog\",\n): string | null {\n let requested = requestedSlug;\n try {\n requested = decodeURIComponent(requestedSlug);\n } catch {\n /* 잘못된 escape — 원본 그대로 비교 */\n }\n if (post.slug === requested) return null;\n // blogBasePath \"/\" 또는 \"\" = 루트 배치 → \"/{slug}\".\n return `${trimSlash(blogBasePath)}/${encodeURIComponent(post.slug)}`;\n}\n\n// ── fleet well-known (사이트 버전 노출 컨벤션) ───────────────────────────────\n\ndeclare const __CMS_RENDERER_VERSION__: string | undefined;\n\nexport interface FleetInfoRouteConfig {\n /** 사이트 식별 라벨 (선택 — 기본 Vercel project URL 또는 null). */\n site?: string;\n}\n\n/**\n * `GET /.well-known/roottale.json` — fleet 가시성 컨벤션 라우트.\n *\n * 플랫폼 internal fleet API 의 `?probe=1` 이 본 엔드포인트를 읽어 \"배포된\"\n * 패키지 버전을 트래픽 없이도 확인한다 (런타임 check-in 의 보조 신호).\n * 노출은 의도적으로 최소: 패키지 버전 + 짧은 build sha — 비밀 없음.\n *\n * 사용 (Next App Router):\n * ```ts\n * // app/.well-known/roottale.json/route.ts\n * import { createFleetInfoRoute } from \"@roottale/cms-renderer-next/routes\";\n * export const GET = createFleetInfoRoute();\n * ```\n */\nexport function createFleetInfoRoute(\n config: FleetInfoRouteConfig = {},\n): () => Promise<Response> {\n // dts 빌드는 node types 없이 돈다 — process 는 globalThis 경유로 안전 접근.\n const env =\n (globalThis as { process?: { env?: Record<string, string | undefined> } })\n .process?.env ?? {};\n return async function GET(): Promise<Response> {\n return Response.json(\n {\n site: config.site ?? env.VERCEL_PROJECT_PRODUCTION_URL ?? null,\n framework: \"next\",\n cms_client: CMS_CLIENT_VERSION,\n cms_renderer:\n typeof __CMS_RENDERER_VERSION__ === \"string\"\n ? __CMS_RENDERER_VERSION__\n : \"dev\",\n build_sha: env.VERCEL_GIT_COMMIT_SHA?.slice(0, 7) ?? null,\n },\n { headers: { \"cache-control\": \"public, max-age=300\" } },\n );\n };\n}\n\n// ── 동적 OG 이미지 (글별 1200×630) ──────────────────────────────────────────\n\n/** Next `opengraph-image` export 컨벤션과 호환되는 크기 상수. */\nexport const OG_IMAGE_SIZE = { width: 1200, height: 630 } as const;\nexport const OG_IMAGE_CONTENT_TYPE = \"image/png\";\n\nexport interface OgImageRouteConfig extends RootTaleSiteConfig {\n /** 배경색. 기본 진네이비 그라데이션 베이스. */\n backgroundColor?: string;\n /** 본문 텍스트 색. 기본 흰색. */\n foregroundColor?: string;\n /** 상단 액센트 바 + 브랜드 라벨 색. 기본 하늘색. */\n accentColor?: string;\n /** 하단 브랜드 라벨. 기본 = `title`. */\n brandLabel?: string;\n}\n\n/** `next/og` 의 ImageResponse 와 구조 호환(직접 의존 회피 — revalidate 콜백 주입과 동일 원칙). */\nexport interface ImageResponseLike {\n new (\n element: unknown,\n options?: {\n width?: number;\n height?: number;\n fonts?: { name: string; data: ArrayBuffer; weight?: number; style?: \"normal\" }[];\n },\n ): Response;\n}\n\n/**\n * 제목에 쓰인 글자만 부분셋(subset)으로 받아 한글을 렌더 — satori 기본 폰트는\n * 한글 글리프가 없다. 구형 UA 로 css2 를 호출하면 TTF(satori 호환) URL 을 준다.\n * 실패 시 null — 폰트 없이도 이미지는 생성(라틴 fallback).\n */\nasync function loadKoreanFontSubset(text: string): Promise<ArrayBuffer | null> {\n try {\n const unique = Array.from(new Set(Array.from(text))).join(\"\");\n const cssUrl = `https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@700&text=${encodeURIComponent(unique)}`;\n const cssRes = await fetch(cssUrl, {\n headers: { \"user-agent\": \"Mozilla/5.0 (Windows NT 6.1; rv:10.0)\" },\n });\n if (!cssRes.ok) return null;\n const css = await cssRes.text();\n const match = css.match(\n /src:\\s*url\\(([^)]+)\\)\\s*format\\(['\"](?:truetype|opentype)['\"]\\)/,\n );\n if (!match?.[1]) return null;\n const fontRes = await fetch(match[1]);\n if (!fontRes.ok) return null;\n return await fontRes.arrayBuffer();\n } catch (err) {\n console.error(\"[@roottale/cms-renderer-next/routes] OG 폰트 로딩 실패:\", err);\n return null;\n }\n}\n\n/**\n * `app/blog/[slug]/opengraph-image.tsx` 용 동적 OG 이미지 (1200×630).\n *\n * `next/og` 의 ImageResponse 를 주입받는다 — 본 패키지는 `next` 에 직접\n * 의존하지 않는다. 글이 없거나 fetch 실패면 사이트 제목만으로 fail-soft 렌더\n * (소셜 크롤러에 빈 카드 대신 브랜드 카드).\n *\n * ```tsx\n * // app/blog/[slug]/opengraph-image.tsx\n * import { ImageResponse } from \"next/og\";\n * import {\n * createPostOgImage,\n * OG_IMAGE_SIZE,\n * OG_IMAGE_CONTENT_TYPE,\n * } from \"@roottale/cms-renderer-next/routes\";\n * import { roottale } from \"@/lib/roottale\"; // RootTaleSiteConfig\n *\n * export const size = OG_IMAGE_SIZE;\n * export const contentType = OG_IMAGE_CONTENT_TYPE;\n * export default createPostOgImage(roottale, { ImageResponse });\n * ```\n */\nexport function createPostOgImage(\n config: OgImageRouteConfig,\n deps: { ImageResponse: ImageResponseLike },\n): (props: { params: Promise<{ slug: string }> | { slug: string } }) => Promise<Response> {\n const background = config.backgroundColor ?? \"#10172a\";\n const foreground = config.foregroundColor ?? \"#f8fafc\";\n const accent = config.accentColor ?? \"#38bdf8\";\n const brand = config.brandLabel ?? config.title;\n\n return async function OgImage(props): Promise<Response> {\n const { slug } = await props.params;\n\n let title = config.title;\n let dateLabel: string | null = null;\n try {\n const post = await fetchPost({\n apiKey: config.apiKey,\n baseUrl: config.apiBase,\n slugOrId: slug,\n });\n if (post) {\n title = post.title;\n dateLabel = new Intl.DateTimeFormat(\"ko-KR\", { dateStyle: \"long\" }).format(\n new Date(post.publishedAt),\n );\n }\n } catch (err) {\n console.error(\"[@roottale/cms-renderer-next/routes] OG fetchPost 실패:\", err);\n }\n\n // satori 줄바꿈에 맡기되 3줄을 넘는 초장문 제목만 자른다.\n const display = title.length > 64 ? `${title.slice(0, 63)}…` : title;\n const fontData = await loadKoreanFontSubset(`${display}${brand}${dateLabel ?? \"\"}`);\n\n const { createElement: h } = await import(\"react\");\n const element = h(\n \"div\",\n {\n style: {\n width: \"100%\",\n height: \"100%\",\n display: \"flex\",\n flexDirection: \"column\",\n justifyContent: \"space-between\",\n padding: \"72px 80px\",\n backgroundColor: background,\n backgroundImage: `linear-gradient(135deg, ${background} 60%, rgba(56,189,248,0.25))`,\n color: foreground,\n fontFamily: fontData ? \"NotoSansKR\" : \"sans-serif\",\n },\n },\n h(\"div\", {\n style: { width: 96, height: 10, backgroundColor: accent, borderRadius: 5 },\n }),\n h(\n \"div\",\n {\n style: {\n display: \"flex\",\n fontSize: display.length > 36 ? 56 : 68,\n fontWeight: 700,\n lineHeight: 1.25,\n letterSpacing: \"-0.02em\",\n wordBreak: \"keep-all\",\n },\n },\n display,\n ),\n h(\n \"div\",\n {\n style: {\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n fontSize: 28,\n },\n },\n h(\"div\", { style: { display: \"flex\", color: accent, fontWeight: 700 } }, brand),\n h(\n \"div\",\n { style: { display: \"flex\", color: \"rgba(248,250,252,0.72)\" } },\n dateLabel ?? trimSlash(config.siteUrl).replace(/^https?:\\/\\//, \"\"),\n ),\n ),\n );\n\n return new deps.ImageResponse(element, {\n width: OG_IMAGE_SIZE.width,\n height: OG_IMAGE_SIZE.height,\n ...(fontData\n ? { fonts: [{ name: \"NotoSansKR\", data: fontData, weight: 700 as const, style: \"normal\" as const }] }\n : {}),\n });\n };\n}\n"],"mappings":"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","names":["resolvePostCollection","resolvePostUrl","postEntries","categoryEntries"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@roottale/cms-renderer-next",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "RootTale CMS public-render React/Next.js Server Components. SSR-only RSC components (RootTaleBlogList / RootTaleBlogPost / RootTaleLeadForm) for external customer sites. Companion of @roottale/cms-renderer-astro (ADR-0034 §1.5 amended).",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
],
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"zod": "^3.23.8",
|
|
45
|
-
"@roottale/cms-core": "^0.
|
|
46
|
-
"@roottale/cms-client": "^0.
|
|
45
|
+
"@roottale/cms-core": "^0.30.0",
|
|
46
|
+
"@roottale/cms-client": "^0.31.0"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"react": "^19.0.0"
|