@roottale/cms-renderer-next 0.35.0 → 0.37.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/dist/routes.d.ts CHANGED
@@ -70,21 +70,66 @@ declare function createFeedRoute(config: RootTaleSiteConfig): () => Promise<Resp
70
70
  interface SitemapEntry {
71
71
  url: string;
72
72
  lastModified?: string | Date;
73
+ /** @deprecated Google 이 무시 — emit 하지 않는다(blanche식 loc+lastmod). */
73
74
  changeFrequency?: "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
75
+ /** @deprecated Google 이 무시 — emit 하지 않는다. */
74
76
  priority?: number;
77
+ /** `<image:image>` — 글 대표 이미지 등. Next `MetadataRoute.Sitemap` 호환. */
78
+ images?: string[];
79
+ /** hreflang 대체 언어 — 다국어(`alternates.languages`). Next 호환. */
80
+ alternates?: {
81
+ languages?: Record<string, string>;
82
+ };
83
+ }
84
+ /** `createSitemapIndex` 옵션 — admin 설정을 코드에서 덮어쓰거나 fetch 를 끌 때만. */
85
+ interface SitemapIndexOptions {
86
+ /** admin 의 `blogImages` 토글을 덮어쓴다. */
87
+ blogImages?: boolean;
88
+ /** admin 의 `locales` 를 덮어쓴다 (다국어, PR C). */
89
+ locales?: string[];
90
+ /** admin 의 `authors` 토글을 덮어쓴다 (작가 사이트맵). */
91
+ authors?: boolean;
92
+ /** 작가 아카이브 base path. 기본 "/blog/author" (→ /blog/author/{slug}). */
93
+ authorBasePath?: string;
94
+ /** true 면 admin 설정 fetch 생략(opts/기본값만). 빌드 시 네트워크 회피용. */
95
+ skipRemoteSettings?: boolean;
75
96
  }
76
97
  /**
77
- * `app/sitemap.ts` default export. 정적 경로 + 글 경로를 합친다.
98
+ * @deprecated `createSitemapIndex` 를 쓰세요 — 단일 평면 사이트맵 대신 인덱스 분리
99
+ * (static/blog/categories) + 이미지 + admin 토글을 지원한다. 본 함수는 하위호환용으로
100
+ * 유지되며 `changeFrequency`/`priority` 는 더 이상 emit 하지 않는다(blanche식).
101
+ *
102
+ * `app/sitemap.ts` default export. 정적 경로 + 글 + 카테고리를 단일 파일로 합친다.
103
+ */
104
+ declare function createSitemap(config: RootTaleSiteConfig, staticRoutes?: SitemapEntry[]): () => Promise<SitemapEntry[]>;
105
+ /** blanche식 인덱스 분리 사이트맵의 하위 섹션 id. */
106
+ type SitemapSectionId = "static" | "blog" | "categories" | "authors";
107
+ /**
108
+ * blanche식 사이트맵 인덱스 — `/sitemap.xml`(인덱스) + `/sitemap/<id>.xml` 하위
109
+ * 사이트맵(static/blog/categories)으로 분리한다(Next 16 `generateSitemaps`). 50k
110
+ * URL/50MB 한도 확장 + 검색엔진이 섹션별로 크롤. 블로그 글에는 대표 이미지를
111
+ * `<image:image>` 로 함께 색인(admin 토글). `changeFrequency`/`priority` 는 emit
112
+ * 하지 않는다.
78
113
  *
79
114
  * ```ts
80
- * import { createSitemap } from "@roottale/cms-renderer-next/routes";
81
- * export default createSitemap(
115
+ * // app/sitemap.ts
116
+ * import { createSitemapIndex } from "@roottale/cms-renderer-next/routes";
117
+ * const { generateSitemaps, sitemap } = createSitemapIndex(
82
118
  * { apiKey: process.env.ROOTTALE_API_KEY!, siteUrl: "https://example.com", title: "..." },
83
- * [{ url: "https://example.com", changeFrequency: "weekly", priority: 1 }],
119
+ * [{ url: "https://example.com" }, { url: "https://example.com/contact" }],
84
120
  * );
121
+ * export { generateSitemaps };
122
+ * export default sitemap;
85
123
  * ```
86
124
  */
