@roottale/cms-renderer-next 0.66.0 → 0.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/{blog-filter-qE9Lj49C.d.ts → blog-filter-CS4GZws8.d.ts} +7 -0
- package/dist/{chunk-GDP2VGJR.js → chunk-3UBTE7BV.js} +1 -1
- package/dist/chunk-3UBTE7BV.js.map +1 -0
- package/dist/routes.d.ts +94 -11
- package/dist/routes.js +105 -25
- package/dist/routes.js.map +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1 -1
- package/package.json +3 -3
- package/dist/chunk-GDP2VGJR.js.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @roottale/cms-renderer-next
|
|
2
2
|
|
|
3
|
+
## 0.67.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 30d26ac: 공통 메타데이터 해석기·고유 경로 조회·Next SSR 빌더와 캐시 태그를 추가합니다. 기존 canonical과 강제 robots 정책을 보존하고 공유 이미지 대체 텍스트를 OG·X에 동일하게 적용합니다. 분류의 공유 전용 설정과 메타데이터 조회·수정 MCP 도구, Pro 하향 후 공개 적용 유지 계약을 문서와 예제에 반영합니다. 분류 색인 전환을 선언한 사이트가 공개 분류 응답의 `category_index_activated`를 확인한 뒤에만 분류 검색 제외를 적용하도록 안내합니다.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies [30d26ac]
|
|
12
|
+
- @roottale/cms-core@0.67.0
|
|
13
|
+
- @roottale/cms-client@0.67.0
|
|
14
|
+
|
|
3
15
|
## 0.66.0
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -17,6 +17,13 @@ interface BlogCategoryWithCount extends BlogCategory {
|
|
|
17
17
|
description?: string;
|
|
18
18
|
seoTitle?: string;
|
|
19
19
|
seoDescription?: string;
|
|
20
|
+
seoOgImage?: string;
|
|
21
|
+
seoOgImageAlt?: string;
|
|
22
|
+
seoNofollow?: boolean;
|
|
23
|
+
/** 서버 집계가 없으면 undefined. API 장애를 미승격(false)으로 바꾸지 않는다. */
|
|
24
|
+
hubPromoted?: boolean;
|
|
25
|
+
/** 공개 분류 API가 계산한 정규 경로. */
|
|
26
|
+
path?: string;
|
|
20
27
|
imageUrl?: string;
|
|
21
28
|
imageAlt?: string;
|
|
22
29
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/blog-filter.ts","../src/post-public-url.ts","../src/routes-shared.ts"],"sourcesContent":["/**\n * `RootTaleBlogList` 카테고리 필터 — 순수 헬퍼(서버 필터, client JS 불요).\n *\n * SSR-only 정책 유지: 필터는 `?category=<slug>` URL 파라미터로 동작하고(칩 =\n * 서버 렌더 `<a>` 링크), consumer 가 searchParams 의 category 를 `activeCategory`\n * 로 주입한다. hydration 없이 SEO 친화 + 공유 가능한 URL.\n */\n\nimport type { CmsPostContent, CmsTermRef } from \"@roottale/cms-client/server\";\n\nexport interface BlogCategory {\n name: string;\n slug: string;\n}\n\nexport interface BlogCategoryWithCount extends BlogCategory {\n count: number;\n description?: string;\n seoTitle?: string;\n seoDescription?: string;\n seoOgImage?: string;\n seoOgImageAlt?: string;\n seoNofollow?: boolean;\n /** 서버 집계가 없으면 undefined. API 장애를 미승격(false)으로 바꾸지 않는다. */\n hubPromoted?: boolean;\n /** 공개 분류 API가 계산한 정규 경로. */\n path?: string;\n imageUrl?: string;\n imageAlt?: string;\n}\n\nfunction categoryTerms(post: CmsPostContent): CmsTermRef[] {\n return (post.terms ?? []).filter((t) => t.taxonomy === \"category\");\n}\n\n/**\n * 글 목록에 등장하는 카테고리를 slug 기준 unique 로 수집. 서버가 보내는 정렬\n * (category → name asc)을 보존하기 위해 처음 등장 순서를 유지한다.\n */\nexport function collectCategories(posts: CmsPostContent[]): BlogCategory[] {\n return collectCategoriesWithCounts(posts).map(({ name, slug }) => ({\n name,\n slug,\n }));\n}\n\nexport function collectCategoriesWithCounts(\n posts: CmsPostContent[],\n): BlogCategoryWithCount[] {\n const bySlug = new Map<string, BlogCategoryWithCount>();\n const out: BlogCategoryWithCount[] = [];\n for (const post of posts) {\n for (const t of categoryTerms(post)) {\n const current = bySlug.get(t.slug);\n if (current) {\n current.count += 1;\n } else {\n const category = { name: t.name, slug: t.slug, count: 1 };\n bySlug.set(t.slug, category);\n out.push(category);\n }\n }\n }\n return out;\n}\n\n/** activeSlug 가 비면 전체 반환. 아니면 그 카테고리를 가진 글만. */\nexport function filterPostsByCategory(\n posts: CmsPostContent[],\n activeSlug: string | null | undefined,\n): CmsPostContent[] {\n if (!activeSlug) return posts;\n return posts.filter((p) => categoryTerms(p).some((t) => t.slug === activeSlug));\n}\n\n/** 기본 카테고리 링크: 전체 → `/blog`, 특정 → `/blog/categories/<slug>`. */\nexport function defaultCategoryHref(slug: string | null): string {\n return makeCategoryHref(\"/blog\", \"namespaced\")(slug);\n}\n\n/**\n * 카테고리 링크 함수 — 목록·카테고리 허브·빵부스러기의 `categoryHref` 로 넘긴다.\n * `\"direct\"` 는 RootTale 표준(`/blog/{category}` 허브, 글은 `/blog/{category}/{slug}`),\n * `\"namespaced\"` 는 종전 `/blog/categories/{slug}`. 전체(`null`)는 언제나 목록.\n */\nexport function makeCategoryHref(\n basePath: string,\n categoryPath: \"namespaced\" | \"direct\",\n): (slug: string | null) => string {\n const base = basePath.replace(/\\/+$/, \"\") || \"\";\n const list = base || \"/\";\n return (slug) => {\n if (!slug) return list;\n const encoded = encodeURIComponent(slug);\n return categoryPath === \"direct\" ? `${base}/${encoded}` : `${base}/categories/${encoded}`;\n };\n}\n\n// ── Collections (ADR-0060) — cms-core 라우팅 프리미티브 재export + 렌더 헬퍼 ──────\n//\n// 라우팅 코어(RouteCollection/resolvePostCollection/resolvePostUrl/postsForCollection/\n// validateCollections)는 `@roottale/cms-core` 에 있어 next/astro 가 공유한다\n// (ADR-0029 parity — codex consult `019ef577` 반영). 여기서는 next 소비자 편의를\n// 위해 재export + CmsPostContent 기반 스트림 스코프 카테고리 집계만 추가한다.\nimport {\n type RouteCollection,\n postsForCollection,\n} from \"@roottale/cms-core\";\n\nexport {\n type RouteCollection,\n type RoutablePost,\n isInCollectionMode,\n resolvePostCollection,\n resolvePostUrl,\n postsForCollection,\n postsInAnyCollection,\n validateCollections,\n} from \"@roottale/cms-core\";\n\n/**\n * 특정 collection 안에서 등장하는 카테고리 집계 — 그 스트림의 카테고리 칩/아카이브용.\n * **전체 정렬 리스트로 first-wins 해석**해 그 스트림에 최종 귀속된 글만 대상(한 글이\n * 여러 스트림 리스트에 중복되는 것을 방지 — codex 지적).\n */\nexport function collectCollectionCategories(\n posts: CmsPostContent[],\n collections: readonly RouteCollection[],\n key: string,\n): BlogCategoryWithCount[] {\n const owned = postsForCollection(posts, collections, key);\n const collection = collections.find((c) => c.key === key);\n const allowed = new Set(collection?.categories ?? []);\n return collectCategoriesWithCounts(owned).filter((c) => allowed.has(c.slug));\n}\n","/**\n * 글의 공개 URL — ADR-0105 Amendment 1.\n *\n * 플랫폼이 저장한 정규 공개 경로(`post.path`, 공개 API `path`)가 있으면 그것을 쓴다.\n * 없으면(구 서버·물질화 전) 지금까지처럼 컬렉션 규칙(`resolvePostUrl`)이나 레거시\n * `blogBasePath` 로 계산한다. 어느 유형에도 속하지 않는 레거시 글은 사이트 설정의\n * `blogBasePath` 가 진실이라 저장 경로보다 우선한다(플랫폼 폴백은 `/blog` 고정).\n */\nimport { resolvePostUrl, type RoutablePost, type RouteCollection } from \"@roottale/cms-core\";\n\nfunction trimSlash(value: string): string {\n return value.replace(/\\/+$/, \"\");\n}\n\nexport interface PostPublicUrlOptions {\n readonly siteUrl: string;\n readonly collections: RouteCollection[] | undefined;\n readonly inCollectionMode: boolean;\n readonly blogBasePath: string;\n}\n\n/** 공개 API 글의 구조적 최소형 — 구 cms-client 에는 `path`·`modelKey` 가 없어도 된다. */\nexport interface PublicUrlPost extends RoutablePost {\n readonly path?: string | null;\n readonly modelKey?: string | null;\n}\n\n/** 소속(모델·컬렉션)이 있는 글의 저장 경로. 레거시 미소속 글은 사이트 설정을 따른다. */\nexport function storedPostPath(\n post: Pick<PublicUrlPost, \"path\" | \"collectionKey\" | \"modelKey\">,\n): string | null {\n if (typeof post.path !== \"string\" || !post.path.startsWith(\"/\")) return null;\n if (!post.collectionKey && !post.modelKey) return null;\n return post.path;\n}\n\n/** 사이트맵·피드·llms 가 함께 쓰는 글 URL. 저장 경로 → 컬렉션 규칙 → 레거시 순. */\nexport function postPublicUrl(\n post: PublicUrlPost,\n options: PostPublicUrlOptions,\n): string | null {\n const siteUrl = trimSlash(options.siteUrl);\n const stored = storedPostPath(post);\n if (stored) return `${siteUrl}${stored}`;\n if (options.inCollectionMode) {\n return resolvePostUrl(siteUrl, post, options.collections!);\n }\n return `${siteUrl}${trimSlash(options.blogBasePath)}/${post.slug}`;\n}\n","/**\n * `routes` 공통 — config 타입, collections resolve, fetch 헬퍼. feed/sitemap/llms/\n * revalidate/og 모듈이 공유한다(순환 import 회피). 공개 표면은 `routes.ts` 배럴.\n */\n\nimport { fetchPosts, type CmsPostContent } from \"@roottale/cms-client/server\";\nimport type { RouteCollection } from \"@roottale/cms-core\";\n\nexport type { RouteCollection };\n// 소비자 가드용 재export (`/blog/[slug]` 에서 스트림 누출 차단 등) — ADR-0060.\nexport { resolvePostCollection, resolvePostUrl } from \"@roottale/cms-core\";\n\nexport interface RootTaleSiteConfig {\n /** `rtlk_cust_*` — 블로그 조회와 같은 키. 서버에서만(절대 브라우저 금지). */\n apiKey: string;\n /** API base 오버라이드. 기본 `https://api.roottale.com`. */\n apiBase?: string;\n /** 사이트 절대 URL (뒤 슬래시 없이). 예: `https://kjmtax.roottale.app`. */\n siteUrl: string;\n /** 피드/사이트 제목. */\n title: string;\n description?: string;\n /** 기본 \"ko-KR\". */\n language?: string;\n /** RSS의 WebSub discovery link. 미지정이면 기존 RSS 출력 유지. */\n webSubHubUrl?: string;\n /** 블로그 base path. 기본 \"/blog\". 레거시 단일 스트림 — `collections` 미사용 시. */\n blogBasePath?: string;\n /**\n * 카테고리 허브 주소 형식(레거시 단일 스트림용, `collections` 미사용 시).\n * `\"namespaced\"`(기본) = `{blogBasePath}/categories/{slug}`,\n * `\"direct\"` = `{blogBasePath}/{slug}` — RootTale 표준(`/blog/{category}/{slug}` 글 주소와\n * 짝). collection 모드는 각 컬렉션의 `categoryPath` 를 따른다.\n */\n categoryPath?: \"namespaced\" | \"direct\";\n /**\n * 다중 스트림 라우팅 (ADR-0060). **길이 > 0 이면 collection 모드** — feed/sitemap/\n * llms.txt 가 글을 소속 스트림 basePath 로 라우팅하고, 어느 스트림에도 안 속한 글은\n * 제외한다. 비우거나 미전달 = 레거시 단일 `blogBasePath`(/blog) 동작(하위호환).\n * 선언 순서 = 우선순위(first-wins).\n *\n * 배열 또는 **async resolver**(`() => Promise<RouteCollection[]>`)를 받는다 — 후자는\n * 사이트가 `@roottale/cms-client` `fetchCollections()` 로 admin 설정을 가져올 때 쓴다.\n * resolver 가 throw 하면 fail-soft(레거시 단일 /blog).\n */\n collections?: CollectionsInput;\n}\n\n/** collections 입력 — 정적 배열 또는 async resolver(admin 설정 fetch 용). */\nexport type CollectionsInput =\n | RouteCollection[]\n | (() => RouteCollection[] | Promise<RouteCollection[]>);\n\n/** resolve 결과 — `failed` 로 \"resolver throw\" 를 \"collections 미설정\" 과 구분한다. */\nexport async function resolveCollectionsResult(\n input: CollectionsInput | undefined,\n): Promise<{ collections: RouteCollection[] | undefined; failed: boolean }> {\n if (typeof input === \"function\") {\n try {\n return { collections: await input(), failed: false };\n } catch (err) {\n console.error(\n \"[@roottale/cms-renderer-next/routes] collections resolver 실패:\",\n err,\n );\n return { collections: undefined, failed: true };\n }\n }\n return { collections: input, failed: false };\n}\n\n/**\n * resolver 면 호출(실패 시 undefined → 레거시), 배열이면 그대로. GET 팩토리(feed/\n * sitemap/llms)는 always-200 철학상 실패를 레거시 /blog 로 흡수 — revalidate 만\n * `resolveCollectionsResult` 로 실패를 구분해 under-invalidation 을 막는다.\n */\nexport async function resolveCollections(\n input: CollectionsInput | undefined,\n): Promise<RouteCollection[] | undefined> {\n return (await resolveCollectionsResult(input)).collections;\n}\n\nexport function trimSlash(u: string): string {\n return u.replace(/\\/+$/, \"\");\n}\n\nexport async function safeFetchPosts(\n config: RootTaleSiteConfig,\n limit: number,\n): Promise<CmsPostContent[]> {\n try {\n const page = await fetchPosts({\n apiKey: config.apiKey,\n baseUrl: config.apiBase,\n type: \"post\",\n limit,\n });\n return page.items;\n } catch (err) {\n // 빌드/런타임 fetch 실패 시 빈 목록 — 라우트는 항상 정상 응답(404 방지).\n console.error(\"[@roottale/cms-renderer-next/routes] fetchPosts 실패:\", err);\n return [];\n }\n}\n"],"mappings":"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","names":["postsForCollection","resolvePostUrl","resolvePostCollection","resolvePostUrl","trimSlash"]}
|
package/dist/routes.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { CmsPostContent, RootTaleRedirect, CmsPostTranslationRef } from '@roottale/cms-client/server';
|
|
2
|
-
import { RouteCollection, RoutablePost } from '@roottale/cms-core';
|
|
3
|
-
export { RouteCollection, resolvePostCollection, resolvePostUrl } from '@roottale/cms-core';
|
|
4
|
-
import { B as BlogCategoryWithCount } from './blog-filter-
|
|
5
|
-
export { m as makeCategoryHref } from './blog-filter-
|
|
2
|
+
import { RouteCollection, MetadataRobotsPolicy, MetadataValues, MetadataResolutionInput, RoutablePost } from '@roottale/cms-core';
|
|
3
|
+
export { RouteCollection, normalizeRedirectPath, resolvePostCollection, resolvePostUrl } from '@roottale/cms-core';
|
|
4
|
+
import { B as BlogCategoryWithCount } from './blog-filter-CS4GZws8.js';
|
|
5
|
+
export { m as makeCategoryHref } from './blog-filter-CS4GZws8.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* `routes` 공통 — config 타입, collections resolve, fetch 헬퍼. feed/sitemap/llms/
|
|
@@ -428,12 +428,6 @@ interface RedirectMiddlewareConfig {
|
|
|
428
428
|
/** 규칙 캐시 TTL(ms). 기본 60_000 (1분). */
|
|
429
429
|
cacheTtlMs?: number;
|
|
430
430
|
}
|
|
431
|
-
/**
|
|
432
|
-
* 경로 정규화 — 앞 슬래시 보장, 뒤 슬래시 제거(루트 제외), 쿼리/해시 제거,
|
|
433
|
-
* percent-decode. 출발 경로 비교·한글 slug 매칭·루프 탐지에 요청·규칙 양쪽에
|
|
434
|
-
* 동일 적용한다. 쿼리 제거는 `/a → /a?x` 류의 무한 루프 방지에 중요.
|
|
435
|
-
*/
|
|
436
|
-
declare function normalizeRedirectPath(path: string): string;
|
|
437
431
|
/**
|
|
438
432
|
* 요청 경로에 맞는 규칙을 찾는다(순수 함수, fetch 비의존 — 테스트 용이).
|
|
439
433
|
* 정확 경로 일치만(와일드카드 없음).
|
|
@@ -541,6 +535,12 @@ interface PostMetadataInput {
|
|
|
541
535
|
authors?: readonly string[] | null;
|
|
542
536
|
}
|
|
543
537
|
interface BuildPostMetadataOptions {
|
|
538
|
+
/** 기존 canonical override는 기본 유지. 코드가 URL을 소유하는 사이트는 false로 지정한다. */
|
|
539
|
+
allowCanonicalOverride?: boolean;
|
|
540
|
+
/** 미리보기·비공개 등의 강제 제한은 직접 SEO 설정으로 해제하지 않는다. */
|
|
541
|
+
robots?: MetadataRobotsPolicy;
|
|
542
|
+
/** 글 값도 비었을 때 적용할 사이트 설명·공유 이미지. */
|
|
543
|
+
siteDefaults?: MetadataValues;
|
|
544
544
|
/**
|
|
545
545
|
* self-canonical 기본값 origin (예: `https://example.com`).
|
|
546
546
|
* `seo.canonical` override 가 있으면 무시된다. `path` 또는 `collections` 와
|
|
@@ -711,6 +711,89 @@ declare function buildPostMetadata(post: PostMetadataInput, options?: BuildPostM
|
|
|
711
711
|
*/
|
|
712
712
|
declare function buildHreflangLanguages(translations: readonly CmsPostTranslationRef[] | undefined, siteUrl: string, locales: PostLocaleOptions): Record<string, string> | undefined;
|
|
713
713
|
|
|
714
|
+
interface MetadataGoogleBotPolicy {
|
|
715
|
+
index?: boolean;
|
|
716
|
+
follow?: boolean;
|
|
717
|
+
noimageindex?: boolean;
|
|
718
|
+
"max-image-preview"?: "none" | "standard" | "large";
|
|
719
|
+
"max-snippet"?: number;
|
|
720
|
+
"max-video-preview"?: number;
|
|
721
|
+
}
|
|
722
|
+
interface BuildPageMetadataOptions {
|
|
723
|
+
siteUrl?: string;
|
|
724
|
+
path?: string;
|
|
725
|
+
/** 문자열은 사이트의 최종 정책. null이면 생략하고, undefined이면 siteUrl+path를 사용한다. */
|
|
726
|
+
canonical?: string | null;
|
|
727
|
+
/** 예: " | 사이트 이름". 이미 붙어 있거나 제목이 사이트 이름이면 다시 붙이지 않는다. */
|
|
728
|
+
titleSuffix?: string;
|
|
729
|
+
openGraphType?: "website" | "article";
|
|
730
|
+
/** 사이트가 기존에 적용하던 봇별 제한. false 제한은 CMS 설정으로 해제하지 않는다. */
|
|
731
|
+
googleBot?: MetadataGoogleBotPolicy;
|
|
732
|
+
/** 실제 SSR 결과와 현재 저장 버전을 대조하는 공개 확인 표식. */
|
|
733
|
+
verification?: {
|
|
734
|
+
routeKey: string;
|
|
735
|
+
locale: string;
|
|
736
|
+
version?: string;
|
|
737
|
+
contractChecksum?: string;
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
interface PageMetadata {
|
|
741
|
+
title: {
|
|
742
|
+
absolute: string;
|
|
743
|
+
};
|
|
744
|
+
description?: string;
|
|
745
|
+
alternates?: {
|
|
746
|
+
canonical: string;
|
|
747
|
+
};
|
|
748
|
+
robots: {
|
|
749
|
+
index: boolean;
|
|
750
|
+
follow: boolean;
|
|
751
|
+
googleBot?: MetadataGoogleBotPolicy;
|
|
752
|
+
};
|
|
753
|
+
openGraph: {
|
|
754
|
+
type: "website" | "article";
|
|
755
|
+
title: string;
|
|
756
|
+
description?: string;
|
|
757
|
+
url?: string;
|
|
758
|
+
images?: {
|
|
759
|
+
url: string;
|
|
760
|
+
alt: string;
|
|
761
|
+
}[];
|
|
762
|
+
};
|
|
763
|
+
twitter: {
|
|
764
|
+
card: "summary_large_image";
|
|
765
|
+
title: string;
|
|
766
|
+
description?: string;
|
|
767
|
+
images?: {
|
|
768
|
+
url: string;
|
|
769
|
+
alt: string;
|
|
770
|
+
}[];
|
|
771
|
+
};
|
|
772
|
+
other?: Record<string, string>;
|
|
773
|
+
}
|
|
774
|
+
/** 글·분류·코드 페이지의 동일한 최종값을 Next Metadata로 변환한다. 네트워크 호출은 없다. */
|
|
775
|
+
declare function buildPageMetadata(input: MetadataResolutionInput, options?: BuildPageMetadataOptions): PageMetadata;
|
|
776
|
+
|
|
777
|
+
interface SitemapMetadataPolicy {
|
|
778
|
+
/** SSR buildPageMetadata에 전달한 것과 같은 입력을 사용한다. */
|
|
779
|
+
metadata?: MetadataResolutionInput;
|
|
780
|
+
/** 사이트의 최종 canonical. 다른 URL이 대표 주소면 현재 entry를 제외한다. */
|
|
781
|
+
canonical?: string | null;
|
|
782
|
+
published?: boolean;
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* 이미 조회한 메타데이터·활성 이동 규칙을 사용한다. 네트워크 호출을 하지 않으며
|
|
786
|
+
* 조회 실패와 빈 설정을 구별하는 책임은 호출자에게 있다(미확인 값은 기존 포함 유지).
|
|
787
|
+
*/
|
|
788
|
+
declare function filterMetadataSitemapEntries<T extends {
|
|
789
|
+
url: string;
|
|
790
|
+
}>(entries: readonly T[], policyFor: (entry: T) => SitemapMetadataPolicy | undefined, options?: {
|
|
791
|
+
redirects?: readonly {
|
|
792
|
+
fromPath: string;
|
|
793
|
+
enabled?: boolean;
|
|
794
|
+
}[];
|
|
795
|
+
}): T[];
|
|
796
|
+
|
|
714
797
|
/**
|
|
715
798
|
* 글의 공개 URL — ADR-0105 Amendment 1.
|
|
716
799
|
*
|
|
@@ -1076,4 +1159,4 @@ interface FleetInfoRouteConfig {
|
|
|
1076
1159
|
*/
|
|
1077
1160
|
declare function createFleetInfoRoute(config?: FleetInfoRouteConfig): () => Promise<Response>;
|
|
1078
1161
|
|
|
1079
|
-
export { ARCHIVE_POST_COUNT_LIMIT, type AlwaysNoindexArchiveKind, type ArchiveCategoriesConfig, type ArchiveCategoriesOptions, type ArchiveIndexDecision, type ArchiveIndexInput, type ArchiveKind, type ArchiveMetadata, type ArchiveMetadataFields, type ArchiveMetadataInput, type ArchivePromotion, type BuildArchiveMetadataOptions, type BuildPostMetadataOptions, type CollectionsInput, type ExposureRouteConfig, type FleetInfoRouteConfig, type ImageResponseLike, type LlmsTxtLink, type LlmsTxtRouteConfig, type LlmsTxtSection, OG_IMAGE_CONTENT_TYPE, OG_IMAGE_SIZE, type OgImageRouteConfig, type PostLocaleOptions, type PostMetadata, type PostMetadataInput, type PostSeoOverrides, type PreviewMetadata, type PromotableArchiveKind, type PublicUrlPost, type RedirectMiddlewareConfig, type RevalidateRejectReason, type RevalidateRelatedPathsContext, type RevalidateRouteConfig, type RobotsMeta, type RootTaleSiteConfig, type SitemapEntry, type SitemapIndexOptions, type SitemapSectionId, buildArchiveMetadata, buildHreflangLanguages, buildPostMetadata, buildPreviewMetadata, createExposureRoute, createFeedRoute, createFleetInfoRoute, createLlmsTxtRoute, createPostOgImage, createRedirectMiddleware, createRevalidateRoute, createRevalidateRouteHandlers, createSitemap, createSitemapIndex, decideArchiveIndex, fetchBlogArchiveCategories, fetchBlogArchivePosts,
|
|
1162
|
+
export { ARCHIVE_POST_COUNT_LIMIT, type AlwaysNoindexArchiveKind, type ArchiveCategoriesConfig, type ArchiveCategoriesOptions, type ArchiveIndexDecision, type ArchiveIndexInput, type ArchiveKind, type ArchiveMetadata, type ArchiveMetadataFields, type ArchiveMetadataInput, type ArchivePromotion, type BuildArchiveMetadataOptions, type BuildPageMetadataOptions, type BuildPostMetadataOptions, type CollectionsInput, type ExposureRouteConfig, type FleetInfoRouteConfig, type ImageResponseLike, type LlmsTxtLink, type LlmsTxtRouteConfig, type LlmsTxtSection, type MetadataGoogleBotPolicy, OG_IMAGE_CONTENT_TYPE, OG_IMAGE_SIZE, type OgImageRouteConfig, type PageMetadata, type PostLocaleOptions, type PostMetadata, type PostMetadataInput, type PostSeoOverrides, type PreviewMetadata, type PromotableArchiveKind, type PublicUrlPost, type RedirectMiddlewareConfig, type RevalidateRejectReason, type RevalidateRelatedPathsContext, type RevalidateRouteConfig, type RobotsMeta, type RootTaleSiteConfig, type SitemapEntry, type SitemapIndexOptions, type SitemapMetadataPolicy, type SitemapSectionId, buildArchiveMetadata, buildHreflangLanguages, buildPageMetadata, buildPostMetadata, buildPreviewMetadata, createExposureRoute, createFeedRoute, createFleetInfoRoute, createLlmsTxtRoute, createPostOgImage, createRedirectMiddleware, createRevalidateRoute, createRevalidateRouteHandlers, createSitemap, createSitemapIndex, decideArchiveIndex, fetchBlogArchiveCategories, fetchBlogArchivePosts, filterMetadataSitemapEntries, matchRedirect, postRedirectPath, resolveAuthorPromotion, resolveCategoryPromotion, resolveCollections, resolveCollectionsResult, resolveRobotsMeta, safeFetchPosts, storedPostPath, trimSlash };
|
package/dist/routes.js
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
safeFetchPosts,
|
|
11
11
|
storedPostPath,
|
|
12
12
|
trimSlash
|
|
13
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-3UBTE7BV.js";
|
|
14
14
|
import {
|
|
15
15
|
exposureHomePaths,
|
|
16
16
|
isExposureHome
|
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
} from "@roottale/cms-core";
|
|
31
31
|
|
|
32
32
|
// src/routes-metadata.ts
|
|
33
|
-
import { resolvePostPath } from "@roottale/cms-core";
|
|
33
|
+
import { resolveMetadata, resolvePostPath } from "@roottale/cms-core";
|
|
34
34
|
function resolveRobotsMeta(flags) {
|
|
35
35
|
if (!flags.noindex && !flags.nofollow) return void 0;
|
|
36
36
|
return { index: !flags.noindex, follow: !flags.nofollow };
|
|
@@ -45,10 +45,17 @@ function buildPreviewMetadata(input) {
|
|
|
45
45
|
}
|
|
46
46
|
function buildPostMetadata(post, options = {}) {
|
|
47
47
|
const seo = post.seo ?? void 0;
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
const resolved = resolveMetadata({
|
|
49
|
+
overrides: seo,
|
|
50
|
+
defaults: { title: post.title, ...post.description ? { description: post.description } : {}, ...post.image ? { ogImage: post.image } : {} },
|
|
51
|
+
siteDefaults: options.siteDefaults,
|
|
52
|
+
robots: options.robots,
|
|
53
|
+
imageBaseUrl: options.siteUrl
|
|
54
|
+
});
|
|
55
|
+
const title = resolved.title ?? post.title;
|
|
56
|
+
const description = resolved.description;
|
|
57
|
+
const ogImage = resolved.ogImage;
|
|
58
|
+
let canonical = options.allowCanonicalOverride === false ? void 0 : seo?.canonical || void 0;
|
|
52
59
|
if (!canonical && options.siteUrl) {
|
|
53
60
|
let path = options.path ?? storedPostPath({
|
|
54
61
|
path: post.path,
|
|
@@ -75,7 +82,7 @@ function buildPostMetadata(post, options = {}) {
|
|
|
75
82
|
if (post.tags && post.tags.length > 0) openGraph.tags = [...post.tags];
|
|
76
83
|
if (post.authors && post.authors.length > 0) openGraph.authors = [...post.authors];
|
|
77
84
|
if (ogImage) {
|
|
78
|
-
openGraph.images = [{ url: ogImage, alt:
|
|
85
|
+
openGraph.images = [{ url: ogImage, alt: resolved.ogImageAlt ?? title }];
|
|
79
86
|
}
|
|
80
87
|
const meta = { title, openGraph };
|
|
81
88
|
if (description) meta.description = description;
|
|
@@ -101,8 +108,8 @@ function buildPostMetadata(post, options = {}) {
|
|
|
101
108
|
};
|
|
102
109
|
}
|
|
103
110
|
const baseRobots = resolveRobotsMeta({
|
|
104
|
-
...
|
|
105
|
-
...
|
|
111
|
+
...resolved.noindex !== void 0 && { noindex: resolved.noindex },
|
|
112
|
+
...resolved.nofollow !== void 0 && { nofollow: resolved.nofollow }
|
|
106
113
|
});
|
|
107
114
|
const index = baseRobots?.index ?? true;
|
|
108
115
|
const follow = baseRobots?.follow ?? true;
|
|
@@ -209,7 +216,9 @@ import {
|
|
|
209
216
|
import {
|
|
210
217
|
isInCollectionMode,
|
|
211
218
|
resolveCategoryPath,
|
|
212
|
-
resolvePostCollection as resolvePostCollection2
|
|
219
|
+
resolvePostCollection as resolvePostCollection2,
|
|
220
|
+
readMetadataValues,
|
|
221
|
+
resolveMetadata as resolveMetadata2
|
|
213
222
|
} from "@roottale/cms-core";
|
|
214
223
|
|
|
215
224
|
// src/archive-categories.ts
|
|
@@ -251,6 +260,11 @@ async function fetchServerCategoryCounts(config, options) {
|
|
|
251
260
|
...category.description ? { description: category.description } : {},
|
|
252
261
|
...category.seoTitle ? { seoTitle: category.seoTitle } : {},
|
|
253
262
|
...category.seoDescription ? { seoDescription: category.seoDescription } : {},
|
|
263
|
+
...category.seoOgImage ? { seoOgImage: category.seoOgImage } : {},
|
|
264
|
+
...category.seoOgImageAlt ? { seoOgImageAlt: category.seoOgImageAlt } : {},
|
|
265
|
+
...typeof category.seoNofollow === "boolean" ? { seoNofollow: category.seoNofollow } : {},
|
|
266
|
+
...typeof category.hubPromoted === "boolean" ? { hubPromoted: category.hubPromoted } : {},
|
|
267
|
+
...category.path ? { path: category.path } : {},
|
|
254
268
|
...category.imageUrl ? { imageUrl: category.imageUrl } : {},
|
|
255
269
|
...category.imageAlt ? { imageAlt: category.imageAlt } : {}
|
|
256
270
|
}));
|
|
@@ -347,6 +361,7 @@ function postEntries(config, siteUrl, posts, collections, settings, defaultLocal
|
|
|
347
361
|
const inCollection = isInCollectionMode(collections);
|
|
348
362
|
const blogBase = config.blogBasePath ?? "/blog";
|
|
349
363
|
return posts.flatMap((p) => {
|
|
364
|
+
if (resolveMetadata2({ overrides: readMetadataValues(p.metaJson.seo) }).noindex === true) return [];
|
|
350
365
|
const url = postPublicUrl(p, {
|
|
351
366
|
siteUrl,
|
|
352
367
|
collections,
|
|
@@ -939,18 +954,9 @@ function createPostOgImage(config, deps) {
|
|
|
939
954
|
}
|
|
940
955
|
|
|
941
956
|
// src/routes-redirects.ts
|
|
957
|
+
import { normalizeRedirectPath, normalizeRedirectSource, normalizeRedirectTarget, isProtectedRedirectPath } from "@roottale/cms-core";
|
|
958
|
+
import { normalizeRedirectPath as normalizeRedirectPath2 } from "@roottale/cms-core";
|
|
942
959
|
import { fetchRedirects } from "@roottale/cms-client/server";
|
|
943
|
-
function normalizeRedirectPath(path) {
|
|
944
|
-
let p = path;
|
|
945
|
-
try {
|
|
946
|
-
p = decodeURIComponent(path);
|
|
947
|
-
} catch {
|
|
948
|
-
}
|
|
949
|
-
p = p.split(/[?#]/)[0] ?? p;
|
|
950
|
-
if (!p.startsWith("/")) p = `/${p}`;
|
|
951
|
-
if (p.length > 1) p = p.replace(/\/+$/, "");
|
|
952
|
-
return p === "" ? "/" : p;
|
|
953
|
-
}
|
|
954
960
|
function isAbsoluteUrl(target) {
|
|
955
961
|
return /^https?:\/\//i.test(target);
|
|
956
962
|
}
|
|
@@ -965,10 +971,13 @@ function internalTargetPath(target, origin) {
|
|
|
965
971
|
}
|
|
966
972
|
}
|
|
967
973
|
function matchRedirect(redirects, pathname, origin) {
|
|
974
|
+
if (isProtectedRedirectPath(pathname)) return null;
|
|
968
975
|
const byFrom = /* @__PURE__ */ new Map();
|
|
969
976
|
for (const r of redirects) {
|
|
970
|
-
const
|
|
971
|
-
|
|
977
|
+
const source = normalizeRedirectSource(r.fromPath);
|
|
978
|
+
const target = normalizeRedirectTarget(r.toTarget);
|
|
979
|
+
if (!source.ok || !target.ok) continue;
|
|
980
|
+
if (!byFrom.has(source.value)) byFrom.set(source.value, { ...r, toTarget: target.value });
|
|
972
981
|
}
|
|
973
982
|
const start = normalizeRedirectPath(pathname);
|
|
974
983
|
const visited = /* @__PURE__ */ new Set([start]);
|
|
@@ -1040,6 +1049,75 @@ function createRedirectMiddleware(config) {
|
|
|
1040
1049
|
};
|
|
1041
1050
|
}
|
|
1042
1051
|
|
|
1052
|
+
// src/routes-page-metadata.ts
|
|
1053
|
+
import { resolveMetadata as resolveMetadata3 } from "@roottale/cms-core";
|
|
1054
|
+
function buildPageMetadata(input, options = {}) {
|
|
1055
|
+
const canonical = options.canonical === void 0 ? options.siteUrl && options.path ? new URL(options.path, options.siteUrl).href : void 0 : options.canonical ?? void 0;
|
|
1056
|
+
const values = resolveMetadata3({ ...input, imageBaseUrl: input.imageBaseUrl ?? options.siteUrl ?? canonical });
|
|
1057
|
+
const baseTitle = values.title ?? "";
|
|
1058
|
+
const suffix = options.titleSuffix ?? "";
|
|
1059
|
+
const siteName = suffix.replace(/^[\s|·—–\-:]+/, "").trim();
|
|
1060
|
+
const title = suffix && baseTitle !== siteName && !baseTitle.endsWith(suffix) ? `${baseTitle}${suffix}` : baseTitle;
|
|
1061
|
+
const description = values.description;
|
|
1062
|
+
const images = values.ogImage ? [{ url: values.ogImage, alt: values.ogImageAlt ?? title }] : void 0;
|
|
1063
|
+
const index = values.noindex !== true;
|
|
1064
|
+
const follow = values.nofollow !== true;
|
|
1065
|
+
return {
|
|
1066
|
+
title: { absolute: title },
|
|
1067
|
+
...description ? { description } : {},
|
|
1068
|
+
...canonical ? { alternates: { canonical } } : {},
|
|
1069
|
+
robots: {
|
|
1070
|
+
index,
|
|
1071
|
+
follow,
|
|
1072
|
+
...options.googleBot ? { googleBot: {
|
|
1073
|
+
...options.googleBot,
|
|
1074
|
+
index: index && options.googleBot.index !== false,
|
|
1075
|
+
follow: follow && options.googleBot.follow !== false
|
|
1076
|
+
} } : {}
|
|
1077
|
+
},
|
|
1078
|
+
openGraph: {
|
|
1079
|
+
type: options.openGraphType ?? "website",
|
|
1080
|
+
title,
|
|
1081
|
+
...description ? { description } : {},
|
|
1082
|
+
...canonical ? { url: canonical } : {},
|
|
1083
|
+
...images ? { images } : {}
|
|
1084
|
+
},
|
|
1085
|
+
twitter: { card: "summary_large_image", title, ...description ? { description } : {}, ...images ? { images } : {} },
|
|
1086
|
+
...options.verification ? { other: {
|
|
1087
|
+
"rt:metadata-route-key": options.verification.routeKey,
|
|
1088
|
+
"rt:metadata-locale": options.verification.locale,
|
|
1089
|
+
...options.verification.version ? { "rt:metadata-version": options.verification.version } : {},
|
|
1090
|
+
...options.verification.contractChecksum ? { "rt:metadata-contract": options.verification.contractChecksum } : {}
|
|
1091
|
+
} } : {}
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// src/routes-metadata-sitemap.ts
|
|
1096
|
+
import { resolveMetadata as resolveMetadata4 } from "@roottale/cms-core";
|
|
1097
|
+
function filterMetadataSitemapEntries(entries, policyFor, options = {}) {
|
|
1098
|
+
const redirected = new Set((options.redirects ?? []).filter((rule) => rule.enabled !== false).map((rule) => normalizeRedirectPath2(rule.fromPath)));
|
|
1099
|
+
return entries.filter((entry) => {
|
|
1100
|
+
let url;
|
|
1101
|
+
try {
|
|
1102
|
+
url = new URL(entry.url);
|
|
1103
|
+
} catch {
|
|
1104
|
+
return false;
|
|
1105
|
+
}
|
|
1106
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) return false;
|
|
1107
|
+
if (redirected.has(normalizeRedirectPath2(url.pathname))) return false;
|
|
1108
|
+
const policy = policyFor(entry);
|
|
1109
|
+
if (policy?.published === false || resolveMetadata4(policy?.metadata ?? {}).noindex === true) return false;
|
|
1110
|
+
if (policy?.canonical) {
|
|
1111
|
+
try {
|
|
1112
|
+
if (new URL(policy.canonical, url).href !== url.href) return false;
|
|
1113
|
+
} catch {
|
|
1114
|
+
return false;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
return true;
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1043
1121
|
// src/routes.ts
|
|
1044
1122
|
function postRedirectPath(post, requestedSlug, blogBasePath = "/blog") {
|
|
1045
1123
|
let requested = requestedSlug;
|
|
@@ -1058,7 +1136,7 @@ function createFleetInfoRoute(config = {}) {
|
|
|
1058
1136
|
site: config.site ?? env.VERCEL_PROJECT_PRODUCTION_URL ?? null,
|
|
1059
1137
|
framework: "next",
|
|
1060
1138
|
cms_client: CMS_CLIENT_VERSION,
|
|
1061
|
-
cms_renderer: true ? "0.
|
|
1139
|
+
cms_renderer: true ? "0.67.0" : "dev",
|
|
1062
1140
|
build_sha: env.VERCEL_GIT_COMMIT_SHA?.slice(0, 7) ?? null,
|
|
1063
1141
|
...config.exposures ? { exposures: config.exposures } : {}
|
|
1064
1142
|
},
|
|
@@ -1072,6 +1150,7 @@ export {
|
|
|
1072
1150
|
OG_IMAGE_SIZE,
|
|
1073
1151
|
buildArchiveMetadata,
|
|
1074
1152
|
buildHreflangLanguages,
|
|
1153
|
+
buildPageMetadata,
|
|
1075
1154
|
buildPostMetadata,
|
|
1076
1155
|
buildPreviewMetadata,
|
|
1077
1156
|
createExposureRoute,
|
|
@@ -1087,9 +1166,10 @@ export {
|
|
|
1087
1166
|
decideArchiveIndex,
|
|
1088
1167
|
fetchBlogArchiveCategories,
|
|
1089
1168
|
fetchBlogArchivePosts,
|
|
1169
|
+
filterMetadataSitemapEntries,
|
|
1090
1170
|
makeCategoryHref,
|
|
1091
1171
|
matchRedirect,
|
|
1092
|
-
normalizeRedirectPath,
|
|
1172
|
+
normalizeRedirectPath2 as normalizeRedirectPath,
|
|
1093
1173
|
postRedirectPath,
|
|
1094
1174
|
resolveAuthorPromotion,
|
|
1095
1175
|
resolveCategoryPromotion,
|