@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.
- package/dist/editor.js +4177 -4144
- package/package.json +2 -1
- package/src/astro-image-paths.ts +74 -0
- package/src/collection-scanner.ts +55 -334
- package/src/content-config-ast.ts +356 -0
- package/src/editor/components/frontmatter-fields.tsx +14 -3
- package/src/editor/components/frontmatter-sidebar.tsx +2 -0
- package/src/editor/components/image-overlay.tsx +16 -7
- package/src/editor/components/media-library.tsx +12 -1
- package/src/editor/manifest.ts +37 -1
- package/src/editor/markdown-api.ts +5 -1
- package/src/editor/signals.ts +4 -0
- package/src/editor/types.ts +4 -1
- package/src/handlers/api-routes.ts +22 -2
- package/src/handlers/astro-image-upload.ts +60 -0
- package/src/index.ts +1 -0
- package/src/migrate-astro-image.ts +116 -0
- package/src/source-finder/snippet-utils.ts +114 -11
- package/src/types.ts +11 -0
|
@@ -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
|
-
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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,
|
package/src/editor/manifest.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
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) => {
|
package/src/editor/signals.ts
CHANGED
|
@@ -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
|
|
package/src/editor/types.ts
CHANGED
|
@@ -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
|
|
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) => {
|