@nuasite/cms 0.46.5 → 0.47.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/editor.js +2 -2
- package/package.json +7 -7
- package/src/admin/tsconfig.tsbuildinfo +1 -1
- package/src/build-processor.ts +1 -1
- package/src/dev-middleware.ts +29 -19
- package/src/handlers/api-routes.ts +1 -1
- package/src/index.ts +4 -4
- package/src/manifest-writer.ts +14 -6
- package/src/migrate-astro-image.ts +2 -2
- package/src/scan-cache.ts +22 -0
- package/src/source-finder/collection-finder.ts +76 -0
- package/src/types.ts +2 -0
- package/src/collection-scanner.ts +0 -990
- package/src/content-config-ast.ts +0 -501
package/src/dev-middleware.ts
CHANGED
|
@@ -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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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 '
|
|
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'
|
package/src/manifest-writer.ts
CHANGED
|
@@ -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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|