87
- declare function createSitemap(config: RootTaleSiteConfig, staticRoutes?: SitemapEntry[]): () => Promise<SitemapEntry[]>;
125
+ declare function createSitemapIndex(config: RootTaleSiteConfig, staticRoutes?: SitemapEntry[], opts?: SitemapIndexOptions): {
126
+ generateSitemaps: () => Promise<{
127
+ id: SitemapSectionId;
128
+ }[]>;
129
+ sitemap: (props: {
130
+ id: string | Promise<string>;
131
+ }) => Promise<SitemapEntry[]>;
132
+ };
88
133
 
89
134
  /** `routes` — llms.txt (AEO/GEO, AI 검색·생성엔진용 마크다운 인덱스). */
90
135
 
@@ -146,7 +191,8 @@ interface RevalidateRouteConfig {
146
191
  * 모든 글/분류/미디어 변경에 대해 *항상 함께* revalidate 할 파생 경로.
147
192
  * 피드·사이트맵·블로그 인덱스처럼 "글 목록"에 의존하는 경로 — admin 변경이
148
193
  * 자동 반영되려면 필수. 명시 시 collections 파생을 덮어쓴다.
149
- * 기본(레거시): ["/feed.xml", "/sitemap.xml", "/llms.txt", blogBase, `${blogBase}/categories`].
194
+ * 기본(레거시): ["/feed.xml", "/llms.txt", "/sitemap.xml", "/sitemap/static.xml",
195
+ * "/sitemap/blog.xml", "/sitemap/categories.xml", blogBase, `${blogBase}/categories`].
150
196
  */
151
197
  alsoRevalidate?: string[];
152
198
  /** (옵션) jti replay 방지 hook (cms-client verify 로 전달). */
@@ -306,6 +352,124 @@ declare function matchRedirect(redirects: readonly RootTaleRedirect[], pathname:
306
352
  */
307
353
  declare function createRedirectMiddleware(config: RedirectMiddlewareConfig): (request: Request) => Promise<Response | null>;
308
354
 
355
+ /**
356
+ * `@roottale/cms-renderer-next/routes` — 블로그 글 메타데이터 빌더.
357
+ *
358
+ * 어드민 SEO 패널(`metaJson.seo`) 값을 Next.js `Metadata` 형태로 1줄 변환한다.
359
+ * 고객사 `app/blog/[slug]/page.tsx` 의 `generateMetadata` 에서 매번 직접 쓰던
360
+ * canonical/robots/openGraph 분기 보일러플레이트를 표준화 — 한 곳이라도
361
+ * 빠뜨려 SEO 가 새는 사고(noindex 누락·canonical 미설정)를 방지한다.
362
+ *
363
+ * 설계 원칙(패키지 공통): `next` 에 직접 의존하지 않는다. 반환은 Next `Metadata`
364
+ * 의 구조적 부분집합인 plain 객체이므로, 소비자는 `generateMetadata` 에서 그대로
365
+ * `return` 하면 된다.
366
+ */
367
+
368
+ /** 어드민 SEO 패널 override (`metaJson.seo`). 전부 선택. */
369
+ interface PostSeoOverrides {
370
+ /** 검색 제목 — 비면 글 제목 사용. */
371
+ title?: string;
372
+ /** 메타 설명 — 비면 글 요약 사용. */
373
+ description?: string;
374
+ /** 대표 URL. 비면 `siteUrl`+`path` 로 self-canonical 생성(옵션 제공 시). */
375
+ canonical?: string;
376
+ /** OG 이미지 — 비면 글 대표 이미지 사용. */
377
+ ogImage?: string;
378
+ /** 색인 제외. */
379
+ noindex?: boolean;
380
+ /** 링크 추적 제외. */
381
+ nofollow?: boolean;
382
+ }
383
+ /** `buildPostMetadata` 입력 — `BlogPostMeta`·원본 post 모두 구조적으로 호환. */
384
+ interface PostMetadataInput {
385
+ /** 글 제목 (`seo.title` 없을 때 fallback). */
386
+ title: string;
387
+ /** 글 요약/설명 (`seo.description` 없을 때 fallback). */
388
+ description?: string | null;
389
+ /** 발행 ISO 일시 → `openGraph.publishedTime`. */
390
+ date?: string | null;
391
+ /** 대표 이미지 URL → `openGraph.images` (`seo.ogImage` 우선). */
392
+ image?: string | null;
393
+ /** 어드민 SEO 패널 override (`metaJson.seo`). */
394
+ seo?: PostSeoOverrides | null;
395
+ /** 글 slug — `collections` 기반 canonical 자동 해석에 사용. */
396
+ slug?: string;
397
+ /**
398
+ * 소속 섹션 key (ADR-0060). `collections` 와 함께 주면 canonical basePath 를
399
+ * 공지/블로그로 구분해 해석한다(`collectionKey:"notice"` → `/notice`).
400
+ */
401
+ collectionKey?: string | null;
402
+ }
403
+ interface BuildPostMetadataOptions {
404
+ /**
405
+ * self-canonical 기본값 origin (예: `https://example.com`).
406
+ * `seo.canonical` override 가 있으면 무시된다. `path` 또는 `collections` 와
407
+ * 함께 줄 때만 동작.
408
+ */
409
+ siteUrl?: string;
410
+ /**
411
+ * self-canonical 기본값 경로 (예: `/blog/my-post`). `siteUrl` 과 함께.
412
+ * 명시하면 `collections` 파생보다 우선한다(단일 라우트 사이트용 명시 경로).
413
+ */
414
+ path?: string;
415
+ /**
416
+ * 섹션 정의 (ADR-0060). `path` 를 안 주면 글의 `collectionKey` 로 소속 섹션
417
+ * basePath 를 찾아 canonical 경로를 **공지/블로그로 구분해 자동 해석**한다
418
+ * (공지 글 → `/notice/{slug}`, 블로그 글 → `/blog/{slug}`). 어느 섹션에도
419
+ * 안 속하면(미설정·미존재 key) canonical 을 생략한다. `post.slug` 필요.
420
+ */
421
+ collections?: readonly RouteCollection[];
422
+ }
423
+ /** Next `Metadata` 의 구조적 부분집합 — 소비자는 그대로 return 가능. */
424
+ interface PostMetadata {
425
+ title: string;
426
+ description?: string;
427
+ alternates?: {
428
+ canonical: string;
429
+ };
430
+ robots?: {
431
+ index: boolean;
432
+ follow: boolean;
433
+ };
434
+ openGraph: {
435
+ type: "article";
436
+ title: string;
437
+ description?: string;
438
+ url?: string;
439
+ publishedTime?: string;
440
+ images?: {
441
+ url: string;
442
+ alt: string;
443
+ }[];
444
+ };
445
+ }
446
+ /**
447
+ * 글 메타데이터를 Next `Metadata` 형태로 빌드.
448
+ *
449
+ * - 제목/설명/OG 이미지는 어드민 SEO override 가 있으면 우선, 없으면 글 값 fallback.
450
+ * - canonical: `seo.canonical` override → 없으면 `siteUrl`+`path`(명시) → 없으면
451
+ * `siteUrl`+`collections`(글의 `collectionKey` 로 공지/블로그 basePath 자동 해석)
452
+ * → 없으면 생략. 모든 글이 자기 자신을 가리키는 canonical 을 갖는 게 권장이므로
453
+ * `siteUrl` 과 함께 `path` 또는 `collections` 전달을 권장한다.
454
+ * - `noindex`/`nofollow` 중 하나라도 켜지면 `robots` 출력.
455
+ *
456
+ * ```ts
457
+ * // 단일 블로그(레거시): 라우트가 곧 섹션이므로 path 를 직접 준다.
458
+ * return buildPostMetadata(post, {
459
+ * siteUrl: process.env.NEXT_PUBLIC_SITE_URL,
460
+ * path: `/blog/${post.slug}`,
461
+ * });
462
+ *
463
+ * // 다중 스트림(ADR-0060): collections 를 주면 공지/블로그를 글의
464
+ * // collectionKey 로 구분해 canonical 을 자동 해석한다(공지→/notice, 블로그→/blog).
465
+ * return buildPostMetadata(post, {
466
+ * siteUrl: process.env.NEXT_PUBLIC_SITE_URL,
467
+ * collections: COLLECTIONS,
468
+ * });
469
+ * ```
470
+ */
471
+ declare function buildPostMetadata(post: PostMetadataInput, options?: BuildPostMetadataOptions): PostMetadata;
472
+
309
473
  /**
310
474
  * `@roottale/cms-renderer-next/routes` — Next.js App Router 통합 라우트 팩토리.
311
475
  *
@@ -365,4 +529,4 @@ interface FleetInfoRouteConfig {
365
529
  */
366
530
  declare function createFleetInfoRoute(config?: FleetInfoRouteConfig): () => Promise<Response>;
367
531
 
368
- export { type CollectionsInput, type FleetInfoRouteConfig, type ImageResponseLike, type LlmsTxtLink, type LlmsTxtRouteConfig, type LlmsTxtSection, OG_IMAGE_CONTENT_TYPE, OG_IMAGE_SIZE, type OgImageRouteConfig, type RedirectMiddlewareConfig, type RevalidateRouteConfig, type RootTaleSiteConfig, type SitemapEntry, createFeedRoute, createFleetInfoRoute, createLlmsTxtRoute, createPostOgImage, createRedirectMiddleware, createRevalidateRoute, createSitemap, matchRedirect, normalizeRedirectPath, postRedirectPath, resolveCollections, resolveCollectionsResult, safeFetchPosts, trimSlash };
532
+ export { type BuildPostMetadataOptions, type CollectionsInput, type FleetInfoRouteConfig, type ImageResponseLike, type LlmsTxtLink, type LlmsTxtRouteConfig, type LlmsTxtSection, OG_IMAGE_CONTENT_TYPE, OG_IMAGE_SIZE, type OgImageRouteConfig, type PostMetadata, type PostMetadataInput, type PostSeoOverrides, type RedirectMiddlewareConfig, type RevalidateRouteConfig, type RootTaleSiteConfig, type SitemapEntry, type SitemapIndexOptions, type SitemapSectionId, buildPostMetadata, createFeedRoute, createFleetInfoRoute, createLlmsTxtRoute, createPostOgImage, createRedirectMiddleware, createRevalidateRoute, createSitemap, createSitemapIndex, matchRedirect, normalizeRedirectPath, postRedirectPath, resolveCollections, resolveCollectionsResult, safeFetchPosts, trimSlash };
package/dist/routes.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  collectCategories,
3
3
  collectCollectionCategories
4
- } from "./chunk-O6KHDNGH.js";
4
+ } from "./chunk-FUA5H6JE.js";
5
5
 
