@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/dist/editor.js +2645 -2644
- package/package.json +7 -7
- package/src/dev-middleware.ts +48 -21
- package/src/editor/components/markdown-editor-overlay.tsx +2 -12
- package/src/shared.ts +22 -0
- package/src/source-finder/collection-finder.ts +20 -0
- package/src/source-finder/index.ts +1 -0
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.
|
|
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.
|
|
30
|
-
"@nuasite/cms-types": "0.46.
|
|
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.
|
|
39
|
-
"@nuasite/collections-admin": "0.46.
|
|
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.
|
|
80
|
-
"@nuasite/collections-admin": "0.46.
|
|
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
|
},
|
package/src/dev-middleware.ts
CHANGED
|
@@ -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
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
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
|
|
189
|
-
const needsPatching = routePrefix && def.entries?.some(e => !e.pathname)
|
|
195
|
+
const routeInfo = collectionRoutes.get(def.name)
|
|
190
196
|
|
|
191
|
-
if (
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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
|
|
706
|
-
* (e.g. [slug].astro) that call getCollection(). Returns a
|
|
707
|
-
*
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|