@roottale/cms-core 0.53.1 → 0.56.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/index.d.cts CHANGED
@@ -83,7 +83,8 @@ declare function bindingSrcValue(value: unknown): string | undefined;
83
83
  *
84
84
  * 설계(codex consult `019ef577` 반영):
85
85
  * - **활성화 = `collections.length > 0`.** 빈 배열·미전달 = 레거시 단일 /blog.
86
- * - `RouteCollection` 은 **라우팅 최소셋**만(key/basePath/categories/feed/archives).
86
+ * - `RouteCollection` 은 **라우팅 최소셋**만(key/basePath/categories/feed/archives와
87
+ * 경로 정책) 가진다.
87
88
  * label/layout/icon 등 표시·작성 메타는 templates `CollectionSchema` 에 둔다.
88
89
  */
89
90
  /** 콘텐츠 스트림 1개 — sitemap/feed/아카이브 파생의 라우팅 단위. */
@@ -118,6 +119,20 @@ interface RouteCollection {
118
119
  * — 소속과 상세 URL 존재 여부는 별개(M4 원칙 연장).
119
120
  */
120
121
  readonly detail?: boolean;
122
+ /**
123
+ * 글 상세 주소 형식. `flat`(기본) = `${basePath}/{postSlug}`,
124
+ * `category` = `${basePath}/{categorySlug}/{postSlug}`.
125
+ * category 모드는 URL을 하나로 결정해야 하므로 `categoryCardinality: "exactly-one"`
126
+ * 과 함께 쓴다.
127
+ */
128
+ readonly detailPath?: "flat" | "category";
129
+ /**
130
+ * 카테고리 허브 주소 형식. `namespaced`(기본) =
131
+ * `${basePath}/categories/{slug}`, `direct` = `${basePath}/{slug}`.
132
+ */
133
+ readonly categoryPath?: "namespaced" | "direct";
134
+ /** 글에 허용하는 category term 개수. 기본 `multiple`(기존 동작). */
135
+ readonly categoryCardinality?: "multiple" | "exactly-one";
121
136
  }
122
137
  /** resolve 입력 — 글의 구조적 최소 형태(cms-client `CmsPostContent` 와 구조 호환). */