6
6
  // src/routes.ts
7
7
  import { CMS_CLIENT_VERSION } from "@roottale/cms-client/server";
@@ -45,7 +45,12 @@ async function safeFetchPosts(config, limit) {
45
45
  }
46
46
 
47
47
  // src/routes-feed-sitemap.ts
48
- import { generateRssXml, rssItemsFromPosts } from "@roottale/cms-client/server";
48
+ import {
49
+ fetchAuthors,
50
+ fetchBlogSettings,
51
+ generateRssXml,
52
+ rssItemsFromPosts
53
+ } from "@roottale/cms-client/server";
49
54
  import {
50
55
  isInCollectionMode,
51
56
  resolvePostCollection as resolvePostCollection2,
@@ -80,62 +85,152 @@ function createFeedRoute(config) {
80
85
  });
81
86
  };
82
87
  }
88
+ var SITEMAP_DEFAULTS = {
89
+ blogImages: true,
90
+ locales: [],
91
+ authors: false
92
+ };
93
+ async function safeFetchAuthors(config) {
94
+ try {
95
+ return await fetchAuthors({ apiKey: config.apiKey, baseUrl: config.apiBase });
96
+ } catch (err) {
97
+ console.error("[@roottale/cms-renderer-next/routes] fetchAuthors \uC2E4\uD328:", err);
98
+ return [];
99
+ }
100
+ }
101
+ async function resolveSitemapSettings(config, opts) {
102
+ let remote = {};
103
+ if (!opts.skipRemoteSettings) {
104
+ try {
105
+ remote = (await fetchBlogSettings({
106
+ apiKey: config.apiKey,
107
+ baseUrl: config.apiBase
108
+ })).sitemap ?? {};
109
+ } catch {
110
+ }
111
+ }
112
+ return {
113
+ blogImages: opts.blogImages ?? remote.blogImages ?? SITEMAP_DEFAULTS.blogImages,
114
+ locales: opts.locales ?? remote.locales ?? SITEMAP_DEFAULTS.locales,
115
+ authors: opts.authors ?? remote.authors ?? SITEMAP_DEFAULTS.authors
116
+ };
117
+ }
118
+ function localizeEntries(entries, siteUrl, locales) {
119
+ const defaultLocale = locales[0];
120
+ if (!defaultLocale) return entries;
121
+ const rest = locales.slice(1);
122
+ return entries.map((entry) => {
123
+ if (entry.url !== siteUrl && !entry.url.startsWith(`${siteUrl}/`)) return entry;
124
+ const path = entry.url.slice(siteUrl.length);
125
+ const languages = {
126
+ [defaultLocale]: entry.url,
127
+ "x-default": entry.url
128
+ };
129
+ for (const loc of rest) languages[loc] = `${siteUrl}/${loc}${path}`;
130
+ return { ...entry, alternates: { languages } };
131
+ });
132
+ }
133
+ function staticEntries(staticRoutes) {
134
+ return staticRoutes.map((r) => {
135
+ const entry = { url: r.url };
136
+ if (r.lastModified !== void 0) entry.lastModified = r.lastModified;
137
+ if (r.images) entry.images = r.images;
138
+ if (r.alternates) entry.alternates = r.alternates;
139
+ return entry;
140
+ });
141
+ }
142
+ function postEntries(config, siteUrl, posts, collections, settings) {
143
+ const inCollection = isInCollectionMode(collections);
144
+ const blogBase = config.blogBasePath ?? "/blog";
145
+ return posts.flatMap((p) => {
146
+ const url = inCollection ? resolvePostUrl2(siteUrl, p, collections) : `${siteUrl}${blogBase}/${p.slug}`;
147
+ if (!url) return [];
148
+ const entry = {
149
+ url,
150
+ lastModified: p.updatedAt ?? p.publishedAt ?? void 0
151
+ };
152
+ if (settings.blogImages && p.featuredImageUrl) {
153
+ entry.images = [p.featuredImageUrl];
154
+ }
155
+ return [entry];
156
+ });
157
+ }
158
+ function categoryEntries(config, siteUrl, posts, collections) {
159
+ if (isInCollectionMode(collections)) {
160
+ return collections.filter((c) => c.archives).flatMap((c) => {
161
+ const cats = collectCollectionCategories(posts, collections, c.key);
162
+ if (!cats.length) return [];
163
+ const base = `${siteUrl}${trimSlash(c.basePath)}`;
164
+ return [
165
+ { url: `${base}/categories` },
166
+ ...cats.map((category) => ({
167
+ url: `${base}/categories/${category.slug}`
168
+ }))
169
+ ];
170
+ });
171
+ }
172
+ const categories = collectCategories(posts);
173
+ if (!categories.length) return [];
174
+ const blogBase = config.blogBasePath ?? "/blog";
175
+ return [
176
+ { url: `${siteUrl}${blogBase}/categories` },
177
+ ...categories.map((category) => ({
178
+ url: `${siteUrl}${blogBase}/categories/${category.slug}`
179
+ }))
180
+ ];
181
+ }
83
182
  function createSitemap(config, staticRoutes = []) {
84
183
  return async function sitemap() {
85
184
  const siteUrl = trimSlash(config.siteUrl);
86
- const blogBase = config.blogBasePath ?? "/blog";
87
185
  const collections = await resolveCollections(config.collections);
88
186
  const posts = await safeFetchPosts(config, 200);
89
- if (isInCollectionMode(collections)) {
90
- const postEntries2 = posts.flatMap((p) => {
91
- const url = resolvePostUrl2(siteUrl, p, collections);
92
- if (!url) return [];
93
- return [
94
- {
95
- url,
96
- lastModified: p.updatedAt ?? p.publishedAt ?? void 0,
97
- changeFrequency: "weekly",
98
- priority: 0.6
99
- }
100
- ];
101
- });
102
- const categoryEntries2 = collections.filter((c) => c.archives).flatMap((c) => {
103
- const cats = collectCollectionCategories(posts, collections, c.key);
104
- if (!cats.length) return [];
105
- const base = `${siteUrl}${trimSlash(c.basePath)}`;
106
- return [
107
- { url: `${base}/categories`, changeFrequency: "weekly", priority: 0.5 },
108
- ...cats.map((category) => ({
109
- url: `${base}/categories/${category.slug}`,
110
- changeFrequency: "weekly",
111
- priority: 0.5
112
- }))
113
- ];
114
- });
115
- return [...staticRoutes, ...postEntries2, ...categoryEntries2];
116
- }
117
- const postEntries = posts.map((p) => ({
118
- url: `${siteUrl}${blogBase}/${p.slug}`,
119
- lastModified: p.updatedAt ?? p.publishedAt ?? void 0,
120
- changeFrequency: "weekly",
121
- priority: 0.6
122
- }));
123
- const categories = collectCategories(posts);
124
- const categoryEntries = categories.length ? [
125
- {
126
- url: `${siteUrl}${blogBase}/categories`,
127
- changeFrequency: "weekly",
128
- priority: 0.5
129
- },
130
- ...categories.map((category) => ({
131
- url: `${siteUrl}${blogBase}/categories/${category.slug}`,
132
- changeFrequency: "weekly",
133
- priority: 0.5
134
- }))
135
- ] : [];
136
- return [...staticRoutes, ...postEntries, ...categoryEntries];
187
+ const settings = await resolveSitemapSettings(config, {
188
+ skipRemoteSettings: true,
189
+ blogImages: false
190
+ });
191
+ return [
192
+ ...staticEntries(staticRoutes),
193
+ ...postEntries(config, siteUrl, posts, collections, settings),
194
+ ...categoryEntries(config, siteUrl, posts, collections)
195
+ ];
137
196
  };
138
197
  }
