@nuasite/cms 0.32.0 → 0.35.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.
@@ -0,0 +1,356 @@
1
+ import type * as t from '@babel/types'
2
+ import fs from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import { getProjectRoot } from './config'
5
+ import { parseFrontmatter } from './source-finder/ast-parser'
6
+ import type { FieldHints, FieldType } from './types'
7
+
8
+ export interface ParsedReference {
9
+ target: string
10
+ isArray: boolean
11
+ }
12
+
13
+ export interface ParsedField {
14
+ name: string
15
+ type?: FieldType
16
+ options?: string[]
17
+ hints?: FieldHints
18
+ required: boolean
19
+ orderBy?: { direction: 'asc' | 'desc' }
20
+ reference?: ParsedReference
21
+ /** True when the field is `image()` from an Astro callback schema, which routes through `astro:assets`. */
22
+ astroImage?: boolean
23
+ }
24
+
25
+ export interface ParsedCollection {
26
+ name: string
27
+ fields: ParsedField[]
28
+ }
29
+
30
+ export type ParsedConfig = Map<string, ParsedCollection>
31
+
32
+ const FIELD_HELPER_TYPES = new Set([
33
+ 'text',
34
+ 'number',
35
+ 'image',
36
+ 'url',
37
+ 'email',
38
+ 'tel',
39
+ 'color',
40
+ 'date',
41
+ 'datetime',
42
+ 'time',
43
+ 'textarea',
44
+ ])
45
+
46
+ const VALID_HINT_KEYS = new Set([
47
+ 'min',
48
+ 'max',
49
+ 'step',
50
+ 'placeholder',
51
+ 'maxLength',
52
+ 'minLength',
53
+ 'rows',
54
+ 'accept',
55
+ ])
56
+
57
+ const WRAPPER_METHODS = new Set(['optional', 'nullable', 'nullish', 'default'])
58
+
59
+ /** Cached parse result keyed by absolute path; invalidated by mtime. */
60
+ const parseCache = new Map<string, { mtimeMs: number; parsed: ParsedConfig }>()
61
+
62
+ /**
63
+ * Parse the project's Astro content config file (TypeScript) into a structured
64
+ * representation of each collection's schema. Returns an empty map if no config
65
+ * file exists or parsing fails.
66
+ */
67
+ export async function parseContentConfig(): Promise<ParsedConfig> {
68
+ const projectRoot = getProjectRoot()
69
+ for (const configPath of ['src/content/config.ts', 'src/content.config.ts']) {
70
+ const fullPath = path.join(projectRoot, configPath)
71
+ let stat: Awaited<ReturnType<typeof fs.stat>>
72
+ try {
73
+ stat = await fs.stat(fullPath)
74
+ } catch {
75
+ continue
76
+ }
77
+
78
+ const cached = parseCache.get(fullPath)
79
+ if (cached && cached.mtimeMs === stat.mtimeMs) {
80
+ if (cached.parsed.size > 0) return cached.parsed
81
+ continue
82
+ }
83
+
84
+ const content = await fs.readFile(fullPath, 'utf-8')
85
+ const parsed = parseConfigSource(content, configPath)
86
+ parseCache.set(fullPath, { mtimeMs: stat.mtimeMs, parsed })
87
+ if (parsed.size > 0) return parsed
88
+ }
89
+ return new Map()
90
+ }
91
+
92
+ /** Exported for unit testing — operates on a source string directly. */
93
+ export function parseConfigSource(source: string, sourcePath?: string): ParsedConfig {
94
+ const result: ParsedConfig = new Map()
95
+ const ast = parseFrontmatter(source, sourcePath) as unknown as t.File | null
96
+ if (!ast) return result
97
+
98
+ // Collect `const X = defineCollection({...})` declarations and the
99
+ // `export const collections = { name: X, ... }` mapping, in any order.
100
+ const collectionDecls = new Map<string, t.ObjectExpression>()
101
+ const exportMap = new Map<string, string>() // varName → collectionName
102
+
103
+ for (const stmt of ast.program.body) {
104
+ const varDecl = stmt.type === 'ExportNamedDeclaration' && stmt.declaration?.type === 'VariableDeclaration'
105
+ ? stmt.declaration
106
+ : stmt.type === 'VariableDeclaration'
107
+ ? stmt
108
+ : null
109
+ if (!varDecl) continue
110
+
111
+ for (const decl of varDecl.declarations) {
112
+ if (decl.id.type !== 'Identifier') continue
113
+ if (!decl.init) continue
114
+
115
+ if (decl.id.name === 'collections' && decl.init.type === 'ObjectExpression') {
116
+ for (const prop of decl.init.properties) {
117
+ if (prop.type !== 'ObjectProperty') continue
118
+ const key = propertyKeyName(prop.key)
119
+ if (!key) continue
120
+ if (prop.value.type === 'Identifier') {
121
+ exportMap.set(prop.value.name, key)
122
+ }
123
+ }
124
+ continue
125
+ }
126
+
127
+ if (decl.init.type === 'CallExpression' && isDefineCollectionCallee(decl.init.callee)) {
128
+ const arg = decl.init.arguments[0]
129
+ if (arg?.type === 'ObjectExpression') {
130
+ collectionDecls.set(decl.id.name, arg)
131
+ }
132
+ }
133
+ }
134
+ }
135
+
136
+ for (const [varName, collectionName] of exportMap) {
137
+ const decl = collectionDecls.get(varName)
138
+ if (!decl) continue
139
+
140
+ const schemaProperty = decl.properties.find(
141
+ p =>
142
+ p.type === 'ObjectProperty'
143
+ && propertyKeyName(p.key) === 'schema',
144
+ ) as t.ObjectProperty | undefined
145
+ if (!schemaProperty) continue
146
+
147
+ const schemaObject = unwrapSchemaToObject(schemaProperty.value)
148
+ if (!schemaObject) continue
149
+
150
+ result.set(collectionName, {
151
+ name: collectionName,
152
+ fields: parseSchemaFields(schemaObject),
153
+ })
154
+ }
155
+
156
+ return result
157
+ }
158
+
159
+ function isDefineCollectionCallee(callee: t.Node): boolean {
160
+ return callee.type === 'Identifier' && callee.name === 'defineCollection'
161
+ }
162
+
163
+ function propertyKeyName(key: t.Node): string | null {
164
+ if (key.type === 'Identifier') return key.name
165
+ if (key.type === 'StringLiteral') return key.value
166
+ return null
167
+ }
168
+
169
+ /**
170
+ * Unwrap a `schema:` value down to the top-level (z|n).object({ ... }) ObjectExpression.
171
+ * Handles direct calls and the Astro callback form `({ image }) => z.object({...})`.
172
+ */
173
+ function unwrapSchemaToObject(node: t.Node): t.ObjectExpression | null {
174
+ if (node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression') {
175
+ const body = node.body
176
+ if (body.type === 'BlockStatement') {
177
+ for (const stmt of body.body) {
178
+ if (stmt.type === 'ReturnStatement' && stmt.argument) {
179
+ return unwrapSchemaToObject(stmt.argument)
180
+ }
181
+ }
182
+ return null
183
+ }
184
+ return unwrapSchemaToObject(body)
185
+ }
186
+
187
+ if (node.type === 'CallExpression') {
188
+ const callee = node.callee
189
+ if (
190
+ callee.type === 'MemberExpression'
191
+ && callee.object.type === 'Identifier'
192
+ && (callee.object.name === 'z' || callee.object.name === 'n')
193
+ && callee.property.type === 'Identifier'
194
+ && callee.property.name === 'object'
195
+ ) {
196
+ const arg = node.arguments[0]
197
+ if (arg?.type === 'ObjectExpression') return arg
198
+ }
199
+ }
200
+
201
+ return null
202
+ }
203
+
204
+ function parseSchemaFields(schemaObject: t.ObjectExpression): ParsedField[] {
205
+ const fields: ParsedField[] = []
206
+ for (const prop of schemaObject.properties) {
207
+ if (prop.type !== 'ObjectProperty') continue
208
+ const name = propertyKeyName(prop.key)
209
+ if (!name) continue
210
+
211
+ const field: ParsedField = { name, required: true }
212
+ analyzeFieldExpression(prop.value, field)
213
+ fields.push(field)
214
+ }
215
+ return fields
216
+ }
217
+
218
+ /**
219
+ * Walk a field's value expression. Each layer is either a wrapper method call
220
+ * (`.optional()`, `.default()`, `.nullable()`, `.nullish()`, `.orderBy(...)`)
221
+ * or the base call (`n.image()`, `image()`, `z.enum([...])`, `n.array(reference(...))`).
222
+ */
223
+ function analyzeFieldExpression(node: t.Node, field: ParsedField): void {
224
+ let current: t.Node | null = node
225
+ while (current) {
226
+ if (current.type !== 'CallExpression') return
227
+
228
+ if (isBaseCall(current)) {
229
+ analyzeBaseCall(current, field)
230
+ return
231
+ }
232
+
233
+ if (current.callee.type !== 'MemberExpression') return
234
+ const property = current.callee.property
235
+ const methodName = property.type === 'Identifier' ? property.name : ''
236
+
237
+ if (WRAPPER_METHODS.has(methodName)) {
238
+ field.required = false
239
+ } else if (methodName === 'orderBy') {
240
+ const arg = current.arguments[0]
241
+ const direction = arg?.type === 'StringLiteral' && arg.value === 'desc' ? 'desc' : 'asc'
242
+ field.orderBy = { direction }
243
+ }
244
+
245
+ current = current.callee.object
246
+ }
247
+ }
248
+
249
+ /**
250
+ * A "base call" is the innermost call that defines the field's type: a Zod/n
251
+ * helper invocation or a bare `image()` / `reference()` from a callback param.
252
+ */
253
+ function isBaseCall(node: t.CallExpression): boolean {
254
+ const callee = node.callee
255
+ if (callee.type === 'Identifier') {
256
+ return callee.name === 'image' || callee.name === 'reference'
257
+ }
258
+ if (callee.type === 'MemberExpression') {
259
+ return callee.object.type === 'Identifier'
260
+ && (callee.object.name === 'n' || callee.object.name === 'z')
261
+ }
262
+ return false
263
+ }
264
+
265
+ function analyzeBaseCall(node: t.CallExpression, field: ParsedField): void {
266
+ const callee = node.callee
267
+
268
+ // Bare image() / reference() from the schema callback form
269
+ if (callee.type === 'Identifier') {
270
+ if (callee.name === 'image') {
271
+ field.type = 'image'
272
+ field.astroImage = true
273
+ return
274
+ }
275
+ if (callee.name === 'reference') {
276
+ const arg = node.arguments[0]
277
+ if (arg?.type === 'StringLiteral') {
278
+ field.reference = { target: arg.value, isArray: false }
279
+ }
280
+ return
281
+ }
282
+ return
283
+ }
284
+
285
+ if (callee.type !== 'MemberExpression') return
286
+ if (callee.object.type !== 'Identifier' || callee.property.type !== 'Identifier') return
287
+ const ns = callee.object.name
288
+ const fn = callee.property.name
289
+
290
+ // n.image(), n.url(), n.text(...), etc. — semantic field types from @nuasite/cms
291
+ if (ns === 'n' && FIELD_HELPER_TYPES.has(fn)) {
292
+ field.type = fn as FieldType
293
+ const firstArg = node.arguments[0]
294
+ if (firstArg?.type === 'ObjectExpression') {
295
+ const hints = parseHintsFromObject(firstArg)
296
+ if (hints) field.hints = hints
297
+ }
298
+ return
299
+ }
300
+
301
+ // (z|n).enum([...]) → select with options
302
+ if ((ns === 'z' || ns === 'n') && fn === 'enum') {
303
+ const arg = node.arguments[0]
304
+ if (arg?.type === 'ArrayExpression') {
305
+ const options: string[] = []
306
+ for (const el of arg.elements) {
307
+ if (el?.type === 'StringLiteral') options.push(el.value)
308
+ }
309
+ if (options.length > 0) {
310
+ field.type = 'select'
311
+ field.options = options
312
+ }
313
+ }
314
+ return
315
+ }
316
+
317
+ // (z|n).array(reference('foo')) → array of references
318
+ if ((ns === 'z' || ns === 'n') && fn === 'array') {
319
+ const inner = node.arguments[0]
320
+ if (
321
+ inner?.type === 'CallExpression'
322
+ && inner.callee.type === 'Identifier'
323
+ && inner.callee.name === 'reference'
324
+ ) {
325
+ const target = inner.arguments[0]
326
+ if (target?.type === 'StringLiteral') {
327
+ field.reference = { target: target.value, isArray: true }
328
+ }
329
+ }
330
+ return
331
+ }
332
+ }
333
+
334
+ function parseHintsFromObject(obj: t.ObjectExpression): FieldHints | undefined {
335
+ const raw: Record<string, string | number> = {}
336
+ for (const prop of obj.properties) {
337
+ if (prop.type !== 'ObjectProperty') continue
338
+ const key = propertyKeyName(prop.key)
339
+ if (!key || !VALID_HINT_KEYS.has(key)) continue
340
+
341
+ const value = prop.value
342
+ if (value.type === 'NumericLiteral') {
343
+ raw[key] = value.value
344
+ } else if (
345
+ value.type === 'UnaryExpression'
346
+ && value.operator === '-'
347
+ && value.argument.type === 'NumericLiteral'
348
+ ) {
349
+ raw[key] = -value.argument.value
350
+ } else if (value.type === 'StringLiteral') {
351
+ raw[key] = value.value
352
+ }
353
+ }
354
+ if (Object.keys(raw).length === 0) return undefined
355
+ return raw as FieldHints
356
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ComponentChildren } from 'preact'
2
2
  import { useEffect, useState } from 'preact/hooks'