123
138
  interface RoutablePost {
@@ -149,6 +164,14 @@ declare function resolvePostCollection(post: RoutablePost, collections: readonly
149
164
  * (주소 없는 유형)이면 null. slug 는 percent-encoding (한글 slug 안전).
150
165
  */
151
166
  declare function resolvePostUrl(siteUrl: string, post: RoutablePost, collections: readonly RouteCollection[]): string | null;
167
+ /**
168
+ * category 기반 상세 주소에 사용할 단일 category slug. collection 에 연결된
169
+ * category만 세고 정확히 1개일 때만 반환한다. 0개·2개 이상이면 정규 URL을
170
+ * 임의 선택하지 않고 null 로 닫는다.
171
+ */
172
+ declare function resolvePostCategorySlug(post: RoutablePost, collection: RouteCollection): string | null;
173
+ /** 카테고리 허브 경로. archives 가 꺼졌거나 주소가 없으면 null. */
174
+ declare function resolveCategoryPath(collection: RouteCollection, categorySlug: string): string | null;
152
175
  /**
153
176
  * 글의 *경로*(siteUrl 없는 basePath-상대) — 예: "/blog/hello". 소속 스트림이
154
177
  * 없거나, `detail: false`(목록 전용 유형) 이거나, `basePath` 가 빈 문자열
@@ -178,6 +201,86 @@ declare function postsNotInAnyCollection<T extends RoutablePost>(posts: readonly
178
201
  */
179
202
  declare function validateCollections(collections: readonly RouteCollection[]): string[];
180
203
 
204
+ /**
205
+ * 콘텐츠 모델의 계층형 분류 주소 계산기.
206
+ *
207
+ * FAQ·도움말·문서처럼 하나의 모델이 여러 단계 분류 허브를 가지는 경우에 쓴다.
208
+ * 저장소나 프레임워크를 모르며, 공개 API와 ROOT-ADMIN이 같은 계산을 재사용한다.
209
+ */
210
+ interface CategoryTreePresentation {
211
+ readonly basePath: string;
212
+ readonly categoryDepth: number;
213
+ }
214
+ interface CategoryTreeTerm {
215
+ readonly id: string;
216
+ readonly slug: string;
217
+ readonly parentId?: string | null;
218
+ }
219
+ interface CategoryTreeEntry {
220
+ readonly slug: string;
221
+ readonly terms: ReadonlyArray<{
222
+ readonly id: string;
223
+ readonly taxonomy: string;
224
+ }>;
225
+ }
226
+ /**
227
+ * 선택한 말단 분류부터 루트까지의 사슬을 루트→말단 순서로 돌려준다.
228
+ * 끊긴 부모·순환·중복 id·깊이 불일치·말단이 아닌 선택은 null로 닫는다.
229
+ */
230
+ declare function resolveCategoryTreeChain(presentation: CategoryTreePresentation, leafId: string, terms: readonly CategoryTreeTerm[]): CategoryTreeTerm[] | null;
231
+ /** 루트부터 선택 분류까지 모든 허브 주소. */
232
+ declare function resolveCategoryTreeHubPaths(presentation: CategoryTreePresentation, termId: string, terms: readonly CategoryTreeTerm[]): string[] | null;
233
+ /** 계층형 분류 모델 항목의 정규 상세 주소. */
234
+ declare function resolveCategoryTreeEntryPath(presentation: CategoryTreePresentation, entry: CategoryTreeEntry, terms: readonly CategoryTreeTerm[]): string | null;
235
+
236
+ /**
237
+ * 글의 정규 공개 경로 — 플랫폼 전체가 쓰는 **하나의** 계산기 (ADR-0105 개정).
238
+ *
239
+ * 우선순위: 콘텐츠 모델 presentation(fixed_page → detail → category_tree) →
240
+ * 호환 컬렉션(`resolvePostPath`) → 레거시 `/blog/{slug}`. `data_only` 모델은
241
+ * 상세 주소가 없으므로 호환 컬렉션이 있으면 그 규칙을, 없으면 null 을 돌려준다.
242
+ *
243
+ * 이 값은 저장 시 `posts.public_path` 로 물질화되고(경로 이력 포함), 웹훅·사이트맵·
244
+ * 단축링크·내부 링크 키·리다이렉트는 저장된 값을 읽는다. 여기 말고 다른 곳에서
245
+ * 주소를 다시 계산하지 않는다.
246
+ */
247
+
248
+ /** 콘텐츠 모델 presentation 의 구조적 최소형(cms-models 스키마와 호환). */
249
+ type PublicPathPresentation = {
250
+ readonly kind: "data_only";
251
+ } | {
252
+ readonly kind: "fixed_page";
253
+ readonly path: string;
254
+ } | {
255
+ readonly kind: "detail";
256
+ readonly detailPath: string;
257
+ } | {
258
+ readonly kind: "category_tree";
259
+ readonly basePath: string;
260
+ readonly categoryDepth: number;
261
+ };
262
+ interface PublicPathPost {
263
+ readonly slug: string;
264
+ readonly collectionKey?: string | null;
265
+ readonly modelKey?: string | null;
266
+ readonly terms: ReadonlyArray<{
267
+ readonly id: string;
268
+ readonly taxonomy: string;
269
+ readonly slug: string;
270
+ }>;
271
+ }
272
+ interface PublicPathContext {
273
+ /** 글의 모델 presentation. 모델 글이 아니거나 모델을 못 찾으면 null. */
274
+ readonly presentation: PublicPathPresentation | null;
275
+ /** 모델의 호환 컬렉션 key(`compatibilityCollectionKey`, 미지정이면 모델 key). */
276
+ readonly compatibilityCollectionKey?: string | null;
277
+ readonly collections: readonly RouteCollection[];
278
+ readonly categories: readonly CategoryTreeTerm[];
279
+ }
280
+ declare const LEGACY_BLOG_DETAIL_PATH = "/blog";
281
+ /** 글의 정규 공개 경로(origin 제외). 상세 주소가 없으면 null. */
282
+ declare function resolvePostPublicPath(post: PublicPathPost, context: PublicPathContext): string | null;
283
+
181
284
  interface ClientHydrationSpec {
182
285
  hydration?: "visible" | "idle" | "load" | "media" | "only";
183
286
  module?: string;
@@ -316,4 +419,4 @@ declare function injectStableIdsTree(blocks: readonly (Omit<Block, "_id"> & {
316
419
  _id?: string;
317
420
  })[]): Block[];
318
421
 
319
- export { BLOCK_NAME_PATTERN, type BindingResolver, type Block, type BlockBinding, type BlockDefinition, type BlockRegistry, type ClientHydrationSpec, type EmbedProvider, FIELD_BINDING_SOURCE, FIELD_BINDING_SOURCE_ALIAS, type FaqEntry, IMAGE_ALT_MAX_LENGTH, type NormalizedEmbed, PHASE_1_CORE_BLOCKS, PHASE_1_CSP, type Phase1CoreBlockName, type PreviewTokenPayload, type RoutablePost, type RouteCollection, applyBlockBindings, bindingSrcValue, bindingTextValue, blockSchema, boundFieldValue, chainResolvers, createFieldBindingResolver, ensureImportedHtmlDocumentRoots, ensureStableIds, extractFaqEntries, faqPageJsonLd, htmlToPlainText, injectStableIdsTree, isAllowedEmbedSrc, isAllowedMapEmbedSrc, isInCollectionMode, isValidBlock, issuePreviewToken, normalizeEmbed, normalizeImageAlt, parseBlock, postsForCollection, postsInAnyCollection, postsNotInAnyCollection, resolvePostCollection, resolvePostPath, resolvePostUrl, sanitizeHtml, sanitizeImportedCss, sanitizeImportedHtml, scopeImportedCss, serializeBlock, validateCollections, verifyPreviewToken };
422
+ export { BLOCK_NAME_PATTERN, type BindingResolver, type Block, type BlockBinding, type BlockDefinition, type BlockRegistry, type CategoryTreeEntry, type CategoryTreePresentation, type CategoryTreeTerm, type ClientHydrationSpec, type EmbedProvider, FIELD_BINDING_SOURCE, FIELD_BINDING_SOURCE_ALIAS, type FaqEntry, IMAGE_ALT_MAX_LENGTH, LEGACY_BLOG_DETAIL_PATH, type NormalizedEmbed, PHASE_1_CORE_BLOCKS, PHASE_1_CSP, type Phase1CoreBlockName, type PreviewTokenPayload, type PublicPathContext, type PublicPathPost, type PublicPathPresentation, type RoutablePost, type RouteCollection, applyBlockBindings, bindingSrcValue, bindingTextValue, blockSchema, boundFieldValue, chainResolvers, createFieldBindingResolver, ensureImportedHtmlDocumentRoots, ensureStableIds, extractFaqEntries, faqPageJsonLd, htmlToPlainText, injectStableIdsTree, isAllowedEmbedSrc, isAllowedMapEmbedSrc, isInCollectionMode, isValidBlock, issuePreviewToken, normalizeEmbed, normalizeImageAlt, parseBlock, postsForCollection, postsInAnyCollection, postsNotInAnyCollection, resolveCategoryPath, resolveCategoryTreeChain, resolveCategoryTreeEntryPath, resolveCategoryTreeHubPaths, resolvePostCategorySlug, resolvePostCollection, resolvePostPath, resolvePostPublicPath, resolvePostUrl, sanitizeHtml, sanitizeImportedCss, sanitizeImportedHtml, scopeImportedCss, serializeBlock, validateCollections, verifyPreviewToken };
package/dist/index.d.ts CHANGED
@@ -83,7 +83,8 @@ declare function bindingSrcValue(value: unknown): string | undefined;
83
83
  *
84
84
  * 설계(codex consult `019ef577` 반영):
85
85
  * - **활성화 = `collections.length > 0`.** 빈 배열·미전달 = 레거시 단일 /blog.
86
- * - `RouteCollection` 은 **라우팅 최소셋**만(key/basePath/categories/feed/archives).
86
+ * - `RouteCollection` 은 **라우팅 최소셋**만(key/basePath/categories/feed/archives와
87
+ * 경로 정책) 가진다.
87
88
  * label/layout/icon 등 표시·작성 메타는 templates `CollectionSchema` 에 둔다.
88
89
  */
89
90
  /** 콘텐츠 스트림 1개 — sitemap/feed/아카이브 파생의 라우팅 단위. */
@@ -118,6 +119,20 @@ interface RouteCollection {
118
119
  * — 소속과 상세 URL 존재 여부는 별개(M4 원칙 연장).
119
120
  */
120
121
  readonly detail?: boolean;
122
+ /**
123
+ * 글 상세 주소 형식. `flat`(기본) = `${basePath}/{postSlug}`,
124
+ * `category` = `${basePath}/{categorySlug}/{postSlug}`.
125
+ * category 모드는 URL을 하나로 결정해야 하므로 `categoryCardinality: "exactly-one"`
126
+ * 과 함께 쓴다.
127
+ */
128
+ readonly detailPath?: "flat" | "category";
129
+ /**
130
+ * 카테고리 허브 주소 형식. `namespaced`(기본) =
131
+ * `${basePath}/categories/{slug}`, `direct` = `${basePath}/{slug}`.
132
+ */
133
+ readonly categoryPath?: "namespaced" | "direct";
134
+ /** 글에 허용하는 category term 개수. 기본 `multiple`(기존 동작). */
135
+ readonly categoryCardinality?: "multiple" | "exactly-one";
121
136
  }
122
137
  /** resolve 입력 — 글의 구조적 최소 형태(cms-client `CmsPostContent` 와 구조 호환). */
123
138
  interface RoutablePost {
@@ -149,6 +164,14 @@ declare function resolvePostCollection(post: RoutablePost, collections: readonly
149
164
  * (주소 없는 유형)이면 null. slug 는 percent-encoding (한글 slug 안전).
150
165
  */
151
166
  declare function resolvePostUrl(siteUrl: string, post: RoutablePost, collections: readonly RouteCollection[]): string | null;
167
+ /**
168
+ * category 기반 상세 주소에 사용할 단일 category slug. collection 에 연결된
169
+ * category만 세고 정확히 1개일 때만 반환한다. 0개·2개 이상이면 정규 URL을
170
+ * 임의 선택하지 않고 null 로 닫는다.
171
+ */
172
+ declare function resolvePostCategorySlug(post: RoutablePost, collection: RouteCollection): string | null;
173
+ /** 카테고리 허브 경로. archives 가 꺼졌거나 주소가 없으면 null. */
174
+ declare function resolveCategoryPath(collection: RouteCollection, categorySlug: string): string | null;
152
175
  /**
153
176
  * 글의 *경로*(siteUrl 없는 basePath-상대) — 예: "/blog/hello". 소속 스트림이
154
177
  * 없거나, `detail: false`(목록 전용 유형) 이거나, `basePath` 가 빈 문자열
@@ -178,6 +201,86 @@ declare function postsNotInAnyCollection<T extends RoutablePost>(posts: readonly
178
201
  */
179
202
  declare function validateCollections(collections: readonly RouteCollection[]): string[];
180
203
 
204
+ /**
205
+ * 콘텐츠 모델의 계층형 분류 주소 계산기.
206
+ *
207
+ * FAQ·도움말·문서처럼 하나의 모델이 여러 단계 분류 허브를 가지는 경우에 쓴다.
208
+ * 저장소나 프레임워크를 모르며, 공개 API와 ROOT-ADMIN이 같은 계산을 재사용한다.
209
+ */
210
+ interface CategoryTreePresentation {
211
+ readonly basePath: string;
212
+ readonly categoryDepth: number;
213
+ }
214
+ interface CategoryTreeTerm {
215
+ readonly id: string;
216
+ readonly slug: string;
217
+ readonly parentId?: string | null;
218
+ }
219
+ interface CategoryTreeEntry {
220
+ readonly slug: string;
221
+ readonly terms: ReadonlyArray<{
222
+ readonly id: string;
223
+ readonly taxonomy: string;
224
+ }>;
225
+ }
226
+ /**
227
+ * 선택한 말단 분류부터 루트까지의 사슬을 루트→말단 순서로 돌려준다.
228
+ * 끊긴 부모·순환·중복 id·깊이 불일치·말단이 아닌 선택은 null로 닫는다.
229
+ */
230
+ declare function resolveCategoryTreeChain(presentation: CategoryTreePresentation, leafId: string, terms: readonly CategoryTreeTerm[]): CategoryTreeTerm[] | null;
231
+ /** 루트부터 선택 분류까지 모든 허브 주소. */
232
+ declare function resolveCategoryTreeHubPaths(presentation: CategoryTreePresentation, termId: string, terms: readonly CategoryTreeTerm[]): string[] | null;
233
+ /** 계층형 분류 모델 항목의 정규 상세 주소. */
234
+ declare function resolveCategoryTreeEntryPath(presentation: CategoryTreePresentation, entry: CategoryTreeEntry, terms: readonly CategoryTreeTerm[]): string | null;
235
+
236
+ /**
237
+ * 글의 정규 공개 경로 — 플랫폼 전체가 쓰는 **하나의** 계산기 (ADR-0105 개정).
238
+ *
239
+ * 우선순위: 콘텐츠 모델 presentation(fixed_page → detail → category_tree) →
240
+ * 호환 컬렉션(`resolvePostPath`) → 레거시 `/blog/{slug}`. `data_only` 모델은
241
+ * 상세 주소가 없으므로 호환 컬렉션이 있으면 그 규칙을, 없으면 null 을 돌려준다.
242
+ *
243
+ * 이 값은 저장 시 `posts.public_path` 로 물질화되고(경로 이력 포함), 웹훅·사이트맵·
244
+ * 단축링크·내부 링크 키·리다이렉트는 저장된 값을 읽는다. 여기 말고 다른 곳에서
245
+ * 주소를 다시 계산하지 않는다.
246
+ */
247
+
248
+ /** 콘텐츠 모델 presentation 의 구조적 최소형(cms-models 스키마와 호환). */
249
+ type PublicPathPresentation = {
250
+ readonly kind: "data_only";
251
+ } | {
252
+ readonly kind: "fixed_page";
253
+ readonly path: string;
254
+ } | {
255
+ readonly kind: "detail";
256
+ readonly detailPath: string;
257
+ } | {
258
+ readonly kind: "category_tree";
259
+ readonly basePath: string;
260
+ readonly categoryDepth: number;
261
+ };
262
+ interface PublicPathPost {
263
+ readonly slug: string;
264
+ readonly collectionKey?: string | null;
265
+ readonly modelKey?: string | null;
266
+ readonly terms: ReadonlyArray<{
267
+ readonly id: string;
268
+ readonly taxonomy: string;
269
+ readonly slug: string;
270
+ }>;
271
+ }
272
+ interface PublicPathContext {
273
+ /** 글의 모델 presentation. 모델 글이 아니거나 모델을 못 찾으면 null. */
274
+ readonly presentation: PublicPathPresentation | null;
275
+ /** 모델의 호환 컬렉션 key(`compatibilityCollectionKey`, 미지정이면 모델 key). */
276
+ readonly compatibilityCollectionKey?: string | null;
277
+ readonly collections: readonly RouteCollection[];
278
+ readonly categories: readonly CategoryTreeTerm[];
279
+ }
280
+ declare const LEGACY_BLOG_DETAIL_PATH = "/blog";
281
+ /** 글의 정규 공개 경로(origin 제외). 상세 주소가 없으면 null. */
282
+ declare function resolvePostPublicPath(post: PublicPathPost, context: PublicPathContext): string | null;
283
+
181
284
  interface ClientHydrationSpec {
182
285
  hydration?: "visible" | "idle" | "load" | "media" | "only";
183
286
  module?: string;
@@ -316,4 +419,4 @@ declare function injectStableIdsTree(blocks: readonly (Omit<Block, "_id"> & {
316
419
  _id?: string;
317
420
  })[]): Block[];
318
421
 
319
- export { BLOCK_NAME_PATTERN, type BindingResolver, type Block, type BlockBinding, type BlockDefinition, type BlockRegistry, type ClientHydrationSpec, type EmbedProvider, FIELD_BINDING_SOURCE, FIELD_BINDING_SOURCE_ALIAS, type FaqEntry, IMAGE_ALT_MAX_LENGTH, type NormalizedEmbed, PHASE_1_CORE_BLOCKS, PHASE_1_CSP, type Phase1CoreBlockName, type PreviewTokenPayload, type RoutablePost, type RouteCollection, applyBlockBindings, bindingSrcValue, bindingTextValue, blockSchema, boundFieldValue, chainResolvers, createFieldBindingResolver, ensureImportedHtmlDocumentRoots, ensureStableIds, extractFaqEntries, faqPageJsonLd, htmlToPlainText, injectStableIdsTree, isAllowedEmbedSrc, isAllowedMapEmbedSrc, isInCollectionMode, isValidBlock, issuePreviewToken, normalizeEmbed, normalizeImageAlt, parseBlock, postsForCollection, postsInAnyCollection, postsNotInAnyCollection, resolvePostCollection, resolvePostPath, resolvePostUrl, sanitizeHtml, sanitizeImportedCss, sanitizeImportedHtml, scopeImportedCss, serializeBlock, validateCollections, verifyPreviewToken };
422
+ export { BLOCK_NAME_PATTERN, type BindingResolver, type Block, type BlockBinding, type BlockDefinition, type BlockRegistry, type CategoryTreeEntry, type CategoryTreePresentation, type CategoryTreeTerm, type ClientHydrationSpec, type EmbedProvider, FIELD_BINDING_SOURCE, FIELD_BINDING_SOURCE_ALIAS, type FaqEntry, IMAGE_ALT_MAX_LENGTH, LEGACY_BLOG_DETAIL_PATH, type NormalizedEmbed, PHASE_1_CORE_BLOCKS, PHASE_1_CSP, type Phase1CoreBlockName, type PreviewTokenPayload, type PublicPathContext, type PublicPathPost, type PublicPathPresentation, type RoutablePost, type RouteCollection, applyBlockBindings, bindingSrcValue, bindingTextValue, blockSchema, boundFieldValue, chainResolvers, createFieldBindingResolver, ensureImportedHtmlDocumentRoots, ensureStableIds, extractFaqEntries, faqPageJsonLd, htmlToPlainText, injectStableIdsTree, isAllowedEmbedSrc, isAllowedMapEmbedSrc, isInCollectionMode, isValidBlock, issuePreviewToken, normalizeEmbed, normalizeImageAlt, parseBlock, postsForCollection, postsInAnyCollection, postsNotInAnyCollection, resolveCategoryPath, resolveCategoryTreeChain, resolveCategoryTreeEntryPath, resolveCategoryTreeHubPaths, resolvePostCategorySlug, resolvePostCollection, resolvePostPath, resolvePostPublicPath, resolvePostUrl, sanitizeHtml, sanitizeImportedCss, sanitizeImportedHtml, scopeImportedCss, serializeBlock, validateCollections, verifyPreviewToken };
package/dist/index.js CHANGED
@@ -127,18 +127,38 @@ function resolvePostCollection(post, collections) {
127
127
  return collections.find((c) => c.key === post.collectionKey) ?? null;
128
128
  }
129
129
  function resolvePostUrl(siteUrl, post, collections) {
130
- const collection = resolvePostCollection(post, collections);
131
- if (!collection) return null;
132
- if (collection.detail === false) return null;
133
- if (!collection.basePath) return null;
134
- return `${trimTrailingSlash(siteUrl)}${trimTrailingSlash(collection.basePath)}/${encodeURIComponent(post.slug)}`;
130
+ const path = resolvePostPath(post, collections);
131
+ return path ? `${trimTrailingSlash(siteUrl)}${path}` : null;
132
+ }
133
+ function resolvePostCategorySlug(post, collection) {
134
+ const allowed = new Set(collection.categories);
135
+ const slugs = [
136
+ ...new Set(
137
+ post.terms.filter(
138
+ (term) => term.taxonomy === "category" && allowed.has(term.slug)
139
+ ).map((term) => term.slug)
140
+ )
141
+ ];
142
+ return slugs.length === 1 ? slugs[0] : null;
143
+ }
144
+ function resolveCategoryPath(collection, categorySlug) {
145
+ if (!collection.archives || !collection.basePath || !categorySlug) return null;
146
+ const basePath = trimTrailingSlash(collection.basePath);
147
+ const encodedSlug = encodeURIComponent(categorySlug);
148
+ return collection.categoryPath === "direct" ? `${basePath}/${encodedSlug}` : `${basePath}/categories/${encodedSlug}`;
135
149
  }
136
150
  function resolvePostPath(post, collections) {
137
151
  const collection = resolvePostCollection(post, collections);
138
152
  if (!collection) return null;
139
153
  if (collection.detail === false) return null;
140
154
  if (!collection.basePath) return null;
141
- return `${trimTrailingSlash(collection.basePath)}/${encodeURIComponent(post.slug)}`;
155
+ const basePath = trimTrailingSlash(collection.basePath);
156
+ const encodedPostSlug = encodeURIComponent(post.slug);
157
+ if (collection.detailPath !== "category") {
158
+ return `${basePath}/${encodedPostSlug}`;
159
+ }
160
+ const categorySlug = resolvePostCategorySlug(post, collection);
161
+ return categorySlug ? `${basePath}/${encodeURIComponent(categorySlug)}/${encodedPostSlug}` : null;
142
162
  }
143
163
  function postsForCollection(posts, collections, key) {
144
164
  return posts.filter((p) => resolvePostCollection(p, collections)?.key === key);
@@ -178,10 +198,108 @@ function validateCollections(collections) {
178
198
  }
179
199
  categoryOwner.set(slug, c.key);
180
200
  }
201
+ if (c.detailPath === "category") {
202
+ if (!c.basePath) {
203
+ problems.push(
204
+ `category \uC0C1\uC138 \uACBD\uB85C\uB294 basePath \uAC00 \uD544\uC694\uD568: "${c.key}"`
205
+ );
206
+ }
207
+ if (c.detail === false) {
208
+ problems.push(
209
+ `category \uC0C1\uC138 \uACBD\uB85C\uB294 \uAE00\uBCC4 \uC0C1\uC138 \uD398\uC774\uC9C0\uAC00 \uD544\uC694\uD568: "${c.key}"`
210
+ );
211
+ }
212
+ if (!c.archives) {
213
+ problems.push(
214
+ `category \uC0C1\uC138 \uACBD\uB85C\uB294 \uCE74\uD14C\uACE0\uB9AC \uC544\uCE74\uC774\uBE0C\uAC00 \uD544\uC694\uD568: "${c.key}"`
215
+ );
216
+ }
217
+ if (c.categoryCardinality !== "exactly-one") {
218
+ problems.push(
219
+ `category \uC0C1\uC138 \uACBD\uB85C\uB294 categoryCardinality "exactly-one" \uC774 \uD544\uC694\uD568: "${c.key}"`
220
+ );
221
+ }
222
+ }
181
223
  }
182
224
  return problems;
183
225
  }
184
226
 
227
+ // src/category-tree.ts
228
+ function normalizeBasePath(basePath) {
229
+ return basePath.replace(/\/+$/, "");
230
+ }
231
+ function resolveCategoryTreeChain(presentation, leafId, terms) {
232
+ if (!Number.isInteger(presentation.categoryDepth) || presentation.categoryDepth < 1) {
233
+ return null;
234
+ }
235
+ const termsById = new Map(terms.map((term) => [term.id, term]));
236
+ if (termsById.size !== terms.length) return null;
237
+ if (terms.some((term) => term.parentId === leafId)) return null;
238
+ const reversed = [];
239
+ const visited = /* @__PURE__ */ new Set();
240
+ let currentId = leafId;
241
+ while (currentId) {
242
+ if (visited.has(currentId)) return null;
243
+ visited.add(currentId);
244
+ const current = termsById.get(currentId);
245
+ if (!current || !current.slug) return null;
246
+ reversed.push(current);
247
+ currentId = current.parentId ?? null;
248
+ }
249
+ if (reversed.length !== presentation.categoryDepth) return null;
250
+ return reversed.reverse();
251
+ }
252
+ function resolveCategoryTreeHubPaths(presentation, termId, terms) {
253
+ const chain = resolveCategoryTreeChain(presentation, termId, terms);
254
+ const basePath = normalizeBasePath(presentation.basePath);
255
+ if (!chain || !basePath) return null;
256
+ const paths = [basePath];
257
+ const segments = [];
258
+ for (const term of chain) {
259
+ segments.push(encodeURIComponent(term.slug));
260
+ paths.push(`${basePath}/${segments.join("/")}`);
261
+ }
262
+ return paths;
263
+ }
264
+ function resolveCategoryTreeEntryPath(presentation, entry, terms) {
265
+ if (!entry.slug) return null;
266
+ const knownIds = new Set(terms.map((term) => term.id));
267
+ const categoryIds = [
268
+ ...new Set(
269
+ entry.terms.filter((term) => term.taxonomy === "category" && knownIds.has(term.id)).map((term) => term.id)
270
+ )
271
+ ];
272
+ if (categoryIds.length !== 1) return null;
273
+ const hubs = resolveCategoryTreeHubPaths(
274
+ presentation,
275
+ categoryIds[0],
276
+ terms
277
+ );
278
+ const leafPath = hubs?.at(-1);
279
+ return leafPath ? `${leafPath}/${encodeURIComponent(entry.slug)}` : null;
280
+ }
281
+
282
+ // src/public-path.ts
283
+ var LEGACY_BLOG_DETAIL_PATH = "/blog";
284
+ function resolvePostPublicPath(post, context) {
285
+ const presentation = context.presentation;
286
+ if (presentation && presentation.kind !== "data_only") {
287
+ if (presentation.kind === "fixed_page") return presentation.path;
288
+ if (presentation.kind === "detail") {
289
+ if (!presentation.detailPath.includes(":slug")) return null;
290
+ return presentation.detailPath.replace(":slug", encodeURIComponent(post.slug));
291
+ }
292
+ return resolveCategoryTreeEntryPath(presentation, post, context.categories);
293
+ }
294
+ const collectionKey = post.collectionKey ?? (presentation ? context.compatibilityCollectionKey ?? post.modelKey ?? null : null);
295
+ const routable = { slug: post.slug, terms: post.terms, collectionKey };
296
+ if (!resolvePostCollection(routable, context.collections)) {
297
+ if (presentation) return null;
298
+ return `${LEGACY_BLOG_DETAIL_PATH}/${post.slug}`;
299
+ }
300
+ return resolvePostPath(routable, context.collections);
301
+ }
302
+
185
303
  // src/delimiter.ts
186
304
  function serializeBlock(block) {
187
305
  const exportable = sanitizeAttributesForExport(block);
@@ -1153,6 +1271,7 @@ export {
1153
1271
  FIELD_BINDING_SOURCE,
1154
1272
  FIELD_BINDING_SOURCE_ALIAS,
1155
1273
  IMAGE_ALT_MAX_LENGTH,
1274
+ LEGACY_BLOG_DETAIL_PATH,
1156
1275
  PHASE_1_CORE_BLOCKS,
1157
1276
  PHASE_1_CSP,
1158
1277
  applyBlockBindings,
@@ -1179,8 +1298,14 @@ export {
1179
1298
  postsForCollection,
1180
1299
  postsInAnyCollection,
1181
1300
  postsNotInAnyCollection,
1301
+ resolveCategoryPath,
1302
+ resolveCategoryTreeChain,
1303
+ resolveCategoryTreeEntryPath,
1304
+ resolveCategoryTreeHubPaths,
1305
+ resolvePostCategorySlug,
1182
1306
  resolvePostCollection,
1183
1307
  resolvePostPath,
1308
+ resolvePostPublicPath,
1184
1309
  resolvePostUrl,
1185
1310
  sanitizeHtml,
1186
1311
  sanitizeImportedCss,