198
+ function createSitemapIndex(config, staticRoutes = [], opts = {}) {
199
+ async function generateSitemaps() {
200
+ const settings = await resolveSitemapSettings(config, opts);
201
+ const ids = ["static", "blog", "categories"];
202
+ if (settings.authors) ids.push("authors");
203
+ return ids.map((id) => ({ id }));
204
+ }
205
+ async function sitemap(props) {
206
+ const id = await props.id;
207
+ const siteUrl = trimSlash(config.siteUrl);
208
+ const settings = await resolveSitemapSettings(config, opts);
209
+ const localize = (entries) => localizeEntries(entries, siteUrl, settings.locales);
210
+ if (id === "static") return localize(staticEntries(staticRoutes));
211
+ if (id === "authors") {
212
+ if (!settings.authors) return [];
213
+ const authors = await safeFetchAuthors(config);
214
+ const base = trimSlash(opts.authorBasePath ?? "/blog/author");
215
+ return localize(
216
+ authors.map((a) => ({
217
+ url: `${siteUrl}${base}/${encodeURIComponent(a.slug)}`,
218
+ lastModified: a.lastUpdatedAt ?? void 0
219
+ }))
220
+ );
221
+ }
222
+ const collections = await resolveCollections(config.collections);
223
+ const posts = await safeFetchPosts(config, 200);
224
+ if (id === "blog") {
225
+ return localize(postEntries(config, siteUrl, posts, collections, settings));
226
+ }
227
+ if (id === "categories") {
228
+ return localize(categoryEntries(config, siteUrl, posts, collections));
229
+ }
230
+ return [];
231
+ }
232
+ return { generateSitemaps, sitemap };
233
+ }
139
234
 
