@nuasite/cms 0.18.0 → 0.19.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.
Files changed (62) hide show
  1. package/dist/editor.js +44697 -26834
  2. package/package.json +23 -21
  3. package/src/build-processor.ts +4 -1
  4. package/src/collection-scanner.ts +425 -48
  5. package/src/dev-middleware.ts +26 -203
  6. package/src/editor/api.ts +1 -22
  7. package/src/editor/components/ai-chat.tsx +3 -3
  8. package/src/editor/components/ai-tooltip.tsx +2 -1
  9. package/src/editor/components/block-editor.tsx +13 -108
  10. package/src/editor/components/collections-browser.tsx +168 -205
  11. package/src/editor/components/component-card.tsx +49 -0
  12. package/src/editor/components/confirm-dialog.tsx +34 -47
  13. package/src/editor/components/create-page-modal.tsx +529 -101
  14. package/src/editor/components/delete-page-dialog.tsx +100 -0
  15. package/src/editor/components/fields.tsx +175 -0
  16. package/src/editor/components/frontmatter-fields.tsx +281 -70
  17. package/src/editor/components/frontmatter-sidebar.tsx +223 -0
  18. package/src/editor/components/highlight-overlay.ts +3 -2
  19. package/src/editor/components/markdown-editor-overlay.tsx +131 -85
  20. package/src/editor/components/markdown-inline-editor.tsx +74 -5
  21. package/src/editor/components/mdx-block-view.tsx +102 -0
  22. package/src/editor/components/mdx-component-picker.tsx +123 -0
  23. package/src/editor/components/mdx-props-editor.tsx +94 -0
  24. package/src/editor/components/media-library.tsx +373 -100
  25. package/src/editor/components/modal-shell.tsx +87 -0
  26. package/src/editor/components/prop-editor.tsx +52 -0
  27. package/src/editor/components/redirect-countdown.tsx +3 -1
  28. package/src/editor/components/redirects-manager.tsx +269 -0
  29. package/src/editor/components/reference-picker.tsx +203 -0
  30. package/src/editor/components/seo-editor.tsx +285 -303
  31. package/src/editor/components/toast/toast-container.tsx +2 -1
  32. package/src/editor/components/toolbar.tsx +177 -46
  33. package/src/editor/constants.ts +26 -0
  34. package/src/editor/editor.ts +112 -0
  35. package/src/editor/fetch.ts +62 -0
  36. package/src/editor/index.tsx +19 -1
  37. package/src/editor/markdown-api.ts +105 -156
  38. package/src/editor/milkdown-mdx-plugin.tsx +269 -0
  39. package/src/editor/signals.ts +206 -13
  40. package/src/editor/types.ts +52 -1
  41. package/src/handlers/api-routes.ts +251 -0
  42. package/src/handlers/component-ops.ts +2 -18
  43. package/src/handlers/markdown-ops.ts +202 -47
  44. package/src/handlers/page-ops.ts +229 -0
  45. package/src/handlers/redirect-ops.ts +163 -0
  46. package/src/handlers/source-writer.ts +157 -1
  47. package/src/html-processor.ts +14 -2
  48. package/src/index.ts +76 -2
  49. package/src/manifest-writer.ts +19 -1
  50. package/src/media/contember.ts +2 -1
  51. package/src/media/local.ts +66 -28
  52. package/src/media/project-images.ts +81 -0
  53. package/src/media/s3.ts +32 -11
  54. package/src/media/types.ts +24 -2
  55. package/src/shared.ts +27 -0
  56. package/src/source-finder/collection-finder.ts +219 -41
  57. package/src/source-finder/index.ts +7 -1
  58. package/src/source-finder/search-index.ts +178 -36
  59. package/src/source-finder/snippet-utils.ts +423 -3
  60. package/src/tsconfig.json +0 -2
  61. package/src/types.ts +111 -2
  62. package/src/utils.ts +40 -4
