@nuasite/cms 0.46.5 → 0.47.1

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.
@@ -1,4 +1,4 @@
1
- import { createCmsCore, createNodeFs } from '@nuasite/cms-core'
1
+ import { createCmsCore, createNodeFs, resolvePathnameFromSpec } from '@nuasite/cms-core'
2
2
  import fs from 'node:fs/promises'
3
3
  import type { IncomingMessage, ServerResponse } from 'node:http'
4
4
  import path from 'node:path'
@@ -193,23 +193,33 @@ export function createDevMiddleware(
193
193
 
194
194
  for (const [name, def] of Object.entries(collectionDefs)) {
195
195
  const routeInfo = collectionRoutes.get(def.name)
196
-
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).
201
- responseCollectionDefs[name] = def
202
- } else {
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 : {
209
- ...def,
210
- entries: def.entries!.map(e => e.pathname ? e : { ...e, pathname: resolvePathname(e) ?? e.pathname }),
211
- }
212
- }
196
+ const routePrefix = typeof routeInfo === 'string' ? routeInfo : undefined
197
+ // Whether any page route renders this collection. The heuristic fallbacks
198
+ // below (a frontmatter-declared site path, or the discovered route prefix)
199
+ // only apply to routed collections otherwise a declared `cms.pathname`
200
+ // spec whose fields are missing for an entry would fabricate a URL from an
201
+ // unrelated frontmatter field (e.g. a data collection's own `url`).
202
+ const routed = routeInfo !== undefined
203
+
204
+ // A declarative `cms.pathname` rule is the highest-priority source: it wins
205
+ // over the rendered-route pathname (addPage set entry.pathname to the page
206
+ // URL). resolvePathnameFromSpec is the same resolver manifest-writer uses at
207
+ // build, so dev-preview and the built manifest agree on the entry's URL.
208
+ const resolvePathname = (entry: CollectionEntryInfo): string | undefined =>
209
+ resolvePathnameFromSpec(def, entry.data)
210
+ ?? entry.pathname
211
+ ?? (routed ? declaredSitePathFromData(entry.data) : undefined)
212
+ ?? (routePrefix ? `${routePrefix}${entry.slug}` : undefined)
213
+
214
+ // Build patched copies rather than mutating the originals so heuristic
215
+ // pathnames don't persist if the route file is later removed. Only patch
216
+ // entries whose resolved pathname differs from what they already have.
217
+ const patchedEntries = def.entries?.map(e => {
218
+ const pathname = resolvePathname(e)
219
+ return pathname && pathname !== e.pathname ? { ...e, pathname } : e
220
+ })
221
+ const changed = patchedEntries?.some((e, i) => e !== def.entries![i]) ?? false
222
+ responseCollectionDefs[name] = changed ? { ...def, entries: patchedEntries! } : def
213
223
 
214
224
  const entries = responseCollectionDefs[name].entries