140
235
  // src/routes-llms.ts
141
236
  import { isInCollectionMode as isInCollectionMode2, resolvePostUrl as resolvePostUrl3 } from "@roottale/cms-core";
@@ -212,7 +307,17 @@ function createRevalidateRoute(config) {
212
307
  if (!result.ok) {
213
308
  return Response.json({ ok: false, reason: result.reason }, { status: 401 });
214
309
  }
215
- const INVARIANTS = ["/feed.xml", "/sitemap.xml", "/llms.txt"];
310
+ const INVARIANTS = [
311
+ "/feed.xml",
312
+ "/llms.txt",
313
+ "/sitemap.xml",
314
+ "/sitemap/static.xml",
315
+ "/sitemap/blog.xml",
316
+ "/sitemap/categories.xml",
317
+ // 작가 사이트맵 — 글 발행/해제로 작가 등장·lastmod 가 바뀐다(authors 토글 off 면
318
+ // 빈 사이트맵이라 무효화해도 무해).
319
+ "/sitemap/authors.xml"
320
+ ];
216
321
  let alsoRevalidate;
217
322
  let warning;
218
323
  if (config.alsoRevalidate) {
@@ -453,6 +558,41 @@ function createRedirectMiddleware(config) {
453
558
  };
454
559
  }
455
560
 
561
+ // src/routes-metadata.ts
562
+ import { resolvePostPath } from "@roottale/cms-core";
563
+ function buildPostMetadata(post, options = {}) {
564
+ const seo = post.seo ?? void 0;
565
+ const title = seo?.title || post.title;
566
+ const description = seo?.description || post.description || void 0;
567
+ const ogImage = seo?.ogImage || post.image || void 0;
568
+ let canonical = seo?.canonical || void 0;
569
+ if (!canonical && options.siteUrl) {
570
+ let path = options.path;
571
+ if (!path && options.collections && post.slug) {
572
+ path = resolvePostPath(
573
+ { slug: post.slug, terms: [], collectionKey: post.collectionKey ?? null },
574
+ options.collections
575
+ ) ?? void 0;
576
+ }
577
+ if (path) {
578
+ const normalized = path.startsWith("/") ? path : `/${path}`;
579
+ canonical = `${trimSlash(options.siteUrl)}${normalized}`;
580
+ }
581
+ }
582
+ const openGraph = { type: "article", title };
583
+ if (description) openGraph.description = description;
584
+ if (canonical) openGraph.url = canonical;
585
+ if (post.date) openGraph.publishedTime = post.date;
586
+ if (ogImage) openGraph.images = [{ url: ogImage, alt: title }];
587
+ const meta = { title, openGraph };
588
+ if (description) meta.description = description;
589
+ if (canonical) meta.alternates = { canonical };
590
+ if (seo?.noindex || seo?.nofollow) {
591
+ meta.robots = { index: !seo.noindex, follow: !seo.nofollow };
592
+ }
593
+ return meta;
594
+ }
595
+
456
596
  // src/routes.ts
457
597
  function postRedirectPath(post, requestedSlug, blogBasePath = "/blog") {
458
598
  let requested = requestedSlug;
@@ -471,7 +611,7 @@ function createFleetInfoRoute(config = {}) {
471
611
  site: config.site ?? env.VERCEL_PROJECT_PRODUCTION_URL ?? null,
472
612
  framework: "next",
473
613
  cms_client: CMS_CLIENT_VERSION,
474
- cms_renderer: true ? "0.35.0" : "dev",
614
+ cms_renderer: true ? "0.37.0" : "dev",
475
615
  build_sha: env.VERCEL_GIT_COMMIT_SHA?.slice(0, 7) ?? null
476
616
  },
477
617
  { headers: { "cache-control": "public, max-age=300" } }
@@ -481,6 +621,7 @@ function createFleetInfoRoute(config = {}) {
481
621
  export {
482
622
  OG_IMAGE_CONTENT_TYPE,
483
623
  OG_IMAGE_SIZE,
624
+ buildPostMetadata,
484
625
  createFeedRoute,
485
626
  createFleetInfoRoute,
486
627
  createLlmsTxtRoute,
@@ -488,6 +629,7 @@ export {
488
629
  createRedirectMiddleware,
489
630
  createRevalidateRoute,
490
631
  createSitemap,
632
+ createSitemapIndex,
491
633
  matchRedirect,
492
634
  normalizeRedirectPath,
493
635
  postRedirectPath,