@@ -0,0 +1,163 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { getProjectRoot } from '../config'
4
+ import type { AddRedirectRequest, DeleteRedirectRequest, RedirectOperationResponse, RedirectRule, UpdateRedirectRequest } from '../types'
5
+ import { acquireFileLock, isNodeError } from '../utils'
6
+
7
+ const DEFAULT_STATUS_CODE = 307
8
+ const REDIRECTS_FILE = 'src/_redirects'
9
+
10
+ function getRedirectsFilePath(): string {
11
+ return path.join(getProjectRoot(), REDIRECTS_FILE)
12
+ }
13
+
14
+ export async function handleGetRedirects(): Promise<{ rules: RedirectRule[] }> {
15
+ const lines = await readRedirectsFile(getRedirectsFilePath())
16
+ return { rules: parseRedirectLines(lines) }
17
+ }
18
+
19
+ export async function handleAddRedirect(request: AddRedirectRequest): Promise<RedirectOperationResponse> {
20
+ const { source, destination, statusCode = DEFAULT_STATUS_CODE } = request
21
+
22
+ if (!source || !destination) {
23
+ return { success: false, error: 'Source and destination are required' }
24
+ }
25
+ if (!source.startsWith('/')) {
26
+ return { success: false, error: 'Source must start with /' }
27
+ }
28
+ if (!destination.startsWith('/') && !destination.startsWith('http')) {
29
+ return { success: false, error: 'Destination must start with / or http' }
30
+ }
31
+
32
+ const filePath = getRedirectsFilePath()
33
+ const release = await acquireFileLock(filePath)
34
+
35
+ try {
36
+ const lines = await readRedirectsFile(filePath)
37
+ const existing = parseRedirectLines(lines)
38
+
39
+ if (existing.some(r => r.source === source)) {
40
+ return { success: false, error: `Redirect already exists for ${source}` }
41
+ }
42
+
43
+ lines.push(formatRedirectLine(source, destination, statusCode))
44
+ await writeRedirectsFile(filePath, lines)
45
+ return { success: true }
46
+ } finally {
47
+ release()
48
+ }
49
+ }
50
+
51
+ export async function handleUpdateRedirect(request: UpdateRedirectRequest): Promise<RedirectOperationResponse> {
52
+ const { lineIndex, source, destination, statusCode = DEFAULT_STATUS_CODE } = request
53
+
54
+ if (!source || !destination) {
55
+ return { success: false, error: 'Source and destination are required' }
56
+ }
57
+
58
+ const filePath = getRedirectsFilePath()
59
+ const release = await acquireFileLock(filePath)
60
+
61
+ try {
62
+ const lines = await readRedirectsFile(filePath)
63
+
64
+ // Guard against stale line index
65
+ const currentLine = lines[lineIndex]?.trim()
66
+ if (!currentLine || currentLine.startsWith('#')) {
67
+ return { success: false, error: 'Line at index is no longer a redirect rule — please refresh and try again' }
68
+ }
69
+
70
+ lines[lineIndex] = formatRedirectLine(source, destination, statusCode)
71
+ await writeRedirectsFile(filePath, lines)
72
+ return { success: true }
73
+ } finally {
74
+ release()
75
+ }
76
+ }
77
+
78
+ export async function handleDeleteRedirect(request: DeleteRedirectRequest): Promise<RedirectOperationResponse> {
79
+ const { lineIndex } = request
80
+
81
+ const filePath = getRedirectsFilePath()
82
+ const release = await acquireFileLock(filePath)
83
+
84
+ try {
85
+ const lines = await readRedirectsFile(filePath)
86
+
87
+ if (lineIndex < 0 || lineIndex >= lines.length) {
88
+ return { success: false, error: `Invalid line index: ${lineIndex}` }
89
+ }
90
+
91
+ const line = lines[lineIndex]!.trim()
92
+ if (!line || line.startsWith('#')) {
93
+ return { success: false, error: 'Line is not a redirect rule' }
94
+ }
95
+
96
+ lines.splice(lineIndex, 1)
97
+ await writeRedirectsFile(filePath, lines)
98
+ return { success: true }
99
+ } finally {
100
+ release()
101
+ }
102
+ }
103
+
104
+ // --- Internal helpers ---
105
+
106
+ function formatRedirectLine(source: string, destination: string, statusCode: number): string {
107
+ return statusCode === DEFAULT_STATUS_CODE
108
+ ? `${source} ${destination}`
109
+ : `${source} ${destination} ${statusCode}`
110
+ }
111
+
112
+ async function readRedirectsFile(filePath: string): Promise<string[]> {
113
+ try {
114
+ const content = await fs.readFile(filePath, 'utf-8')
115
+ return content.split('\n')
116
+ } catch (error) {
117
+ if (isNodeError(error, 'ENOENT')) return []
118
+ throw error
119
+ }
120
+ }
121
+
122
+ async function writeRedirectsFile(filePath: string, lines: string[]): Promise<void> {
123
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
124
+
125
+ const trimmed = lines.slice()
126
+ while (trimmed.length > 0 && trimmed[trimmed.length - 1]!.trim() === '') {
127
+ trimmed.pop()
128
+ }
129
+
130
+ if (trimmed.length === 0) {
131
+ await fs.writeFile(filePath, '', 'utf-8')
132
+ return
133
+ }
134
+
135
+ await fs.writeFile(filePath, trimmed.join('\n') + '\n', 'utf-8')
136
+ }
137
+
138
+ function parseRedirectLines(lines: string[]): RedirectRule[] {
139
+ const rules: RedirectRule[] = []
140
+
141
+ for (let i = 0; i < lines.length; i++) {
142
+ const line = lines[i]!.trim()
143
+ if (!line || line.startsWith('#')) continue
144
+
145
+ const parts = line.split(/\s+/)
146
+ if (parts.length < 2) continue
147
+
148
+ const source = parts[0]!
149
+ if (!source.startsWith('/')) continue
150
+
151
+ const destination = parts[1]!
152
+ const statusCode = parts[2] ? parseInt(parts[2], 10) : DEFAULT_STATUS_CODE
153
+
154
+ rules.push({
155
+ source,
156
+ destination,
157
+ statusCode: Number.isNaN(statusCode) ? DEFAULT_STATUS_CODE : statusCode,
158
+ lineIndex: i,
159
+ })
160
+ }
161
+
162
+ return rules
163
+ }
@@ -1,9 +1,11 @@
1
1
  import { NodeType, parse as parseHtml } from 'node-html-parser'