3
- import { getCollectionEntryOptions } from '../manifest'
3
+ import { buildAstroUploadContext, getCollectionEntryOptions } from '../manifest'
4
4
  import { renameMarkdownPage } from '../markdown-api'
5
5
  import {
6
6
  config,
@@ -234,6 +234,8 @@ export function CreateModeFrontmatter({
234
234
  field={field}
235
235
  value={page.frontmatter[field.name]}
236
236
  onChange={(newValue) => updateMarkdownFrontmatter({ [field.name]: newValue })}
237
+ collection={collectionDefinition.name}
238
+ entrySlug={page.slug}
237
239
  />
238
240
  ))}
239
241
  </FieldGroupHeader>
@@ -348,6 +350,8 @@ export function EditModeFrontmatter({
348
350
  field={field}
349
351
  value={page.frontmatter[field.name]}
350
352
  onChange={(newValue) => updateMarkdownFrontmatter({ [field.name]: newValue })}
353
+ collection={collectionDefinition.name}
354
+ entrySlug={page.slug}
351
355
  />
352
356
  ))}
353
357
  </FieldGroupHeader>
@@ -387,12 +391,17 @@ interface SchemaFrontmatterFieldProps {
387
391
  field: FieldDefinition
388
392
  value: unknown
389
393
  onChange: (value: unknown) => void
394
+ /** Required when editing an `astroImage` field — routes uploads to the entry's directory. */
395
+ collection?: string
396
+ entrySlug?: string
390
397
  }
