@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/CHANGELOG.md +82 -0
- package/dist/{chunk-O6KHDNGH.js → chunk-FUA5H6JE.js} +1 -1
- package/dist/{chunk-O6KHDNGH.js.map → chunk-FUA5H6JE.js.map} +1 -1
- package/dist/{chunk-HPERSTZ2.js → chunk-VWGMLIY4.js} +64 -10
- package/dist/chunk-VWGMLIY4.js.map +1 -0
- package/dist/cms-public.css +64 -0
- package/dist/routes.d.ts +171 -7
- package/dist/routes.js +195 -53
- package/dist/routes.js.map +1 -1
- package/dist/server.d.ts +33 -2
- package/dist/server.js +2 -2
- package/dist/templates.js +2 -2
- package/package.json +3 -3
- package/dist/chunk-HPERSTZ2.js.map +0 -1
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
|
-
* `
|
|
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
|
-
*
|
|
81
|
-
*
|
|
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",
|
|
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
|
|
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", "/
|
|
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-
|
|
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 {
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 = [
|
|
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.
|
|
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,
|