2
2
  import fs from 'node:fs/promises'
3
3
  import path from 'node:path'
4
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
4
5
  import { getProjectRoot } from '../config'
5
6
  import type { AttributeChangePayload, ChangePayload, SaveBatchRequest } from '../editor/types'
6
7
  import type { ManifestWriter } from '../manifest-writer'
8
+ import { extractAstroImageOriginalUrl } from '../source-finder/snippet-utils'
7
9
  import type { CmsManifest, ManifestEntry } from '../types'
8
10
  import { acquireFileLock, escapeReplacement, normalizePagePath, resolveAndValidatePath } from '../utils'
9
11
 
@@ -59,7 +61,6 @@ export async function handleUpdate(
59
61
  fileChanges,
60
62
  manifest,
61
63
  )
62
-
63
64
  if (failedChanges.length > 0) {
64
65
  errors.push(...failedChanges)
65
66
  }
@@ -185,6 +186,32 @@ export function applyImageChange(
185
186
  }
186
187
  }
187
188
 
189
+ // Extract original path from Astro Image optimization URLs (/_image?href=...)
190
+ const decodedHref = extractAstroImageOriginalUrl(originalSrc)
191
+ if (decodedHref && !srcCandidates.includes(decodedHref)) {
192
+ srcCandidates.push(decodedHref)
193
+ }
194
+
195
+ // Extract the authored value from YAML/JSON source snippets.
196
+ // Astro optimizes images from content collections (e.g. ./images/photo.jpg → /assets/hash.webp),
197
+ // so the rendered URL won't match the value in the data file. Parse the snippet to recover it.
198
+ if (change.sourceSnippet) {
199
+ const yamlKeyMatch = change.sourceSnippet.match(/^\s*([\w][\w-]*):\s*/)
200
+ if (yamlKeyMatch?.[1]) {
201
+ try {
202
+ const parsed = parseYaml(change.sourceSnippet)
203
+ if (parsed && typeof parsed === 'object') {
204
+ const value = (parsed as Record<string, unknown>)[yamlKeyMatch[1]]
205
+ if (typeof value === 'string' && !srcCandidates.includes(value)) {
206
+ srcCandidates.push(value)
207
+ }
208
+ }
209
+ } catch {
210
+ // Not valid YAML, ignore
211
+ }
212
+ }
213
+ }
214
+
188
215
  let newContent = content