215
225
  if (entries) {
@@ -440,7 +450,7 @@ async function markHtmlForDev(
440
450
  const idGenerator = () => `cms-${pageCounter++}`
441
451
 
442
452
  // Check if this is a collection page
443
- const collectionInfo = await findCollectionSource(pagePath, config.contentDir)
453
+ const collectionInfo = await findCollectionSource(pagePath, config.contentDir, manifestWriter.getCollectionDefinitions())
444
454
  const isCollectionPage = !!collectionInfo
445
455
 
446
456
  let mdContent: Awaited<ReturnType<typeof parseMarkdownContent>> | undefined
@@ -2,11 +2,11 @@ import type { CmsCore, CmsFileSystem } from '@nuasite/cms-core'
2
2
  import { listProjectImages } from '@nuasite/cms-core'
3
3
  import type { IncomingMessage, ServerResponse } from 'node:http'
4
4
  import path from 'node:path'
5
- import { scanCollections } from '../collection-scanner'
6
5
  import { getProjectRoot } from '../config'
7
6
  import { expectedDeletions } from '../dev-middleware'
8
7
  import type { ManifestWriter } from '../manifest-writer'
9
8
  import type { MediaStorageAdapter } from '../media/types'
9
+ import { scanCollections } from '../scan-cache'
10
10
  import { handleAddArrayItem, handleRemoveArrayItem } from './array-ops'
11
11
  import { tryAstroImageUpload } from './astro-image-upload'
12
12
  import { handleInsertComponent, handleRemoveComponent } from './component-ops'
package/src/index.ts CHANGED
@@ -7,9 +7,8 @@ import { fileURLToPath } from 'node:url'
7
7
  import { createLocalStorageAdapter } from '@nuasite/cms-core'
8
8
  import remarkDirective from 'remark-directive'
9
9
  import { processBuildOutput } from './build-processor'
10
- import { scanCollections } from './collection-scanner'
11
10
  import { ComponentRegistry } from './component-registry'
12
- import { resetProjectRoot } from './config'
11
+ import { getProjectRoot, resetProjectRoot } from './config'
13
12
  import { createDevMiddleware } from './dev-middleware'
14
13
  import { getErrorCollector, resetErrorCollector } from './error-collector'
15
14
  import { ADMIN_ROUTE, createLocalAdminMiddleware } from './local-admin'
@@ -18,6 +17,7 @@ import type { MediaStorageAdapter } from './media/types'
18
17
  import { type CmsMode, resolveCmsMode } from './mode'
19
18
  import { rehypeCmsMarker } from './rehype-cms-marker'
20
19
  import { remarkListDirective } from './remark-list-directive'
20
+ import { scanCollections } from './scan-cache'
21
21
  import type { CmsFeatures, CmsMarkerOptions, ComponentDefinition } from './types'
22
22
  import { createPublicStaticFileChecker } from './utils'
23
23
  import { createVitePlugin } from './vite-plugin'
@@ -482,14 +482,14 @@ export {
482
482
  createS3StorageAdapter as s3Media,
483
483
  } from '@nuasite/cms-core'
484
484
  export { FIELD_TYPES, isFieldType } from '@nuasite/cms-types'
485
- export type { CollectionLayout, CollectionLayoutSection } from '@nuasite/cms-types'
485
+ export type { CollectionLayout, CollectionLayoutSection, PathnameSegment, PathnameSpec } from '@nuasite/cms-types'
486
486
  export { defineCmsCollection, n } from './field-types'
487
487
  export type { DateHints, FileHints, ImageHints, LayoutHints, NumberHints, TextareaHints, TextHints } from './field-types'
488
488
  export type { MediaFolderItem, MediaItem, MediaListOptions, MediaListResult, MediaStorageAdapter, MediaTypeFilter } from './media/types'
489
489
  export { type CmsMode, detectHostedFromEnv, resolveCmsMode } from './mode'
490
490
  export type { Color, Date, DateTime, Email, Image, Reference, Textarea, Time, Url } from './prop-types'
491
491
 
492
- export { scanCollections } from './collection-scanner'
492
+ export { scanCollections } from '@nuasite/cms-core'
493
493
  export { getProjectRoot, resetProjectRoot, setProjectRoot } from './config'
494
494
  export { type FieldMigration, migrateAstroImages, type MigrationOptions, type MigrationResult } from './migrate-astro-image'
495
495
  export { rehypeCmsMarker } from './rehype-cms-marker'
@@ -1,3 +1,4 @@
1
+ import { resolvePathnameFromSpec } from '@nuasite/cms-core'
1
2
  import fs from 'node:fs/promises'
2
3
  import path from 'node:path'
3
4
  import { getProjectRoot } from './config'
@@ -279,12 +280,19 @@ export class ManifestWriter {
279
280
 
280
281
  // Populate pathname on collection definition entries
281
282
  for (const def of Object.values(this.collectionDefinitions)) {
282
- if (def.entries) {
283
- for (const entry of def.entries) {
284
- const pathname = collectionPathMap.get(`${def.name}/${entry.slug}`)
285
- if (pathname) {
286
- entry.pathname = pathname
287
- }
283
+ if (!def.entries) continue
284
+ for (const entry of def.entries) {
285
+ // A declarative `cms.pathname` rule is the highest-priority source: it
286
+ // wins over the rendered-page pathname. resolvePathnameFromSpec is the
287
+ // same resolver the dev middleware uses, so dev and build agree.
288
+ const fromSpec = resolvePathnameFromSpec(def, entry.data)
289
+ if (fromSpec) {
290
+ entry.pathname = fromSpec
291
+ continue
292
+ }
293
+ const pathname = collectionPathMap.get(`${def.name}/${entry.slug}`)
294
+ if (pathname) {
295
+ entry.pathname = pathname
288
296
  }
289
297
  }
290
298
  }
@@ -1,8 +1,8 @@
1
+ import { createNodeFs, scanCollections } from '@nuasite/cms-core'
1
2
  import fs from 'node:fs/promises'
2
3
  import path from 'node:path'
3
4
  import { parseDocument } from 'yaml'
4
5
  import { pickAstroImageTarget } from './astro-image-paths'
5
- import { scanCollections } from './collection-scanner'
6
6
  import { getProjectRoot } from './config'
7
7
 
8
8
  export interface MigrationOptions {
@@ -33,7 +33,7 @@ export async function migrateAstroImages(options: MigrationOptions = {}): Promis
33
33
  const projectRoot = options.projectRoot ?? getProjectRoot()
34
34
  const dryRun = options.dryRun ?? false
35
35
 
36
- const collections = await scanCollections()
36
+ const collections = await scanCollections(createNodeFs(projectRoot))
37
37
  const migrations: FieldMigration[] = []
38
38
  const skipped: MigrationResult['skipped'] = []
39
39
 
@@ -0,0 +1,22 @@
1
+ import { createNodeFs, type ParseCache, scanCollections as coreScanCollections } from '@nuasite/cms-core'
2
+ import type { CollectionDefinition } from '@nuasite/cms-types'
3
+ import { getProjectRoot } from './config'
4
+
5
+ /**
6
+ * Process-persistent content-config parse cache shared across every standalone
7
+ * collection scan (server setup + each entry create/delete). cms-core's
8
+ * `parseContentConfig` is mtime-keyed, so threading one cache here lets repeated
9
+ * scans skip re-reading and re-Babel-parsing `content.config.ts` when it hasn't
10
+ * changed — the CmsCore instance keeps its own cache the same way, but these
11
+ * top-level call sites bypass it.
12
+ */
13
+ const parseCache: ParseCache = new Map()
14
+
15
+ /**
16
+ * Scan all content collections against the current project root, reusing the
17
+ * shared {@link parseCache}. Drop-in replacement for cms-core's `scanCollections`
18
+ * for the dev-server call sites that would otherwise each allocate a fresh cache.
19
+ */
20
+ export function scanCollections(contentDir?: string): Promise<Record<string, CollectionDefinition>> {
21
+ return coreScanCollections(createNodeFs(getProjectRoot()), contentDir, parseCache)
22
+ }
@@ -1,8 +1,10 @@
1
+ import { resolvePathnameFromSpec } from '@nuasite/cms-core'
1
2
  import fs from 'node:fs/promises'
2
3
  import path from 'node:path'
3
4
  import { isMap, isPair, isScalar, isSeq, LineCounter, parseDocument } from 'yaml'
4
5
 
5
6
  import { getProjectRoot } from '../config'
7
+ import { scanCollections } from '../scan-cache'
6
8
  import type { CollectionDefinition } from '../types'
7
9
  import { getCollectionTextIndex, getDeclaredUrlIndexCache, getMarkdownFileCache, setCollectionTextIndex } from './cache'
8
10
  import { normalizeText } from './snippet-utils'
@@ -249,6 +251,7 @@ const DECLARED_URL_FIELDS = ['urlpath', 'permalink', 'pathname', 'route', 'url']
249
251
  export async function findCollectionSource(
250
252
  pagePath: string,
251
253
  contentDir: string = 'src/content',
254
+ collections?: Record<string, CollectionDefinition>,
252
255
  ): Promise<CollectionInfo | undefined> {
253
256
  // Remove leading/trailing slashes
254
257
  const cleanPath = pagePath.replace(/^\/+|\/+$/g, '')
@@ -259,6 +262,18 @@ export async function findCollectionSource(
259
262
  }
260
263
 
261
264
  const requestedUrl = normalizeSitePath(`/${cleanPath}`)
265
+
266
+ // Highest priority: derive each entry's canonical URL from its collection's
267
+ // declarative `cms.pathname` rule (the same spec the forward resolver/manifest
268
+ // use) and match. This keeps reverse resolution (URL → source file) consistent
269
+ // with forward resolution without the entry needing a redundant `urlPath`
270
+ // frontmatter field, and correctly resolves entries whose filename differs
271
+ // from their URL slug (e.g. people stored as `<role>__<slug>.md` served at
272
+ // `/<family>/<slug>`). Collections without a `cms.pathname` rule contribute
273
+ // nothing here and fall through to the filename/declared-URL logic below.
274
+ const bySpec = await resolveBySpec(requestedUrl, contentDir, collections)
275
+ if (bySpec) return bySpec
276
+
262
277
  const contentPath = path.join(getProjectRoot(), contentDir)
263
278
 
264
279
  try {
@@ -357,6 +372,67 @@ export async function findCollectionSource(
357
372
  return undefined
358
373
  }
359
374
 
375
+ // Reverse index (computed spec URL → source) cached per scanned-collections
376
+ // object. The scan cache hands back a fresh object identity whenever the
377
+ // content config or any entry changes, so a WeakMap keyed on it rebuilds the
378
+ // index exactly when the underlying data changes and never goes stale.
379
+ const specPathnameIndexCache = new WeakMap<object, Map<string, CollectionInfo>>()
380
+
381
+ /**
382
+ * Resolve `requestedUrl` to a source file by applying each collection's
383
+ * declarative `cms.pathname` rule to its entries' frontmatter data. Returns
384
+ * undefined when no collection declares a rule or none produces `requestedUrl`.
385
+ *
386
+ * `collections` should be the definitions already scanned once at plugin setup
387
+ * (`manifestWriter.getCollectionDefinitions()`) — passing them keeps this off the
388
+ * hot per-page path with no rescans and guarantees the reverse index matches the
389
+ * forward resolver's specs exactly. When omitted (tests, misc callers) it falls
390
+ * back to a self-contained scan.
391
+ */
392
+ async function resolveBySpec(
393
+ requestedUrl: string,
394
+ contentDir: string,
395
+ collections: Record<string, CollectionDefinition> | undefined,
396
+ ): Promise<CollectionInfo | undefined> {
397
+ let defs = collections
398
+ if (!defs) {
399
+ try {
400
+ defs = await scanCollections(contentDir)
401
+ } catch {
402
+ return undefined
403
+ }
404
+ }
405
+
406
+ let index = specPathnameIndexCache.get(defs)
407
+ if (!index) {
408
+ index = buildSpecPathnameIndex(defs)
409
+ specPathnameIndexCache.set(defs, index)
410
+ }
411
+ return index.get(requestedUrl)
412
+ }
413
+
414
+ /**
415
+ * Build a `computed-URL → CollectionInfo` map from every collection that
416
+ * declares a `cms.pathname` rule. Collections and entries are visited in sorted
417
+ * order and the first writer of a URL wins, so a URL collision (a content bug)
418
+ * resolves deterministically rather than depending on scan/readdir order.
419
+ */
420
+ function buildSpecPathnameIndex(collections: Record<string, CollectionDefinition>): Map<string, CollectionInfo> {
421
+ const index = new Map<string, CollectionInfo>()
422
+ for (const name of Object.keys(collections).sort()) {
423
+ const def = collections[name]
424
+ if (!def?.pathname || !def.entries) continue
425
+ const entries = [...def.entries].sort((a, b) => a.slug.localeCompare(b.slug))
426
+ for (const entry of entries) {
427
+ if (!entry.data) continue
428
+ const url = resolvePathnameFromSpec(def, entry.data)
429
+ if (!url || index.has(url)) continue
430
+ index.set(url, { name: def.name, slug: entry.slug, file: entry.sourcePath })
431
+ }
432
+ }
433
+ return index
434
+ }
435
+
360
436
  /** Normalize a site-absolute path: ensure a leading slash, drop query/hash and any trailing slash. */
361
437
  function normalizeSitePath(p: string): string {
362
438
  let s = p.split('?')[0]?.split('#')[0] ?? p
package/src/types.ts CHANGED
@@ -24,6 +24,8 @@ export type {
24
24
  FieldHints,
25
25
  FieldType,
26
26
  MutationResult,
27
+ PathnameSegment,
28
+ PathnameSpec,
27
29
  } from '@nuasite/cms-types'
28
30
  export type {
29
31
  AddRedirectRequest,