@nuasite/cms 0.46.2 → 0.46.4

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.4",
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.4",
30
+ "@nuasite/cms-types": "0.46.4",
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.4",
39
+ "@nuasite/collections-admin": "0.46.4",
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.4",
80
+ "@nuasite/collections-admin": "0.46.4",
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
+ }
@@ -1,3 +1,4 @@
1
+ import path from 'node:path'
1
2
  import type { CachedParsedFile, ImageIndexEntry, SearchIndexEntry, SourceLocation } from './types'
2
3
 
3
4
  // ============================================================================
@@ -113,10 +114,16 @@ export function markFileDirty(absPath: string): void {
113
114
  dirtyFiles.add(absPath)
114
115
  // Also evict the parsed file cache so it's re-read from disk
115
116
  parsedFileCache.delete(absPath)
116
- // A changed file may add/remove/alter a declared URL anywhere in its
117
- // collection directory cheaper to drop the whole cache than track
118
- // per-directory membership for a rarely-hit index.
119
- declaredUrlIndexCache.clear()
117
+ // A changed file may add/remove/alter its declared URL, so drop the URL→file
118
+ // index for its collection directory. The index is keyed by collection dir:
119
+ // for a flat `<dir>/<slug>.md` that's the file's directory; for Hugo-style
120
+ // `<dir>/<slug>/index.md` it's the parent of the file's directory. Evict both
121
+ // candidates (deleting a non-existent key is a no-op) instead of clearing the
122
+ // whole cache, which would rebuild every collection's index on the next
123
+ // unmatched-page lookup.
124
+ const fileDir = path.dirname(absPath)
125
+ declaredUrlIndexCache.delete(fileDir)
126
+ declaredUrlIndexCache.delete(path.dirname(fileDir))
120
127
  }
121
128
 
122
129
  export function getDirtyFiles(): Set<string> {
@@ -300,7 +300,7 @@ export async function findCollectionSource(
300
300
  // Prefer the entry whose declared canonical URL equals the requested
301
301
  // path. Only kicks in when an entry actually declares a URL, so
302
302
  // URL-less projects fall through to the filename logic unchanged.
303
- const byUrl = await resolveByDeclaredUrl(matches, requestedUrl, contentPath)
303
+ const byUrl = await resolveByDeclaredUrl(matches, requestedUrl, contentPath, pathParts[0])
304
304
  if (byUrl) {
305
305
  // byUrl.file may differ from the file the filename match found
306
306
  // (that's the whole point of this fallback) — its slug must be
@@ -331,6 +331,29 @@ export async function findCollectionSource(
331
331
  }
332
332
  }
333
333
 
334
+ // No filename-based candidate matched any tail slug. The source file may be
335
+ // named unlike its URL entirely — e.g. people entries stored as
336
+ // `<role>__<slug>.md` but served at `/<family>/<slug>`, so no tail segment
337
+ // ever hits a `<slug>.md` file. Fall back to resolving by declared canonical
338
+ // URL across every collection.
339
+ //
340
+ // Guarded to multi-segment paths: the tail-slug loop above only ran for
341
+ // `pathParts.length >= 2`, so single-segment paths (bare static pages like
342
+ // `/about`) were never treated as collection pages. Keeping that boundary
343
+ // avoids both a full declared-URL scan on every such page and the risk of
344
+ // mis-attributing a static page to a collection entry that happens to
345
+ // declare the same URL.
346
+ if (pathParts.length >= 2) {
347
+ const byDeclaredUrl = await findByDeclaredUrlAcross(collectionDirs, contentPath, requestedUrl, pathParts[0])
348
+ if (byDeclaredUrl) {
349
+ return {
350
+ name: byDeclaredUrl.name,
351
+ slug: slugFromFilePath(byDeclaredUrl.file),
352
+ file: path.relative(getProjectRoot(), byDeclaredUrl.file),
353
+ }
354
+ }
355
+ }
356
+
334
357
  return undefined
335
358
  }
336
359
 
@@ -342,6 +365,26 @@ function normalizeSitePath(p: string): string {
342
365
  return s
343
366
  }
344
367
 
368
+ /**
369
+ * Extract an entry's declared canonical site path from its already-parsed
370
+ * frontmatter/data object (e.g. `CollectionEntryInfo.data`). Same field set and
371
+ * rules as {@link readDeclaredPageUrl}, but operates in-memory so callers that
372
+ * already hold the data don't re-read the file. Returns the normalized
373
+ * site-absolute path, or undefined when no site-absolute URL field is declared.
374
+ */
375
+ export function declaredSitePathFromData(data: unknown): string | undefined {
376
+ if (!data || typeof data !== 'object') return undefined
377
+ const lowerKeyed = new Map<string, unknown>()
378
+ for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
379
+ lowerKeyed.set(key.toLowerCase(), value)
380
+ }
381
+ for (const field of DECLARED_URL_FIELDS) {
382
+ const value = lowerKeyed.get(field)
383
+ if (typeof value === 'string' && value.startsWith('/')) return normalizeSitePath(value)
384
+ }
385
+ return undefined
386
+ }
387
+
345
388
  /**
346
389
  * Derive a collection entry's slug from its file path, matching the same
347
390
  * convention collection-scanner.ts uses: flat `<slug>.md(x)` files use the
@@ -408,6 +451,7 @@ async function resolveByDeclaredUrl(
408
451
  matches: { name: string; file: string }[],
409
452
  requestedUrl: string,
410
453
  contentPath: string,
454
+ urlPrefix: string | undefined,
411
455
  ): Promise<{ name: string; file: string } | undefined> {
412
456
  let sawDeclaredUrl = false
413
457
  for (const m of matches) {
@@ -421,13 +465,30 @@ async function resolveByDeclaredUrl(
421
465
 
422
466
  // Contradiction: the right entry is named differently from its slug. Scan
423
467
  // the collection(s) that produced filename matches for a declared-URL hit.
424
- // `matches` (and thus this Set) is built by iterating the sorted
425
- // `collectionDirs`, so directory order here is deterministic.
426
- for (const dir of new Set(matches.map(m => m.name))) {
468
+ return findByDeclaredUrlAcross([...new Set(matches.map(m => m.name))], contentPath, requestedUrl, urlPrefix)
469
+ }
470
+
471
+ /**
472
+ * Resolve `requestedUrl` to a source file by its declared canonical URL across
473
+ * the given collection directories, using the cached per-directory URL→file
474
+ * index. When more than one collection declares the same URL, a directory whose
475
+ * name equals the URL's first path segment wins; otherwise the sorted-first
476
+ * match is returned so the result is deterministic regardless of readdir order.
477
+ */
478
+ async function findByDeclaredUrlAcross(
479
+ dirs: string[],
480
+ contentPath: string,
481
+ requestedUrl: string,
482
+ urlPrefix: string | undefined,
483
+ ): Promise<{ name: string; file: string } | undefined> {
484
+ let firstHit: { name: string; file: string } | undefined
485
+ for (const dir of [...dirs].sort()) {
427
486
  const hit = await findFileByDeclaredUrl(path.join(contentPath, dir), requestedUrl)
428
- if (hit) return { name: dir, file: hit }
487
+ if (!hit) continue
488
+ if (dir === urlPrefix) return { name: dir, file: hit }
489
+ firstHit ??= { name: dir, file: hit }
429
490
  }
430
- return undefined
491
+ return firstHit
431
492
  }
432
493
 
433
494
  /**
@@ -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,