189
216
  let replacedIndex = -1
190
217
  for (const srcToFind of srcCandidates) {
@@ -209,6 +236,46 @@ export function applyImageChange(
209
236
  }
210
237
  }
211
238
 
239
+ // Fallback: try YAML key-value replacement for collection frontmatter fields
240
+ // Try all srcCandidates since the rendered URL may differ from the authored YAML value
241
+ if (replacedIndex < 0 && change.sourceSnippet) {
242
+ for (const srcToFind of srcCandidates) {
243
+ const yamlResult = tryYamlValueReplacement(change.sourceSnippet, srcToFind, newSrc)
244
+ if (yamlResult !== null) {
245
+ // Search near the source line to avoid matching a duplicate snippet elsewhere
246
+ let searchStart = 0
247
+ if (change.sourceLine > 1) {
248
+ let linesFound = 0
249
+ for (let j = 0; j < newContent.length; j++) {
250
+ if (newContent[j] === '\n' && ++linesFound >= change.sourceLine - 1) {
251
+ searchStart = j + 1
252
+ break
253
+ }
254
+ }
255
+ }
256
+ const snippetIdx = newContent.indexOf(change.sourceSnippet, searchStart)
257
+ if (snippetIdx >= 0) {
258
+ replacedIndex = snippetIdx
259
+ newContent = newContent.slice(0, snippetIdx) + yamlResult + newContent.slice(snippetIdx + change.sourceSnippet.length)
260
+ break
261
+ }
262
+ }
263
+ }
264
+ }
265
+
266
+ // Fallback: direct quoted-value replacement for data files (JSON, YAML, MD frontmatter)
267
+ // The source file may be a collection data file where the image is a plain string value
268
+ if (replacedIndex < 0 && change.sourceSnippet) {
269
+ for (const srcToFind of srcCandidates) {
270
+ const result = tryDataFileValueReplacement(newContent, change.sourceSnippet, srcToFind, newSrc, change.sourceLine)
271
+ if (result) {
272
+ replacedIndex = result.index
273
+ newContent = result.content
274
+ break
275
+ }
276
+ }
277
+ }
278
+
212
279
  // Fallback: if literal src not found, try to find an expression-based src attribute
213
280
  // near the source line (handles src={variable}, src={obj.prop}, etc.)
