@nuasite/cms 0.46.2 → 0.46.3

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/package.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "directory": "packages/astro-cms"
15
15
  },
16
16
  "license": "Apache-2.0",
17
- "version": "0.46.2",
17
+ "version": "0.46.3",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -26,8 +26,8 @@
26
26
  }
27
27
  },
28
28
  "dependencies": {
29
- "@nuasite/cms-core": "0.46.2",
30
- "@nuasite/cms-types": "0.46.2",
29
+ "@nuasite/cms-core": "0.46.3",
30
+ "@nuasite/cms-types": "0.46.3",
31
31
  "@astrojs/compiler": "^3.0.1",
32
32
  "@babel/parser": "^7.29.2",
33
33
  "node-html-parser": "^7.1.0",
@@ -35,8 +35,8 @@
35
35
  "yaml": "^2.8.3"
36
36
  },
37
37
  "devDependencies": {
38
- "@nuasite/cms-sidecar": "0.46.2",
39
- "@nuasite/collections-admin": "0.46.2",
38
+ "@nuasite/cms-sidecar": "0.46.3",
39
+ "@nuasite/collections-admin": "0.46.3",
40
40
  "@babel/types": "^7.29.0",
41
41
  "@types/react": "^19.2.7",
42
42
  "@types/react-dom": "^19.2.3",
@@ -76,8 +76,8 @@
76
76
  "typescript": "^6.0.2",
77
77
  "vite": "^7.0.0",
78
78
  "@aws-sdk/client-s3": "^3.0.0",
79
- "@nuasite/cms-sidecar": "0.46.2",
80
- "@nuasite/collections-admin": "0.46.2",
79
+ "@nuasite/cms-sidecar": "0.46.3",
80
+ "@nuasite/collections-admin": "0.46.3",
81
81
  "react": "^19.0.0",
82
82
  "react-dom": "^19.0.0"
83
83
  },
@@ -17,6 +17,7 @@ import { processHtml } from './html-processor'
17
17
  import type { ManifestWriter } from './manifest-writer'
18
18
  import type { MediaStorageAdapter } from './media/types'