391
398
 
392
399
  export function SchemaFrontmatterField({
393
400
  field,
394
401
  value,
395
402
  onChange,
403
+ collection,
404
+ entrySlug,
396
405
  }: SchemaFrontmatterFieldProps) {
397
406
  const label = field.required ? `${formatFieldLabel(field.name)} *` : formatFieldLabel(field.name)
398
407
  const hints = field.hints
@@ -414,7 +423,8 @@ export function SchemaFrontmatterField({
414
423
  />
415
424
  )
416
425
 
417
- case 'image':
426
+ case 'image': {
427
+ const astroContext = buildAstroUploadContext(field, collection, entrySlug)
418
428
  return (
419
429
  <ImageField
420
430
  label={label}
@@ -424,11 +434,12 @@ export function SchemaFrontmatterField({
424
434
  onBrowse={() => {
425
435
  openMediaLibraryWithCallback((url: string) => {
426
436
  onChange(url)
427
- })
437
+ }, astroContext)
428
438
  }}
429
439
  required={field.required}
430
440
  />
431
441
  )
442
+ }
432
443
 
433
444
  case 'color':
434
445
  return (
@@ -201,6 +201,8 @@ export function FrontmatterSidebar({ fields, page, collectionDefinition }: Front
201
201
  field={field}
202
202
  value={page.frontmatter[field.name]}
203
203
  onChange={(newValue) => updateMarkdownFrontmatter({ [field.name]: newValue })}
204
+ collection={collectionDefinition?.name}
205
+ entrySlug={page.slug}
204
206
  />
205
207
  )
206
208
  : (
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useRef } from 'preact/hooks'
2
2
  import { Z_INDEX } from '../constants'
3
3
  import { getCaretRangeFromPoint } from '../dom'
4
+ import { getAstroUploadContextForCmsId } from '../manifest'
4
5
  import * as signals from '../signals'
5
6
 
6
7
  export interface ImageOverlayProps {
@@ -139,6 +140,10 @@ export function ImageOverlay({ visible, rect, element, cmsId }: ImageOverlayProp
139
140
  e.preventDefault()
140
141
  e.stopPropagation()
141
142
 
143
+ // If this image came from a collection's astroImage field, route the upload
144
+ // to the entry's directory so Astro's asset pipeline can resize it.
145
+ const astroContext = getAstroUploadContextForCmsId(signals.manifest.value, cmsId)
146
+
142
147
  // Open media library with callback to replace the image
143
148
  signals.openMediaLibraryWithCallback((url: string, alt: string) => {
144
149
  // Track the change BEFORE modifying element.src
@@ -148,12 +153,16 @@ export function ImageOverlay({ visible, rect, element, cmsId }: ImageOverlayProp
148
153
  const originalSrcSet = currentChange?.originalSrcSet ?? element.getAttribute('srcset') ?? ''
149
154
  const isDirty = url !== originalSrc
150
155
 
151
- // Update the image element
152
- element.src = url
153
- // Clear srcset so browser uses the new src
154
- element.removeAttribute('srcset')
155
- if (alt) {
156
- element.alt = alt
156
+ // Update the image element. Skip for astroImage uploads — the returned
157
+ // path (`./file.jpg`) is entry-relative and won't resolve against the
158
+ // page URL. The new image becomes visible after save when Astro reloads.
159
+ if (!astroContext) {
160
+ element.src = url
161
+ // Clear srcset so browser uses the new src
162
+ element.removeAttribute('srcset')
163
+ if (alt) {
164
+ element.alt = alt
165
+ }
157
166
  }
158
167
 
159
168
  if (currentChange) {
@@ -175,7 +184,7 @@ export function ImageOverlay({ visible, rect, element, cmsId }: ImageOverlayProp
175
184
  isDirty,
176
185
  })
177
186
  }
178
- })
187
+ }, astroContext)
179
188
  }
180
189
 
181
190
  overlayRef.current.addEventListener('click', handleClick)
@@ -117,11 +117,22 @@ export function MediaLibrary() {
117
117
  const handleUploadFile = async (file: File) => {
118
118
  setUploadProgress(0)
119
119
  try {
120
+ const uploadContext = mediaLibraryState.value.uploadContext ?? undefined
120
121
  const result = await uploadMedia(config.value, file, (percent) => {
121
122
  setUploadProgress(percent)
122
- }, { folder: currentFolder || undefined })
123
+ }, { folder: currentFolder || undefined, context: uploadContext })
123
124
 
124
125
  if (result.success && result.url) {
126
+ // Astro image() uploads return entry-relative paths (`./foo.jpg`); skip adding
127
+ // to the library list (the file lives under src/content/, not in the media adapter)
128
+ // and call the callback directly so the field gets the relative value.
129
+ if (uploadContext && result.url.startsWith('./')) {
130
+ insertCallback?.(result.url, '')
131
+ handleClose()
132
+ showToast('Uploaded next to entry', 'success')
133
+ return
134
+ }
135
+
125
136
  const newItem: MediaItem = {
126
137
  id: result.id || crypto.randomUUID(),
127
138
  url: result.url,
@@ -1,4 +1,5 @@
1
- import type { CmsManifest, ComponentDefinition, ComponentInstance, ManifestEntry } from './types'
1
+ import type { MediaUploadContext } from '../types'
2
+ import type { CmsManifest, ComponentDefinition, ComponentInstance, FieldDefinition, ManifestEntry } from './types'
2
3
 
3
4
  type GetManifestEntry = (manifest: CmsManifest, id: string) => ManifestEntry | undefined
4
5
  export const getManifestEntry: GetManifestEntry = (manifest, id) => manifest.entries[id]
@@ -24,6 +25,41 @@ export const getManifestEntryCount: GetManifestEntryCount = (manifest: CmsManife
24
25
  type GetAvailableComponentNames = (manifest: CmsManifest) => string[]
25
26
  export const getAvailableComponentNames: GetAvailableComponentNames = manifest => Object.keys(getComponentDefinitions(manifest))
26
27
 
28
+ export function getCollectionField(
29
+ manifest: CmsManifest,
30
+ collectionName: string | undefined,
31
+ fieldName: string | undefined,
32
+ ): FieldDefinition | undefined {
33
+ if (!collectionName || !fieldName) return undefined
34
+ return manifest.collectionDefinitions?.[collectionName]?.fields.find(f => f.name === fieldName)
35
+ }
36
+
37
+ /**
38
+ * Build the upload context that routes a media upload to an Astro `image()`
39
+ * field's entry-relative location. Returns undefined when the field isn't
40
+ * astroImage or any piece of the (collection, slug, field) triple is missing.
41
+ */
42
+ export function buildAstroUploadContext(
43
+ field: FieldDefinition | undefined,
44
+ collection: string | undefined,
45
+ slug: string | undefined,
46
+ ): MediaUploadContext | undefined {
47
+ if (!field?.astroImage || !collection || !slug) return undefined
48
+ return { collection, entry: slug, field: field.name }
49
+ }
50
+
51
+ /** Look up the upload context for a CMS-marked element via its manifest entry. */
52
+ export function getAstroUploadContextForCmsId(
53
+ manifest: CmsManifest,
54
+ cmsId: string | null,
55
+ ): MediaUploadContext | undefined {
56
+ if (!cmsId) return undefined
57
+ const entry = getManifestEntry(manifest, cmsId)
58
+ if (!entry) return undefined
59
+ const field = getCollectionField(manifest, entry.collectionName, entry.collectionFieldName)
60
+ return buildAstroUploadContext(field, entry.collectionName, entry.collectionSlug)
61
+ }
62
+
27
63
  export function getCollectionEntryOptions(manifest: CmsManifest, collectionName?: string): Array<{ value: string; label: string }> {
28
64
  if (!collectionName) return []
29
65
  const def = manifest.collectionDefinitions?.[collectionName]
@@ -1,3 +1,4 @@
1
+ import type { MediaUploadContext } from '../types'
1
2
  import { API } from './constants'
2
3
  import { fetchWithTimeout, getJson, postJson } from './fetch'
3
4
  import type {
@@ -73,13 +74,16 @@ export function uploadMedia(
73
74
  config: CmsConfig,
74
75
  file: File,
75
76
  onProgress?: (percent: number) => void,
76
- options?: { folder?: string },
77
+ options?: { folder?: string; context?: MediaUploadContext },
77
78
  ): Promise<MediaUploadResponse> {
78
79
  const formData = new FormData()
79
80
  formData.append('file', file)
80
81
 
81
82
  const params = new URLSearchParams()
82
83
  if (options?.folder) params.set('folder', options.folder)
84
+ if (options?.context?.collection) params.set('collection', options.context.collection)
85
+ if (options?.context?.entry) params.set('entry', options.context.entry)
86
+ if (options?.context?.field) params.set('field', options.context.field)
83
87
  const qs = params.toString()
84
88
 
85
89
  return new Promise((resolve) => {
@@ -23,6 +23,7 @@ import type {
23
23
  MarkdownPageEntry,
24
24
  MediaItem,
25
25
  MediaLibraryState,
26
+ MediaUploadContext,
26
27
  PendingAttributeChange,
27
28
  PendingBackgroundImageChange,
28
29
  PendingChange,
@@ -122,6 +123,7 @@ function createInitialMediaLibraryState(): MediaLibraryState {
122
123
  isLoading: false,
123
124
  selectedItem: null,
124
125
  insertCallback: null,
126
+ uploadContext: null,
125
127
  }
126
128
  }
127
129
 
@@ -987,11 +989,13 @@ export function setMediaLibraryInsertCallback(
987
989
 
988
990
  export function openMediaLibraryWithCallback(
989
991
  callback: (url: string, alt: string) => void,
992
+ uploadContext?: MediaUploadContext,
990
993
  ): void {
991
994
  mediaLibraryState.value = {
992
995
  ...mediaLibraryState.value,
993
996
  isOpen: true,
994
997
  insertCallback: callback,
998
+ uploadContext: uploadContext ?? null,
995
999
  }
996
1000
  }
997
1001
 
@@ -1,4 +1,4 @@
1
- import type { Attribute, CmsFeatures, CmsManifest, CollectionDefinition, ComponentInstance, RedirectRule } from '../types'
1
+ import type { Attribute, CmsFeatures, CmsManifest, CollectionDefinition, ComponentInstance, MediaUploadContext, RedirectRule } from '../types'
2
2
 
3
3
  // Re-export shared types from @nuasite/cms-marker (source of truth)
4
4
  export type {
@@ -17,6 +17,7 @@ export type {
17
17
  FieldType,
18
18
  JsonLdEntry,
19
19
  ManifestEntry,
20
+ MediaUploadContext,
20
21
  OpenGraphData,
21
22
  PageSeoData,
22
23
  SeoFavicon,
@@ -361,6 +362,8 @@ export interface MediaLibraryState {
361
362
  isLoading: boolean
362
363
  selectedItem: MediaItem | null
363
364
  insertCallback: ((url: string, alt: string) => void) | null
365
+ /** When set, uploads inside the media library route to an Astro `image()` field's entry directory. */
366
+ uploadContext: MediaUploadContext | null
364
367
  }
365
368
 
366
369
  export interface CreatePageState {
@@ -7,6 +7,7 @@ import type { ManifestWriter } from '../manifest-writer'
7
7
  import { listProjectImages } from '../media/project-images'
8
8
  import type { MediaStorageAdapter } from '../media/types'
9
9
  import { handleAddArrayItem, handleRemoveArrayItem } from './array-ops'
10
+ import { tryAstroImageUpload } from './astro-image-upload'
10
11
  import { handleInsertComponent, handleRemoveComponent } from './component-ops'
11
12
  import { handleCreateMarkdown, handleDeleteMarkdown, handleGetMarkdownContent, handleRenameMarkdown, handleUpdateMarkdown } from './markdown-ops'
12
13
  import { handleCheckSlugExists, handleCreatePage, handleDeletePage, handleDuplicatePage, handleGetLayouts } from './page-ops'
@@ -146,13 +147,12 @@ const routeMap = new Map<string, RouteHandler>([
146
147
  sendJson(ctx.res, { items })
147
148
  }),
148
149
  custom('POST', 'media/upload', async (ctx) => {
149
- if (!requireMedia(ctx)) return
150
150
  const contentType = ctx.req.headers['content-type'] ?? ''
151
151
  if (!contentType.includes('multipart/form-data')) {
152
152
  sendError(ctx.res, 'Expected multipart/form-data')
153
153
  return
154
154
  }
155
- const folder = getQuery(ctx).get('folder') ?? undefined
155
+ const query = getQuery(ctx)
156
156
  const body = await readBody(ctx.req, 50 * 1024 * 1024)
157
157
  const file = parseMultipartFile(body, contentType)
158
158
  if (!file) {
@@ -164,6 +164,26 @@ const routeMap = new Map<string, RouteHandler>([
164
164
  sendError(ctx.res, `File type not allowed: ${file.contentType}`)
165
165
  return
166
166
  }
167
+
168
+ // Astro image() fields write entry-relative — bypasses the media adapter so
169
+ // the file lands in src/content where astro:assets can pick it up.
170
+ const astroResult = await tryAstroImageUpload({
171
+ context: {
172
+ collection: query.get('collection') ?? undefined,
173
+ entry: query.get('entry') ?? undefined,
174
+ field: query.get('field') ?? undefined,
175
+ },
176
+ manifestWriter: ctx.manifestWriter,
177
+ fileBuffer: file.buffer,
178
+ originalFilename: file.filename,
179
+ })
180
+ if (astroResult) {
181
+ sendJson(ctx.res, astroResult, astroResult.success ? 200 : 400)
182
+ return
183
+ }
184
+
185
+ if (!requireMedia(ctx)) return
186
+ const folder = query.get('folder') ?? undefined
167
187
  sendJson(ctx.res, await ctx.mediaAdapter.upload(file.buffer, file.filename, file.contentType, { folder }))
168
188
  }),
169
189
  custom('POST', 'media/folder', async (ctx) => {