214
281
  if (replacedIndex < 0 && change.sourceLine > 0) {
@@ -543,6 +610,13 @@ export function applyTextChange(
543
610
  const updatedSnippet = sourceSnippet.replace(resolvedOriginal, resolvedNewText)
544
611
 
545
612
  if (updatedSnippet === sourceSnippet) {
613
+ // Try YAML key-value replacement for multi-line frontmatter values
614
+ // (e.g., "title: long text\n that wraps")
615
+ const yamlResult = tryYamlValueReplacement(sourceSnippet, resolvedOriginal, resolvedNewText)
616
+ if (yamlResult !== null) {
617
+ return { success: true, content: content.replace(sourceSnippet, yamlResult) }
618
+ }
619
+
546
620
  // Try AST-based <br> normalization (browser normalizes <br class="..." /> to <br>
547
621
  // and collapses surrounding whitespace/indentation)
548
622
  const brResult = tryBrNormalizedChange(sourceSnippet, resolvedOriginal, resolvedNewText)
@@ -774,6 +848,88 @@ function getVisibleText(html: string): string {
774
848
  return text.trim()
775
849
  }
776
850
 
851
+ /**
852
+ * Try to replace a YAML value in a frontmatter snippet.
853
+ * Uses the YAML parser to resolve the value (handles all scalar styles:
854
+ * plain wrapping, single/double quoted, block literal `|`, folded `>`).
855
+ * Returns the updated snippet, or null if this approach doesn't apply.
856
+ */
857
+ function tryYamlValueReplacement(
858
+ sourceSnippet: string,
859
+ resolvedOriginal: string,
860
+ resolvedNewText: string,
861
+ ): string | null {
862
+ // Must look like a YAML key: value pair
863
+ const keyMatch = sourceSnippet.match(/^(\s*([\w][\w-]*):\s*)/)
864
+ if (!keyMatch) return null
865
+
866
+ // Use the YAML parser to resolve the value — handles all scalar styles
867
+ try {
868
+ const parsed = parseYaml(sourceSnippet)
869
+ if (parsed == null || typeof parsed !== 'object') return null
870
+ const value = (parsed as Record<string, unknown>)[keyMatch[2]!]
871
+ if (typeof value !== 'string' && typeof value !== 'number') return null
872
+ if (String(value) !== resolvedOriginal) return null
873
+ } catch {
874
+ return null
875
+ }
876
+
877
+ // Use the YAML library to safely serialize the new value,
878
+ // handling characters that would break plain scalars (: # [ ] { } , etc.)
879
+ const serialized = stringifyYaml(resolvedNewText, { lineWidth: 0 }).trimEnd()
880
+ return `${keyMatch[1]}${serialized}`
881
+ }
882
+
883
+ /**
884
+ * Replace an image value in a data file (JSON, YAML, MD frontmatter).
885
+ * Matches the original value as a quoted string within the source snippet context.
886
+ */
887
+ function tryDataFileValueReplacement(
888
+ content: string,
889
+ sourceSnippet: string,
890
+ originalValue: string,
891
+ newValue: string,
892
+ sourceLine: number,
893
+ ): { content: string; index: number } | null {
894
+ // Check if snippet contains the original value as a quoted string (JSON or YAML)
895
+ const doubleQuoted = `"${originalValue}"`
896
+ const singleQuoted = `'${originalValue}'`
897
+
898
+ let quotedOriginal: string
899
+ let quotedNew: string
900
+ if (sourceSnippet.includes(doubleQuoted)) {
901
+ quotedOriginal = doubleQuoted
902
+ quotedNew = `"${newValue}"`
903
+ } else if (sourceSnippet.includes(singleQuoted)) {
904
+ quotedOriginal = singleQuoted
905
+ quotedNew = `'${newValue}'`
906
+ } else {
907
+ return null
908
+ }
909
+
910
+ const updatedSnippet = sourceSnippet.replace(quotedOriginal, quotedNew)
911
+ if (updatedSnippet === sourceSnippet) return null
912
+
913
+ // Find the snippet in content near the source line
914
+ let searchStart = 0
915
+ if (sourceLine > 1) {
916
+ let linesFound = 0
917
+ for (let j = 0; j < content.length; j++) {
918
+ if (content[j] === '\n' && ++linesFound >= sourceLine - 1) {
919
+ searchStart = j + 1
920
+ break
921
+ }
922
+ }
923
+ }
924
+ const snippetIdx = content.indexOf(sourceSnippet, searchStart)
925
+ if (snippetIdx < 0) return null
926
+
927
+ return {
928
+ content: content.slice(0, snippetIdx) + updatedSnippet + content.slice(snippetIdx + sourceSnippet.length),
929
+ index: snippetIdx,
930
+ }
931
+ }
932
+
777
933
  /**
778
934
  * Try to apply a text change when the mismatch is due to <br> normalization.
779
935
  * The browser normalizes <br class="..." /> to plain <br> and collapses surrounding whitespace.
@@ -2,7 +2,16 @@ import { type HTMLElement as ParsedHTMLElement, parse } from 'node-html-parser'
2
2
  import { processSeoFromHtml } from './seo-processor'
3
3
  import { enhanceManifestWithSourceSnippets } from './source-finder'
4
4
  import { extractBackgroundImageClasses, extractColorClasses, extractTextStyleClasses } from './tailwind-colors'
5
- import type { Attribute, BackgroundImageMetadata, ComponentInstance, ImageMetadata, ManifestEntry, PageSeoData, SeoOptions } from './types'
5
+ import type {
6
+ Attribute,
7
+ BackgroundImageMetadata,
8
+ CollectionDefinition,
9
+ ComponentInstance,
10
+ ImageMetadata,
11
+ ManifestEntry,
12
+ PageSeoData,
13
+ SeoOptions,
14
+ } from './types'
6
15
  import { generateStableId } from './utils'
7
16
 
8
17
  /** Type for parsed HTML element nodes from node-html-parser */
@@ -69,6 +78,8 @@ export interface ProcessHtmlOptions {
69
78
  }
70
79
  /** SEO tracking options */
71
80
  seo?: SeoOptions
81
+ /** Collection definitions for resolving frontmatter text on listing pages */
82
+ collectionDefinitions?: Record<string, CollectionDefinition>
72
83
  }
73
84
 
74
85
  export interface ProcessHtmlResult {
@@ -223,6 +234,7 @@ export async function processHtml(
223
234
  skipInlineStyleTags = true,
224
235
  collectionInfo,
225
236
  seo: seoOptions,
237
+ collectionDefinitions,
226
238
  } = options
227
239
 
228
240
  const root = parse(html, {
@@ -1013,7 +1025,7 @@ export async function processHtml(
1013
1025
 
1014
1026
  // Enhance manifest entries with actual source snippets from source files
1015
1027
  // This allows the CMS to match and replace dynamic content in source files
1016
- const enhancedEntries = await enhanceManifestWithSourceSnippets(entries)
1028
+ const enhancedEntries = await enhanceManifestWithSourceSnippets(entries, collectionDefinitions)
1017
1029
 
1018
1030
  // Get the current HTML for SEO processing
1019
1031
  let finalHtml = root.toString()
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { AstroIntegration } from 'astro'
2
2
  import { existsSync, readFileSync } from 'node:fs'
3
+ import fs from 'node:fs/promises'
3
4
  import { dirname, join } from 'node:path'
4
5
  import { fileURLToPath } from 'node:url'
5
6
 
@@ -12,7 +13,7 @@ import { getErrorCollector, resetErrorCollector } from './error-collector'
12
13
  import { ManifestWriter } from './manifest-writer'
13
14
  import { createLocalStorageAdapter } from './media/local'
14
15
  import type { MediaStorageAdapter } from './media/types'
15
- import type { CmsMarkerOptions, ComponentDefinition } from './types'
16
+ import type { CmsFeatures, CmsMarkerOptions, ComponentDefinition } from './types'
16
17
  import { createVitePlugin } from './vite-plugin'
17
18
 
18
19
  export interface NuaCmsOptions extends CmsMarkerOptions {
@@ -30,6 +31,7 @@ export interface NuaCmsOptions extends CmsMarkerOptions {
30
31
  debug?: boolean
31
32
  theme?: Record<string, string>
32
33
  themePreset?: string
34
+ features?: CmsFeatures
33
35
  }
34
36
  /**
35
37
  * Proxy /_nua/cms requests to this target URL during dev.
@@ -41,6 +43,19 @@ export interface NuaCmsOptions extends CmsMarkerOptions {
41
43
  * Defaults to local filesystem (public/uploads) when no proxy is configured.
42
44
  */
43
45
  media?: MediaStorageAdapter
46
+ /**
47
+ * Directories containing components available in the MDX component picker.
48
+ * Only components within these directories (relative to project root) will appear.
49
+ * Example: ['src/components/mdx'] or ['src/components/mdx', 'src/components/blocks']
50
+ */
51
+ mdxComponentDirs?: string[]
52
+ /**
53
+ * Per-collection field overrides for position and grouping.
54
+ * Highest priority — overrides scanner defaults and frontmatter comment directives.
55
+ */
56
+ collections?: Record<string, {
57
+ fields?: Record<string, { position?: 'sidebar' | 'header'; group?: string }>
58
+ }>
44
59
  }
45
60
 
46
61
  const VIRTUAL_CMS_PATH = '/@nuasite/cms-editor.js'
@@ -62,6 +77,7 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
62
77
  markComponents = true,
63
78
  componentDirs = ['src/components'],
64
79
  contentDir = 'src/content',
80
+ mdxComponentDirs,
65
81
  seo = { trackSeo: true, markTitle: true, parseJsonLd: true },
66
82
  } = options
67
83
 
@@ -110,6 +126,14 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
110
126
  componentDefinitions = registry.getComponents()
111
127
  manifestWriter.setComponentDefinitions(componentDefinitions)
112
128
 
129
+ if (mdxComponentDirs) {
130
+ const normalizedDirs = mdxComponentDirs.map(dir => dir.endsWith('/') ? dir : dir + '/')
131
+ const mdxNames = Object.values(componentDefinitions)
132
+ .filter(def => normalizedDirs.some(dir => def.file.startsWith(dir)))
133
+ .map(def => def.name)
134
+ manifestWriter.setMdxComponents(mdxNames)
135
+ }
136
+
113
137
  const componentCount = Object.keys(componentDefinitions).length
114
138
  if (componentCount > 0) {
115
139
  logger.info(`Found ${componentCount} component definitions`)
@@ -117,6 +141,21 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
117
141
  }
118
142
 
119
143
  const collectionDefinitions = await scanCollections(contentDir)
144
+
145
+ // Apply per-collection field overrides from astro config (highest priority)
146
+ if (options.collections) {
147
+ for (const [collectionName, overrides] of Object.entries(options.collections)) {
148
+ const def = collectionDefinitions[collectionName]
149
+ if (!def || !overrides.fields) continue
150
+ for (const field of def.fields) {
151
+ const fieldOverride = overrides.fields[field.name]
152
+ if (!fieldOverride) continue
153
+ if (fieldOverride.position) field.position = fieldOverride.position
154
+ if (fieldOverride.group) field.group = fieldOverride.group
155
+ }
156
+ }
157
+ }
158
+
120
159
  manifestWriter.setCollectionDefinitions(collectionDefinitions)
121
160
 
122
161
  const collectionCount = Object.keys(collectionDefinitions).length
@@ -260,6 +299,9 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
260
299
  await processBuildOutput(dir, markerConfig, manifestWriter, idCounter, logger)
261
300
  }
262
301
 
302
+ // Merge CMS-managed redirects (src/_redirects) into dist/_redirects
303
+ await mergeRedirects(dir, logger)
304
+
263
305
  const errorCollector = getErrorCollector()
264
306
  if (errorCollector.hasWarnings()) {
265
307
  const warnings = errorCollector.getWarnings()
@@ -273,10 +315,42 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
273
315
  }
274
316
  }
275
317
 
318
+ /**
319
+ * Merge CMS-managed redirects from src/_redirects into the build output's dist/_redirects.
320
+ * This ensures both Astro config redirects (written by adapters) and CMS-managed redirects coexist.
321
+ */
322
+ async function mergeRedirects(dir: URL, logger: { info: (msg: string) => void }): Promise<void> {
323
+ const srcRedirectsPath = join(process.cwd(), 'src', '_redirects')
324
+
325
+ let cmsRedirects: string
326
+ try {
327
+ cmsRedirects = (await fs.readFile(srcRedirectsPath, 'utf-8')).trim()
328
+ } catch {
329
+ return
330
+ }
331
+ if (!cmsRedirects) return
332
+
333
+ const distDir = fileURLToPath(dir)
334
+ const distRedirectsPath = join(distDir, '_redirects')
335
+
336
+ let existing = ''
337
+ try {
338
+ existing = await fs.readFile(distRedirectsPath, 'utf-8')
339
+ } catch {
340
+ // File doesn't exist yet — will be created
341
+ }
342
+
343
+ const separator = existing ? '\n\n# CMS-managed redirects\n' : '# CMS-managed redirects\n'
344
+ await fs.writeFile(distRedirectsPath, existing + separator + cmsRedirects + '\n', 'utf-8')
345
+
346
+ const lineCount = cmsRedirects.split('\n').filter((l) => l.trim() && !l.trim().startsWith('#')).length
347
+ logger.info(`Merged ${lineCount} CMS redirect(s) into _redirects`)
348
+ }
349
+
276
350
  export { createContemberStorageAdapter as contemberMedia } from './media/contember'
277
351
  export { createLocalStorageAdapter as localMedia } from './media/local'
278
352
  export { createS3StorageAdapter as s3Media } from './media/s3'
279
- export type { MediaItem, MediaStorageAdapter } from './media/types'
353
+ export type { MediaFolderItem, MediaItem, MediaListOptions, MediaListResult, MediaStorageAdapter, MediaTypeFilter } from './media/types'
280
354
 
281
355
  export { scanCollections } from './collection-scanner'
282
356
  export { getProjectRoot, resetProjectRoot, setProjectRoot } from './config'
@@ -38,6 +38,7 @@ export class ManifestWriter {
38
38
  private collectionDefinitions: Record<string, CollectionDefinition> = {}
39
39
  private availableColors: AvailableColors | undefined
40
40
  private availableTextStyles: AvailableTextStyles | undefined
41
+ private mdxComponents: string[] | undefined
41
42
  private writeQueue: Promise<void> = Promise.resolve()
42
43
 
43
44
  constructor(manifestFile: string, componentDefinitions: Record<string, ComponentDefinition> = {}) {
@@ -67,6 +68,14 @@ export class ManifestWriter {
67
68
  this.globalManifest.componentDefinitions = definitions
68
69
  }
69
70
 
71
+ /**
72
+ * Set the list of component names allowed in the MDX component picker
73
+ */
74
+ setMdxComponents(names: string[]): void {
75
+ this.mdxComponents = names
76
+ this.globalManifest.mdxComponents = names
77
+ }
78
+
70
79
  /**
71
80
  * Load available Tailwind colors and text styles from the project's CSS config
72
81
  */
@@ -98,7 +107,15 @@ export class ManifestWriter {
98
107
  */
99
108
  setCollectionDefinitions(definitions: Record<string, CollectionDefinition>): void {
100
109
  this.collectionDefinitions = definitions
101
- this.globalManifest.collectionDefinitions = definitions
110
+ // Strip entry.data before publishing to the manifest — it's only needed
111
+ // server-side (for reference detection) and would bloat the browser payload.
112
+ const stripped: Record<string, CollectionDefinition> = {}
113
+ for (const [name, def] of Object.entries(definitions)) {
114
+ stripped[name] = def.entries
115
+ ? { ...def, entries: def.entries.map(({ data, ...rest }) => rest) }
116
+ : def
117
+ }
118
+ this.globalManifest.collectionDefinitions = stripped
102
119
  }
103
120
 
104
121
  /**
@@ -325,6 +342,7 @@ export class ManifestWriter {
325
342
  collectionDefinitions: this.collectionDefinitions,
326
343
  availableColors: this.availableColors,
327
344
  availableTextStyles: this.availableTextStyles,
345
+ mdxComponents: this.mdxComponents,
328
346
  }
329
347
  this.writeQueue = Promise.resolve()
330
348
  }
@@ -42,7 +42,8 @@ export function createContemberStorageAdapter(options: ContemberStorageOptions):
42
42
  throw new Error(`Failed to list media (${res.status}): ${await res.text()}`)
43
43
  }
44
44
 
45
- return (await res.json()) as MediaListResult
45
+ const data = await res.json()
46
+ return { folders: [], ...data } as MediaListResult
46
47
  },
47
48
 
48
49
  async upload(file, filename, contentType) {