19
19
  import {
20
+ declaredSitePathFromData,
20
21
  enhanceManifestWithSourceSnippets,
21
22
  findCollectionSource,
22
23
  findImageSourceLocation,
@@ -28,6 +29,7 @@ import type {
28
29
  CmsMarkerOptions,
29
30
  CollectionDefinition,
30
31
  CollectionEntry,
32
+ CollectionEntryInfo,
31
33
  ComponentDefinition,
32
34
  ComponentInstance,
33
35
  ManifestEntry,
@@ -176,24 +178,36 @@ export function createDevMiddleware(
176
178
  }
177
179
 
178
180
  // 2. Add collection entry pages from collection definitions,
179
- // pre-populating pathnames from filesystem routes so the collections
180
- // browser can redirect to detail pages without visiting them first.
181
- // We build patched copies rather than mutating the originals so that
182
- // heuristic pathnames don't persist if the route file is later removed.
181
+ // pre-populating pathnames so the collections browser can redirect to
182
+ // detail pages without visiting them first. Prefer the entry's own
183
+ // declared URL (urlPath/permalink/…) from frontmatter, which is
184
+ // correct even when the collection is served under a *dynamic* route
185
+ // prefix (e.g. `[topic]/[slug]`) that discoverCollectionRoutes can't
186
+ // map to a static prefix. Fall back to the discovered static route
187
+ // prefix + slug. We build patched copies rather than mutating the
188
+ // originals so that heuristic pathnames don't persist if the route
189
+ // file is later removed.
183
190
  const collectionDefs = manifestWriter.getCollectionDefinitions()
184
191
  const collectionRoutes = await discoverCollectionRoutes()
185
192
  const responseCollectionDefs: Record<string, CollectionDefinition> = {}
186
193
 
187
194
  for (const [name, def] of Object.entries(collectionDefs)) {
188
- const routePrefix = collectionRoutes.get(def.name)
189
- const needsPatching = routePrefix && def.entries?.some(e => !e.pathname)
195
+ const routeInfo = collectionRoutes.get(def.name)
190
196
 
191
- if (!needsPatching) {
197
+ if (routeInfo === undefined) {
198
+ // No page renders this collection at all — leave entries alone rather
199
+ // than fabricating a pathname from an unrelated frontmatter field
200
+ // (e.g. a data collection's own `url` field pointing elsewhere).
192
201
  responseCollectionDefs[name] = def
193
202
  } else {
194
- responseCollectionDefs[name] = {
203
+ const routePrefix = typeof routeInfo === 'string' ? routeInfo : undefined
204
+ const resolvePathname = (entry: CollectionEntryInfo): string | undefined =>
205
+ declaredSitePathFromData(entry.data) ?? (routePrefix ? `${routePrefix}${entry.slug}` : undefined)
206
+ const needsPatching = def.entries?.some(e => !e.pathname && resolvePathname(e))
207
+
208
+ responseCollectionDefs[name] = !needsPatching ? def : {
195
209
  ...def,
196
- entries: def.entries!.map(e => e.pathname ? e : { ...e, pathname: `${routePrefix}${e.slug}` }),
210
+ entries: def.entries!.map(e => e.pathname ? e : { ...e, pathname: resolvePathname(e) ?? e.pathname }),
197
211
  }
198
212
  }
199
213
 
@@ -693,8 +707,17 @@ async function discoverPagesFromFilesystem(): Promise<string[]> {
693
707
  return pages
694
708
  }
695
709
 
710
+ /**
711
+ * A collection's route info: a usable static URL prefix (e.g. '/blog/'), or
712
+ * `true` when the collection is confirmed routed by some page but no static
713
+ * prefix can be fabricated because an ancestor path segment is itself dynamic
714
+ * (e.g. `[topic]/[slug].astro`) — callers must resolve those entries' pathnames
715
+ * from their own declared URL instead of `${prefix}${slug}`.
716
+ */
717
+ type CollectionRouteInfo = string | true
718
+
696
719
  /** Cached result of collection route discovery; invalidated by file watcher */
697
- let collectionRoutesCache: Map<string, string> | null = null
720
+ let collectionRoutesCache: Map<string, CollectionRouteInfo> | null = null
698
721
 
699
722
  /** Invalidate the cached collection routes (called from vite-plugin when route files change) */
700
723
  export function invalidateCollectionRoutesCache() {
@@ -702,16 +725,19 @@ export function invalidateCollectionRoutesCache() {
702
725
  }
703
726
 
704
727
  /**
705
- * Discover collection route patterns by scanning src/pages for dynamic route files
706
- * (e.g. [slug].astro) that call getCollection(). Returns a map from collection name
707
- * to the URL prefix (e.g. 'blog' '/blog/'). Result is cached after first call.
728
+ * Discover which collections are rendered as pages by scanning src/pages for
729
+ * dynamic route files (e.g. [slug].astro) that call getCollection(). Returns a
730
+ * map from collection name to its route info (see {@link CollectionRouteInfo}).
731
+ * Recurses into dynamic directories too (e.g. `[topic]/[slug].astro`) so those
732
+ * collections are still recognized as routed, just without a static prefix.
733
+ * Result is cached after first call.
708
734
  */
709
- async function discoverCollectionRoutes(): Promise<Map<string, string>> {
735
+ export async function discoverCollectionRoutes(): Promise<Map<string, CollectionRouteInfo>> {
710
736
  if (collectionRoutesCache) return collectionRoutesCache
711
737
 
712
738
  const projectRoot = getProjectRoot()
713
739
  const pagesDir = path.join(projectRoot, 'src', 'pages')
714
- const routes = new Map<string, string>()
740
+ const routes = new Map<string, CollectionRouteInfo>()
715
741
 
716
742
  try {
717
743
  await fs.access(pagesDir)
@@ -720,16 +746,14 @@ async function discoverCollectionRoutes(): Promise<Map<string, string>> {
720
746
  return routes
721
747
  }
722
748
 
723
- async function walk(dir: string, urlPrefix: string) {
749
+ async function walk(dir: string, urlPrefix: string, dynamicAncestor: boolean) {
724
750
  const entries = await fs.readdir(dir, { withFileTypes: true })
725
751
  for (const entry of entries) {
726
752
  if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue
727
753
 
728
754
  const fullPath = path.join(dir, entry.name)
729
755
  if (entry.isDirectory()) {
730
- // Skip directories with dynamic segments
731
- if (entry.name.includes('[')) continue
732
- await walk(fullPath, `${urlPrefix}${entry.name}/`)
756
+ await walk(fullPath, `${urlPrefix}${entry.name}/`, dynamicAncestor || entry.name.includes('['))
733
757
  } else {
734
758
  const ext = path.extname(entry.name)
735
759
  if (!PAGE_EXTENSIONS.has(ext)) continue
@@ -740,14 +764,17 @@ async function discoverCollectionRoutes(): Promise<Map<string, string>> {
740
764
  const content = await fs.readFile(fullPath, 'utf-8')
741
765
  const match = content.match(/getCollection\(\s*['"](\w+)['"]\s*\)/)
742
766
  if (match?.[1]) {
743
- routes.set(match[1], urlPrefix)
767
+ // A static prefix is only usable when no ancestor directory is
768
+ // itself dynamic — otherwise record "routed, no prefix" so the
769
+ // caller still trusts declared URLs without fabricating one.
770
+ routes.set(match[1], dynamicAncestor ? true : urlPrefix)
744
771
  }
745
772
  } catch { /* skip unreadable files */ }
746
773
  }
747
774
  }
748
775
  }
749
776
 
750
- await walk(pagesDir, '/')
777
+ await walk(pagesDir, '/', false)
751
778
  collectionRoutesCache = routes
752
779
  return routes
753
780
  }
@@ -1,6 +1,6 @@
1
1
  import { type Editor, editorViewCtx } from '@milkdown/core'
2
2
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
3
- import { slugify } from '../../shared'
3
+ import { resolveCreateRedirectUrl, slugify } from '../../shared'
4
4
  import { updateMarkdownPage } from '../api'
5
5
  import { STORAGE_KEYS, Z_INDEX } from '../constants'
6
6
  import { cn } from '../lib/cn'
@@ -240,17 +240,7 @@ export function MarkdownEditorOverlay() {
240
240
  })
241
241
 
242
242
  if (result.success) {
243
- // Derive the new page URL from existing collection entry pathnames
244
- const entries = opts.collectionDefinition.entries ?? []
245
- const existingEntry = entries.find((e) => e.pathname)
246
- let redirectUrl: string | undefined
247
- if (existingEntry?.pathname) {
248
- // Extract base path from an existing entry pathname (e.g., "/blog/first-post" → "/blog/")
249
- const lastSlash = existingEntry.pathname.lastIndexOf('/')
250
- if (lastSlash >= 0) {
251
- redirectUrl = existingEntry.pathname.slice(0, lastSlash + 1) + slug
252
- }
253
- }
243
+ const redirectUrl = resolveCreateRedirectUrl(opts.collectionDefinition.entries ?? [], slug)
254
244
 
255
245
  showToast(STRINGS.markdown.pageCreated, 'success')
256
246
  resetMarkdownEditorState()
package/src/shared.ts CHANGED
@@ -25,3 +25,25 @@ export function slugifyHref(text: string): string {
25
25
  .replace(/[\s_]+/g, '-')
26
26
  .replace(/^-+|-+$/g, '')
27
27
  }
28
+
29
+ /**
30
+ * Derive the pathname a newly created collection entry will resolve to, from
31
+ * the pathnames of existing sibling entries. Entries can resolve their own
32
+ * pathname from a per-entry declared URL field (see `declaredSitePathFromData`
33
+ * in the source-finder package), so entries in the same collection can live
34
+ * under different prefixes -- e.g. a collection served under a dynamic
35
+ * `[topic]/[slug]` route. Only reuse a sibling's prefix when every entry that
36
+ * has a pathname agrees on the same one; otherwise there's no way to know
37
+ * which prefix the new entry belongs under, and guessing wrong would send the
38
+ * user to an unrelated page.
39
+ */
40
+ export function resolveCreateRedirectUrl(entries: Array<{ pathname?: string }>, slug: string): string | undefined {
41
+ const prefixes = new Set(
42
+ entries
43
+ .map((e) => e.pathname)
44
+ .filter((p): p is string => !!p)
45
+ .map((p) => p.slice(0, p.lastIndexOf('/') + 1))
46
+ .filter((prefix) => prefix.length > 0),
47
+ )
48
+ return prefixes.size === 1 ? `${[...prefixes][0]}${slug}` : undefined
49
+ }
@@ -342,6 +342,26 @@ function normalizeSitePath(p: string): string {
342
342
  return s
343
343
  }
344
344
 
345
+ /**
346
+ * Extract an entry's declared canonical site path from its already-parsed
347
+ * frontmatter/data object (e.g. `CollectionEntryInfo.data`). Same field set and
348
+ * rules as {@link readDeclaredPageUrl}, but operates in-memory so callers that
349
+ * already hold the data don't re-read the file. Returns the normalized
350
+ * site-absolute path, or undefined when no site-absolute URL field is declared.
351
+ */
352
+ export function declaredSitePathFromData(data: unknown): string | undefined {
353
+ if (!data || typeof data !== 'object') return undefined
354
+ const lowerKeyed = new Map<string, unknown>()
355
+ for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
356
+ lowerKeyed.set(key.toLowerCase(), value)
357
+ }
358
+ for (const field of DECLARED_URL_FIELDS) {
359
+ const value = lowerKeyed.get(field)
360
+ if (typeof value === 'string' && value.startsWith('/')) return normalizeSitePath(value)
361
+ }
362
+ return undefined
363
+ }
364
+
345
365
  /**
346
366
  * Derive a collection entry's slug from its file path, matching the same
347
367
  * convention collection-scanner.ts uses: flat `<slug>.md(x)` files use the
@@ -25,6 +25,7 @@ export { findImageSourceLocation } from './image-finder'
25
25
  // Collection/markdown finding
26
26
  export {
27
27
  buildCollectionTextIndex,
28
+ declaredSitePathFromData,
28
29
  findCollectionSource,
29
30
  findFieldInCollectionEntry,
30
31
  findMarkdownSourceLocation,