@nuasite/cms 0.27.0 → 0.29.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/README.md +103 -0
- package/dist/editor.js +10967 -10736
- package/package.json +1 -1
- package/src/collection-scanner.ts +203 -29
- package/src/dev-middleware.ts +86 -45
- package/src/editor/components/collections-browser.tsx +3 -11
- package/src/editor/components/create-page-modal.tsx +22 -10
- package/src/editor/components/fields.tsx +30 -8
- package/src/editor/components/frontmatter-fields.tsx +22 -4
- package/src/editor/components/link-edit-popover.tsx +232 -0
- package/src/editor/components/markdown-editor-overlay.tsx +16 -12
- package/src/editor/components/markdown-inline-editor.tsx +25 -52
- package/src/editor/components/mdx-block-view.tsx +21 -17
- package/src/editor/components/prop-editor.tsx +10 -5
- package/src/editor/hooks/useLinkPopover.ts +64 -0
- package/src/editor/milkdown-utils.ts +21 -0
- package/src/field-types.ts +111 -27
- package/src/handlers/api-routes.ts +10 -16
- package/src/index.ts +2 -0
- package/src/manifest-writer.ts +15 -0
- package/src/types.ts +19 -0
- package/src/vite-plugin.ts +18 -72
- package/src/content-invalidator.ts +0 -134
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'node:path'
|
|
|
3
3
|
import { isMap, isPair, isScalar, parse as parseYaml, parseDocument } from 'yaml'
|
|
4
4
|
import { getProjectRoot } from './config'
|
|
5
5
|
import { slugifyHref } from './shared'
|
|
6
|
-
import type { CollectionDefinition, CollectionEntryInfo, FieldDefinition, FieldType } from './types'
|
|
6
|
+
import type { CollectionDefinition, CollectionEntryInfo, FieldDefinition, FieldHints, FieldType } from './types'
|
|
7
7
|
|
|
8
8
|
/** Regex patterns for type inference */
|
|
9
9
|
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}/
|
|
@@ -31,7 +31,6 @@ const SIDEBAR_FIELD_NAMES = new Set([
|
|
|
31
31
|
'author',
|
|
32
32
|
])
|
|
33
33
|
|
|
34
|
-
/** Directive pattern: # @position <value> or # @group <value> */
|
|
35
34
|
/** Matches `@position <value>` or `@group <value>` in YAML comment text (# already stripped by parser) */
|
|
36
35
|
const DIRECTIVE_PATTERN = /^\s*@(position|group)\s+(.+)$/
|
|
37
36
|
|
|
@@ -400,10 +399,6 @@ async function parseContentConfigSchemaBlocks(): Promise<Array<{ collectionName:
|
|
|
400
399
|
const fullPath = path.join(projectRoot, configPath)
|
|
401
400
|
const content = await fs.readFile(fullPath, 'utf-8')
|
|
402
401
|
|
|
403
|
-
const collectionBlocks = content.matchAll(
|
|
404
|
-
/(?:const\s+(\w+)\s*=\s*)?defineCollection\s*\(\s*\{[\s\S]*?schema\s*:\s*z\.object\s*\(\s*\{([\s\S]*?)\}\s*\)/g,
|
|
405
|
-
)
|
|
406
|
-
|
|
407
402
|
// Map variable names to collection names from exports
|
|
408
403
|
const varToName = new Map<string, string>()
|
|
409
404
|
const exportMatch = content.match(/export\s+const\s+collections\s*=\s*\{([\s\S]*?)\}/)
|
|
@@ -414,13 +409,32 @@ async function parseContentConfigSchemaBlocks(): Promise<Array<{ collectionName:
|
|
|
414
409
|
}
|
|
415
410
|
}
|
|
416
411
|
|
|
412
|
+
// Find schema block starts via regex, then extract bodies with brace counting
|
|
413
|
+
// to correctly handle nested objects like n.number({ min: 1, max: 100 })
|
|
414
|
+
const schemaStart = /(?:const\s+(\w+)\s*=\s*)?defineCollection\s*\(\s*\{[\s\S]*?schema\s*:\s*(?:z|n)\.object\s*\(\s*\{/g
|
|
417
415
|
const blocks: Array<{ collectionName: string; schemaBody: string }> = []
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
416
|
+
|
|
417
|
+
let match
|
|
418
|
+
while ((match = schemaStart.exec(content)) !== null) {
|
|
419
|
+
const varName = match[1]
|
|
421
420
|
const collectionName = varName ? varToName.get(varName) : undefined
|
|
422
421
|
if (!collectionName) continue
|
|
423
|
-
|
|
422
|
+
|
|
423
|
+
// Brace-balanced extraction: the regex consumed the opening {,
|
|
424
|
+
// so start at depth 1 and scan forward for the matching }
|
|
425
|
+
const bodyStart = match.index + match[0].length
|
|
426
|
+
let depth = 1
|
|
427
|
+
let i = bodyStart
|
|
428
|
+
while (i < content.length && depth > 0) {
|
|
429
|
+
if (content[i] === '{') depth++
|
|
430
|
+
else if (content[i] === '}') depth--
|
|
431
|
+
i++
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (depth === 0) {
|
|
435
|
+
// i is one past the matching }, so body is [bodyStart, i-1)
|
|
436
|
+
blocks.push({ collectionName, schemaBody: content.slice(bodyStart, i - 1) })
|
|
437
|
+
}
|
|
424
438
|
}
|
|
425
439
|
|
|
426
440
|
if (blocks.length > 0) return blocks
|
|
@@ -455,7 +469,7 @@ function parseContentConfigReferences(
|
|
|
455
469
|
}
|
|
456
470
|
|
|
457
471
|
/** Valid field type names exported by `n` helper from @nuasite/cms */
|
|
458
|
-
const FIELD_HELPER_TYPES = new Set(['image', 'url', 'email', 'color', 'date', 'datetime', 'time', 'textarea'])
|
|
472
|
+
const FIELD_HELPER_TYPES = new Set(['text', 'number', 'image', 'url', 'email', 'tel', 'color', 'date', 'datetime', 'time', 'textarea'])
|
|
459
473
|
|
|
460
474
|
/**
|
|
461
475
|
* Parse the content config file to extract explicit field type hints:
|
|
@@ -501,17 +515,56 @@ function parseContentConfigFieldTypes(
|
|
|
501
515
|
}
|
|
502
516
|
|
|
503
517
|
/**
|
|
504
|
-
*
|
|
505
|
-
* Matches
|
|
518
|
+
* Parse the content config to find `.orderBy('asc'|'desc')` markers on fields.
|
|
519
|
+
* Matches patterns like `fieldName: n.number().orderBy('asc')`.
|
|
520
|
+
* Returns a map: collectionName → { field, direction }.
|
|
521
|
+
*/
|
|
522
|
+
function parseContentConfigOrderBy(
|
|
523
|
+
schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
|
|
524
|
+
): Map<string, { field: string; direction: 'asc' | 'desc' }> {
|
|
525
|
+
const result = new Map<string, { field: string; direction: 'asc' | 'desc' }>()
|
|
526
|
+
for (const { collectionName, schemaBody } of schemaBlocks) {
|
|
527
|
+
const match = schemaBody.match(/(\w+)\s*:.*\.orderBy\s*\(\s*(?:['"](\w+)['"])?\s*\)/)
|
|
528
|
+
if (match) {
|
|
529
|
+
const direction = match[2] === 'desc' ? 'desc' as const : 'asc' as const
|
|
530
|
+
result.set(collectionName, { field: match[1]!, direction })
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return result
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Apply orderBy configuration: set the field name and direction on the definition, then re-sort entries.
|
|
506
538
|
*/
|
|
507
|
-
function
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
539
|
+
function applyCollectionOrderBy(
|
|
540
|
+
collections: Record<string, CollectionDefinition>,
|
|
541
|
+
schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
|
|
542
|
+
): void {
|
|
543
|
+
const orderByFields = parseContentConfigOrderBy(schemaBlocks)
|
|
544
|
+
for (const [collectionName, { field: fieldName, direction }] of orderByFields) {
|
|
545
|
+
const def = collections[collectionName]
|
|
546
|
+
if (!def) continue
|
|
547
|
+
def.orderBy = fieldName
|
|
548
|
+
def.orderDirection = direction
|
|
549
|
+
if (def.entries && def.entries.length > 1) {
|
|
550
|
+
const dir = direction === 'desc' ? -1 : 1
|
|
551
|
+
def.entries.sort((a, b) => {
|
|
552
|
+
const aVal = a.data?.[fieldName]
|
|
553
|
+
const bVal = b.data?.[fieldName]
|
|
554
|
+
if (aVal == null && bVal == null) return 0
|
|
555
|
+
if (aVal == null) return 1
|
|
556
|
+
if (bVal == null) return -1
|
|
557
|
+
if (typeof aVal === 'number' && typeof bVal === 'number') return (aVal - bVal) * dir
|
|
558
|
+
if (aVal instanceof Date && bVal instanceof Date) return (aVal.getTime() - bVal.getTime()) * dir
|
|
559
|
+
return String(aVal).localeCompare(String(bVal)) * dir
|
|
560
|
+
})
|
|
561
|
+
}
|
|
511
562
|
}
|
|
512
|
-
return names
|
|
513
563
|
}
|
|
514
564
|
|
|
565
|
+
/** Match `fieldName:` patterns at the start of lines within a schema body. */
|
|
566
|
+
const SCHEMA_FIELD_PATTERN = /^\s*(\w+)\s*:/gm
|
|
567
|
+
|
|
515
568
|
/**
|
|
516
569
|
* When a content config schema exists, filter scanned fields to only include
|
|
517
570
|
* those defined in the schema. This prevents stale or extra frontmatter fields
|
|
@@ -524,32 +577,150 @@ function filterFieldsBySchema(
|
|
|
524
577
|
for (const { collectionName, schemaBody } of schemaBlocks) {
|
|
525
578
|
const def = collections[collectionName]
|
|
526
579
|
if (!def) continue
|
|
527
|
-
const schemaNames =
|
|
580
|
+
const schemaNames = new Set<string>()
|
|
581
|
+
for (const m of schemaBody.matchAll(SCHEMA_FIELD_PATTERN)) {
|
|
582
|
+
schemaNames.add(m[1]!)
|
|
583
|
+
}
|
|
528
584
|
if (schemaNames.size === 0) continue
|
|
529
585
|
def.fields = def.fields.filter(f => schemaNames.has(f.name))
|
|
530
586
|
}
|
|
531
587
|
}
|
|
532
588
|
|
|
533
589
|
/**
|
|
534
|
-
* Apply
|
|
590
|
+
* Apply a parsed per-field config map to scanned collection definitions.
|
|
535
591
|
*/
|
|
536
|
-
function
|
|
592
|
+
function applyPerFieldConfig<T>(
|
|
537
593
|
collections: Record<string, CollectionDefinition>,
|
|
538
|
-
|
|
594
|
+
configMap: Map<string, Map<string, T>>,
|
|
595
|
+
apply: (field: FieldDefinition, value: T) => void,
|
|
539
596
|
): void {
|
|
540
|
-
const
|
|
541
|
-
for (const [collectionName, fieldTypes] of configTypes) {
|
|
597
|
+
for (const [collectionName, fieldMap] of configMap) {
|
|
542
598
|
const def = collections[collectionName]
|
|
543
599
|
if (!def) continue
|
|
544
|
-
for (const [fieldName,
|
|
600
|
+
for (const [fieldName, value] of fieldMap) {
|
|
545
601
|
const field = def.fields.find(f => f.name === fieldName)
|
|
546
602
|
if (!field) continue
|
|
547
|
-
field
|
|
548
|
-
|
|
549
|
-
|
|
603
|
+
apply(field, value)
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** Apply field type overrides from config parsing to scanned collections. */
|
|
609
|
+
function applyConfigFieldTypes(
|
|
610
|
+
collections: Record<string, CollectionDefinition>,
|
|
611
|
+
schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
|
|
612
|
+
): void {
|
|
613
|
+
applyPerFieldConfig(collections, parseContentConfigFieldTypes(schemaBlocks), (field, override) => {
|
|
614
|
+
field.type = override.type
|
|
615
|
+
if (override.options) field.options = override.options
|
|
616
|
+
})
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** All recognized hint keys */
|
|
620
|
+
const VALID_HINT_KEYS = new Set(['min', 'max', 'step', 'placeholder', 'maxLength', 'minLength', 'rows', 'accept'])
|
|
621
|
+
/** Subset of hint keys that take numeric values */
|
|
622
|
+
const NUMERIC_HINT_KEYS = new Set(['min', 'max', 'step', 'maxLength', 'minLength', 'rows'])
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Parse `n.type({ key: value, ... })` options objects from schema blocks.
|
|
626
|
+
* Returns a map: collectionName → fieldName → FieldHints.
|
|
627
|
+
*/
|
|
628
|
+
function parseContentConfigFieldHints(
|
|
629
|
+
schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
|
|
630
|
+
): Map<string, Map<string, FieldHints>> {
|
|
631
|
+
const result = new Map<string, Map<string, FieldHints>>()
|
|
632
|
+
|
|
633
|
+
for (const { collectionName, schemaBody } of schemaBlocks) {
|
|
634
|
+
const fields = new Map<string, FieldHints>()
|
|
635
|
+
|
|
636
|
+
// Match: fieldName: n.helperName({ ...options })
|
|
637
|
+
const fieldMatches = schemaBody.matchAll(/(\w+)\s*:\s*n\.\w+\s*\(\s*\{([\s\S]*?)}\s*\)/g)
|
|
638
|
+
for (const m of fieldMatches) {
|
|
639
|
+
const fieldName = m[1]!
|
|
640
|
+
const optionsBody = m[2]!
|
|
641
|
+
const raw: Record<string, string | number> = {}
|
|
642
|
+
|
|
643
|
+
// Extract key-value pairs from the options body
|
|
644
|
+
const kvMatches = optionsBody.matchAll(/(\w+)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?[\d.]+))/g)
|
|
645
|
+
for (const kv of kvMatches) {
|
|
646
|
+
const key = kv[1]!
|
|
647
|
+
if (!VALID_HINT_KEYS.has(key)) continue
|
|
648
|
+
const strValue = kv[2] ?? kv[3]
|
|
649
|
+
const numValue = kv[4]
|
|
650
|
+
|
|
651
|
+
if (numValue != null && NUMERIC_HINT_KEYS.has(key)) {
|
|
652
|
+
raw[key] = Number(numValue)
|
|
653
|
+
} else if (strValue != null) {
|
|
654
|
+
if (NUMERIC_HINT_KEYS.has(key)) {
|
|
655
|
+
const parsed = Number(strValue)
|
|
656
|
+
raw[key] = Number.isNaN(parsed) ? strValue : parsed
|
|
657
|
+
} else {
|
|
658
|
+
raw[key] = strValue
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
const hints = raw as FieldHints
|
|
663
|
+
|
|
664
|
+
if (Object.keys(hints).length > 0) {
|
|
665
|
+
fields.set(fieldName, hints)
|
|
550
666
|
}
|
|
551
667
|
}
|
|
668
|
+
|
|
669
|
+
if (fields.size > 0) {
|
|
670
|
+
result.set(collectionName, fields)
|
|
671
|
+
}
|
|
552
672
|
}
|
|
673
|
+
return result
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Parse required/optional status from schema blocks.
|
|
678
|
+
* In Zod, fields are required by default. `.optional()`, `.nullable()`, and `.default(...)` make them not required.
|
|
679
|
+
*/
|
|
680
|
+
function parseContentConfigRequiredFields(
|
|
681
|
+
schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
|
|
682
|
+
): Map<string, Map<string, boolean>> {
|
|
683
|
+
const result = new Map<string, Map<string, boolean>>()
|
|
684
|
+
|
|
685
|
+
for (const { collectionName, schemaBody } of schemaBlocks) {
|
|
686
|
+
const fields = new Map<string, boolean>()
|
|
687
|
+
|
|
688
|
+
const fieldMatches = [...schemaBody.matchAll(SCHEMA_FIELD_PATTERN)]
|
|
689
|
+
for (let i = 0; i < fieldMatches.length; i++) {
|
|
690
|
+
const fieldName = fieldMatches[i]![1]!
|
|
691
|
+
const start = fieldMatches[i]!.index!
|
|
692
|
+
const end = i + 1 < fieldMatches.length ? fieldMatches[i + 1]!.index! : schemaBody.length
|
|
693
|
+
const fieldSource = schemaBody.slice(start, end)
|
|
694
|
+
|
|
695
|
+
const isOptional = /\.(optional|nullable|default)\s*\(/.test(fieldSource)
|
|
696
|
+
fields.set(fieldName, !isOptional)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (fields.size > 0) {
|
|
700
|
+
result.set(collectionName, fields)
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return result
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** Apply field hints from content config parsing to scanned collections. */
|
|
707
|
+
function applyConfigFieldHints(
|
|
708
|
+
collections: Record<string, CollectionDefinition>,
|
|
709
|
+
schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
|
|
710
|
+
): void {
|
|
711
|
+
applyPerFieldConfig(collections, parseContentConfigFieldHints(schemaBlocks), (field, hints) => {
|
|
712
|
+
field.hints = hints
|
|
713
|
+
})
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Apply required/optional status from content config to scanned collections. */
|
|
717
|
+
function applyConfigRequiredFields(
|
|
718
|
+
collections: Record<string, CollectionDefinition>,
|
|
719
|
+
schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
|
|
720
|
+
): void {
|
|
721
|
+
applyPerFieldConfig(collections, parseContentConfigRequiredFields(schemaBlocks), (field, required) => {
|
|
722
|
+
field.required = required
|
|
723
|
+
})
|
|
553
724
|
}
|
|
554
725
|
|
|
555
726
|
/**
|
|
@@ -787,12 +958,15 @@ export async function scanCollections(contentDir: string = 'src/content'): Promi
|
|
|
787
958
|
// Content directory doesn't exist or isn't readable
|
|
788
959
|
}
|
|
789
960
|
|
|
790
|
-
// Post-scan: apply explicit type hints, detect references,
|
|
961
|
+
// Post-scan: apply explicit type hints, field hints, required status, detect references, derived fields, and ordering
|
|
791
962
|
const schemaBlocks = await parseContentConfigSchemaBlocks()
|
|
792
963
|
filterFieldsBySchema(collections, schemaBlocks)
|
|
793
964
|
applyConfigFieldTypes(collections, schemaBlocks)
|
|
965
|
+
applyConfigFieldHints(collections, schemaBlocks)
|
|
966
|
+
applyConfigRequiredFields(collections, schemaBlocks)
|
|
794
967
|
await detectReferenceFields(collections, schemaBlocks)
|
|
795
968
|
detectDerivedHrefFields(collections)
|
|
969
|
+
applyCollectionOrderBy(collections, schemaBlocks)
|
|
796
970
|
|
|
797
971
|
return collections
|
|
798
972
|
}
|
package/src/dev-middleware.ts
CHANGED
|
@@ -2,7 +2,6 @@ import fs from 'node:fs/promises'
|
|
|
2
2
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { getProjectRoot } from './config'
|
|
5
|
-
import { awaitNextContentStoreUpdate } from './content-invalidator'
|
|
6
5
|
import { handleCmsApiRoute } from './handlers/api-routes'
|
|
7
6
|
import { buildMapPattern, detectArrayPattern, extractArrayElementProps, parseInlineArrayName } from './handlers/array-ops'
|
|
8
7
|
import {
|
|
@@ -104,43 +103,6 @@ export function createDevMiddleware(
|
|
|
104
103
|
if (options.enableCmsApi) {
|
|
105
104
|
const projectRoot = getProjectRoot()
|
|
106
105
|
|
|
107
|
-
/**
|
|
108
|
-
* Hold the HTTP response for a `markdown/update` (or equivalent) call
|
|
109
|
-
* until Astro's content layer has actually re-synced the edited file.
|
|
110
|
-
*
|
|
111
|
-
* The race we're fixing: handleUpdateMarkdown writes the file and
|
|
112
|
-
* returns immediately, the editor then triggers a full-reload, and
|
|
113
|
-
* the next page render reads a still-cached `astro:data-layer-content`
|
|
114
|
-
* virtual module — so the user sees their edit disappear until Astro's
|
|
115
|
-
* async chain (glob loader → syncData → 500 ms save debounce → atomic
|
|
116
|
-
* write → fs.watch → invalidateModule) finally catches up.
|
|
117
|
-
*
|
|
118
|
-
* The fix, end to end:
|
|
119
|
-
*
|
|
120
|
-
* 1. `server.watcher.emit('change', fullPath)` kicks Astro's glob
|
|
121
|
-
* loader directly. It is registered on this exact watcher (see
|
|
122
|
-
* astro/dist/core/dev/dev.js — `viteServer.watcher` is handed to
|
|
123
|
-
* `globalContentLayer.init`), so synthetic change events fire its
|
|
124
|
-
* `onChange` handler and trigger `syncData`. This also works
|
|
125
|
-
* around Vite's bundled chokidar missing some edits.
|
|
126
|
-
* 2. `awaitNextContentStoreUpdate` parks until the shared data-store
|
|
127
|
-
* watcher (in `vite-plugin.ts`) observes the resulting atomic
|
|
128
|
-
* write and finishes invalidating the SSR module graph.
|
|
129
|
-
* 3. Only then do we return — so the subsequent full-reload lands
|
|
130
|
-
* on a page that will re-execute with fresh content.
|
|
131
|
-
*
|
|
132
|
-
* The timeout fallback covers edits that legitimately do not rewrite
|
|
133
|
-
* the data store (Astro's MutableDataStore skips identical writes).
|
|
134
|
-
* In that case no fs.watch event will ever fire, and 3 s is plenty of
|
|
135
|
-
* budget before we give up and let the response through anyway.
|
|
136
|
-
*/
|
|
137
|
-
const notifyContentChanged = async (filePath: string): Promise<void> => {
|
|
138
|
-
const fullPath = path.resolve(projectRoot, filePath)
|
|
139
|
-
const waiter = awaitNextContentStoreUpdate(3000)
|
|
140
|
-
server.watcher?.emit('change', fullPath)
|
|
141
|
-
await waiter
|
|
142
|
-
}
|
|
143
|
-
|
|
144
106
|
server.middlewares.use((req, res, next) => {
|
|
145
107
|
const url = req.url || ''
|
|
146
108
|
if (!url.startsWith('/_nua/cms/')) {
|
|
@@ -152,7 +114,7 @@ export function createDevMiddleware(
|
|
|
152
114
|
|
|
153
115
|
const route = url.replace('/_nua/cms/', '').split('?')[0]!
|
|
154
116
|
|
|
155
|
-
handleCmsApiRoute(route, req, res, manifestWriter, config.contentDir, options.mediaAdapter
|
|
117
|
+
handleCmsApiRoute(route, req, res, manifestWriter, config.contentDir, options.mediaAdapter)
|
|
156
118
|
.catch((error) => {
|
|
157
119
|
console.error('[astro-cms] API error:', error)
|
|
158
120
|
sendError(res, 'Internal server error', 500)
|
|
@@ -177,11 +139,31 @@ export function createDevMiddleware(
|
|
|
177
139
|
pageMap.set(pagePath, { pathname: pagePath })
|
|
178
140
|
}
|
|
179
141
|
|
|
180
|
-
// 2. Add collection entry pages from collection definitions
|
|
142
|
+
// 2. Add collection entry pages from collection definitions,
|
|
143
|
+
// pre-populating pathnames from filesystem routes so the collections
|
|
144
|
+
// browser can redirect to detail pages without visiting them first.
|
|
145
|
+
// We build patched copies rather than mutating the originals so that
|
|
146
|
+
// heuristic pathnames don't persist if the route file is later removed.
|
|
181
147
|
const collectionDefs = manifestWriter.getCollectionDefinitions()
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
148
|
+
const collectionRoutes = await discoverCollectionRoutes()
|
|
149
|
+
const responseCollectionDefs: Record<string, CollectionDefinition> = {}
|
|
150
|
+
|
|
151
|
+
for (const [name, def] of Object.entries(collectionDefs)) {
|
|
152
|
+
const routePrefix = collectionRoutes.get(def.name)
|
|
153
|
+
const needsPatching = routePrefix && def.entries?.some(e => !e.pathname)
|
|
154
|
+
|
|
155
|
+
if (!needsPatching) {
|
|
156
|
+
responseCollectionDefs[name] = def
|
|
157
|
+
} else {
|
|
158
|
+
responseCollectionDefs[name] = {
|
|
159
|
+
...def,
|
|
160
|
+
entries: def.entries!.map(e => e.pathname ? e : { ...e, pathname: `${routePrefix}${e.slug}` }),
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const entries = responseCollectionDefs[name].entries
|
|
165
|
+
if (entries) {
|
|
166
|
+
for (const entry of entries) {
|
|
185
167
|
if (entry.pathname) {
|
|
186
168
|
pageMap.set(entry.pathname, { pathname: entry.pathname, title: entry.title })
|
|
187
169
|
}
|
|
@@ -205,8 +187,8 @@ export function createDevMiddleware(
|
|
|
205
187
|
availableTextStyles: manifestWriter.getAvailableTextStyles(),
|
|
206
188
|
pages,
|
|
207
189
|
}
|
|
208
|
-
if (Object.keys(
|
|
209
|
-
manifest.collectionDefinitions =
|
|
190
|
+
if (Object.keys(responseCollectionDefs).length > 0) {
|
|
191
|
+
manifest.collectionDefinitions = responseCollectionDefs
|
|
210
192
|
}
|
|
211
193
|
const mdxComponents = manifestWriter.getMdxComponents()
|
|
212
194
|
if (mdxComponents) {
|
|
@@ -626,6 +608,65 @@ async function discoverPagesFromFilesystem(): Promise<string[]> {
|
|
|
626
608
|
return pages
|
|
627
609
|
}
|
|
628
610
|
|
|
611
|
+
/** Cached result of collection route discovery; invalidated by file watcher */
|
|
612
|
+
let collectionRoutesCache: Map<string, string> | null = null
|
|
613
|
+
|
|
614
|
+
/** Invalidate the cached collection routes (called from vite-plugin when route files change) */
|
|
615
|
+
export function invalidateCollectionRoutesCache() {
|
|
616
|
+
collectionRoutesCache = null
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Discover collection route patterns by scanning src/pages for dynamic route files
|
|
621
|
+
* (e.g. [slug].astro) that call getCollection(). Returns a map from collection name
|
|
622
|
+
* to the URL prefix (e.g. 'blog' → '/blog/'). Result is cached after first call.
|
|
623
|
+
*/
|
|
624
|
+
async function discoverCollectionRoutes(): Promise<Map<string, string>> {
|
|
625
|
+
if (collectionRoutesCache) return collectionRoutesCache
|
|
626
|
+
|
|
627
|
+
const projectRoot = getProjectRoot()
|
|
628
|
+
const pagesDir = path.join(projectRoot, 'src', 'pages')
|
|
629
|
+
const routes = new Map<string, string>()
|
|
630
|
+
|
|
631
|
+
try {
|
|
632
|
+
await fs.access(pagesDir)
|
|
633
|
+
} catch {
|
|
634
|
+
collectionRoutesCache = routes
|
|
635
|
+
return routes
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async function walk(dir: string, urlPrefix: string) {
|
|
639
|
+
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
640
|
+
for (const entry of entries) {
|
|
641
|
+
if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue
|
|
642
|
+
|
|
643
|
+
const fullPath = path.join(dir, entry.name)
|
|
644
|
+
if (entry.isDirectory()) {
|
|
645
|
+
// Skip directories with dynamic segments
|
|
646
|
+
if (entry.name.includes('[')) continue
|
|
647
|
+
await walk(fullPath, `${urlPrefix}${entry.name}/`)
|
|
648
|
+
} else {
|
|
649
|
+
const ext = path.extname(entry.name)
|
|
650
|
+
if (!PAGE_EXTENSIONS.has(ext)) continue
|
|
651
|
+
// Only interested in dynamic route files
|
|
652
|
+
if (!entry.name.includes('[')) continue
|
|
653
|
+
|
|
654
|
+
try {
|
|
655
|
+
const content = await fs.readFile(fullPath, 'utf-8')
|
|
656
|
+
const match = content.match(/getCollection\(\s*['"](\w+)['"]\s*\)/)
|
|
657
|
+
if (match?.[1]) {
|
|
658
|
+
routes.set(match[1], urlPrefix)
|
|
659
|
+
}
|
|
660
|
+
} catch { /* skip unreadable files */ }
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
await walk(pagesDir, '/')
|
|
666
|
+
collectionRoutesCache = routes
|
|
667
|
+
return routes
|
|
668
|
+
}
|
|
669
|
+
|
|
629
670
|
function mediaMimeFromExt(ext: string): string {
|
|
630
671
|
const map: Record<string, string> = {
|
|
631
672
|
'.jpg': 'image/jpeg',
|
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
selectBrowserCollection,
|
|
12
12
|
selectedBrowserCollection,
|
|
13
13
|
} from '../signals'
|
|
14
|
-
import { savePendingEntryNavigation } from '../storage'
|
|
15
14
|
import { ChevronRightIcon, CollectionIcon } from './create-page-modal'
|
|
16
15
|
import { CloseButton, ModalBackdrop, ModalHeader } from './modal-shell'
|
|
17
16
|
|
|
@@ -51,16 +50,9 @@ export function CollectionsBrowser() {
|
|
|
51
50
|
const def = selectedDef
|
|
52
51
|
if (!def) return null
|
|
53
52
|
|
|
54
|
-
const handleEntryClick = (slug: string, sourcePath: string
|
|
53
|
+
const handleEntryClick = (slug: string, sourcePath: string) => {
|
|
55
54
|
closeCollectionsBrowser()
|
|
56
|
-
|
|
57
|
-
// Navigate to the collection detail page to edit inline.
|
|
58
|
-
savePendingEntryNavigation({ collectionName: selected, slug, sourcePath, pathname })
|
|
59
|
-
window.location.href = pathname
|
|
60
|
-
} else {
|
|
61
|
-
// No detail page exists for this entry — open the markdown editor inline.
|
|
62
|
-
openMarkdownEditorForEntry(selected, slug, sourcePath, def)
|
|
63
|
-
}
|
|
55
|
+
openMarkdownEditorForEntry(selected, slug, sourcePath, def)
|
|
64
56
|
}
|
|
65
57
|
|
|
66
58
|
const handleAddNew = () => {
|
|
@@ -201,7 +193,7 @@ export function CollectionsBrowser() {
|
|
|
201
193
|
: (
|
|
202
194
|
<button
|
|
203
195
|
type="button"
|
|
204
|
-
onClick={() => handleEntryClick(entry.slug, entry.sourcePath
|
|
196
|
+
onClick={() => handleEntryClick(entry.slug, entry.sourcePath)}
|
|
205
197
|
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-white/10 rounded-cms-lg transition-colors text-left group"
|
|
206
198
|
data-cms-ui
|
|
207
199
|
>
|
|
@@ -206,7 +206,12 @@ function NewPageForm() {
|
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
return (
|
|
209
|
-
|
|
209
|
+
<form
|
|
210
|
+
onSubmit={(e) => {
|
|
211
|
+
e.preventDefault()
|
|
212
|
+
handleSubmit()
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
210
215
|
<ModalHeader title="New Blank Page" onBack={() => setCreatePageMode('pick')} onClose={() => resetCreatePageState()} />
|
|
211
216
|
<div class="p-5 space-y-4">
|
|
212
217
|
<Field label="Title">
|
|
@@ -215,6 +220,7 @@ function NewPageForm() {
|
|
|
215
220
|
value={form.title}
|
|
216
221
|
onInput={(e) => form.handleTitleChange((e.target as HTMLInputElement).value)}
|
|
217
222
|
placeholder="My New Page"
|
|
223
|
+
required
|
|
218
224
|
class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white placeholder:text-white/30 focus:outline-none focus:border-cms-primary/50"
|
|
219
225
|
autoFocus
|
|
220
226
|
data-cms-ui
|
|
@@ -229,6 +235,7 @@ function NewPageForm() {
|
|
|
229
235
|
value={form.slug}
|
|
230
236
|
onInput={(e) => form.handleSlugChange((e.target as HTMLInputElement).value)}
|
|
231
237
|
placeholder="my-new-page"
|
|
238
|
+
required
|
|
232
239
|
class="flex-1 px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white placeholder:text-white/30 focus:outline-none focus:border-cms-primary/50"
|
|
233
240
|
data-cms-ui
|
|
234
241
|
/>
|
|
@@ -253,16 +260,15 @@ function NewPageForm() {
|
|
|
253
260
|
<ModalFooter>
|
|
254
261
|
<CancelButton onClick={() => resetCreatePageState()} />
|
|
255
262
|
<button
|
|
256
|
-
type="
|
|
257
|
-
|
|
258
|
-
disabled={!canSubmit}
|
|
263
|
+
type="submit"
|
|
264
|
+
disabled={!!form.slugError || form.slugChecking || form.isSubmitting}
|
|
259
265
|
class="px-5 py-2.5 text-sm font-medium rounded-cms-pill transition-colors cursor-pointer bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover disabled:opacity-40 disabled:cursor-not-allowed"
|
|
260
266
|
data-cms-ui
|
|
261
267
|
>
|
|
262
268
|
Create Page
|
|
263
269
|
</button>
|
|
264
270
|
</ModalFooter>
|
|
265
|
-
|
|
271
|
+
</form>
|
|
266
272
|
)
|
|
267
273
|
}
|
|
268
274
|
|
|
@@ -302,7 +308,12 @@ function DuplicatePageForm() {
|
|
|
302
308
|
}
|
|
303
309
|
|
|
304
310
|
return (
|
|
305
|
-
|
|
311
|
+
<form
|
|
312
|
+
onSubmit={(e) => {
|
|
313
|
+
e.preventDefault()
|
|
314
|
+
handleSubmit()
|
|
315
|
+
}}
|
|
316
|
+
>
|
|
306
317
|
<ModalHeader title="Duplicate Page" onBack={() => setCreatePageMode('pick')} onClose={() => resetCreatePageState()} />
|
|
307
318
|
<div class="p-5 space-y-4">
|
|
308
319
|
<Field label="Source Page">
|
|
@@ -312,6 +323,7 @@ function DuplicatePageForm() {
|
|
|
312
323
|
setSourcePath((e.target as HTMLSelectElement).value)
|
|
313
324
|
form.resetSlugManual()
|
|
314
325
|
}}
|
|
326
|
+
required
|
|
315
327
|
class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white focus:outline-none focus:border-cms-primary/50"
|
|
316
328
|
data-cms-ui
|
|
317
329
|
>
|
|
@@ -342,6 +354,7 @@ function DuplicatePageForm() {
|
|
|
342
354
|
value={form.slug}
|
|
343
355
|
onInput={(e) => form.handleSlugChange((e.target as HTMLInputElement).value)}
|
|
344
356
|
placeholder="new-page-slug"
|
|
357
|
+
required
|
|
345
358
|
class="flex-1 px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white placeholder:text-white/30 focus:outline-none focus:border-cms-primary/50"
|
|
346
359
|
data-cms-ui
|
|
347
360
|
/>
|
|
@@ -363,16 +376,15 @@ function DuplicatePageForm() {
|
|
|
363
376
|
<ModalFooter>
|
|
364
377
|
<CancelButton onClick={() => resetCreatePageState()} />
|
|
365
378
|
<button
|
|
366
|
-
type="
|
|
367
|
-
|
|
368
|
-
disabled={!canSubmit}
|
|
379
|
+
type="submit"
|
|
380
|
+
disabled={!!form.slugError || form.slugChecking || form.isSubmitting}
|
|
369
381
|
class="px-5 py-2.5 text-sm font-medium rounded-cms-pill transition-colors cursor-pointer bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover disabled:opacity-40 disabled:cursor-not-allowed"
|
|
370
382
|
data-cms-ui
|
|
371
383
|
>
|
|
372
384
|
Duplicate Page
|
|
373
385
|
</button>
|
|
374
386
|
</ModalFooter>
|
|
375
|
-
|
|
387
|
+
</form>
|
|
376
388
|
)
|
|
377
389
|
}
|
|
378
390
|
|