@nuasite/cms 0.31.0 → 0.34.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 +5167 -5123
- 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/constants.ts +3 -0
- package/src/editor/dom.ts +3 -0
- package/src/editor/editor.ts +27 -0
- 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/field-types.ts +15 -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 +105 -7
- 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/constants.ts
CHANGED
|
@@ -109,6 +109,9 @@ export const CSS = {
|
|
|
109
109
|
HIGHLIGHT_ELEMENT: 'cms-highlight-overlay',
|
|
110
110
|
/** Data attribute for background image elements */
|
|
111
111
|
BG_IMAGE_ATTRIBUTE: 'data-cms-bg-img',
|
|
112
|
+
/** Data attribute set during edit mode on elements whose manifest entry has no source path;
|
|
113
|
+
* suppresses hover outlines and blocks interaction so the user doesn't type into a dead-end. */
|
|
114
|
+
LOCKED_ATTRIBUTE: 'data-cms-locked',
|
|
112
115
|
} as const
|
|
113
116
|
|
|
114
117
|
/**
|
package/src/editor/dom.ts
CHANGED
|
@@ -76,6 +76,8 @@ export function getCmsElementAtPosition(
|
|
|
76
76
|
if (!el.hasAttribute(CSS.ID_ATTRIBUTE)) continue
|
|
77
77
|
// Skip component roots - they should be handled separately
|
|
78
78
|
if (el.hasAttribute(CSS.COMPONENT_ID_ATTRIBUTE)) continue
|
|
79
|
+
// Skip elements locked because their manifest entry has no source path
|
|
80
|
+
if (el.hasAttribute(CSS.LOCKED_ATTRIBUTE)) continue
|
|
79
81
|
|
|
80
82
|
const cmsId = el.getAttribute(CSS.ID_ATTRIBUTE)
|
|
81
83
|
|
|
@@ -96,6 +98,7 @@ export function getCmsElementAtPosition(
|
|
|
96
98
|
if (!(el instanceof HTMLElement)) continue
|
|
97
99
|
if (!el.hasAttribute(CSS.ID_ATTRIBUTE)) continue
|
|
98
100
|
if (el.hasAttribute(CSS.COMPONENT_ID_ATTRIBUTE)) continue
|
|
101
|
+
if (el.hasAttribute(CSS.LOCKED_ATTRIBUTE)) continue
|
|
99
102
|
|
|
100
103
|
const cmsId = el.getAttribute(CSS.ID_ATTRIBUTE)
|
|
101
104
|
|
package/src/editor/editor.ts
CHANGED
|
@@ -76,6 +76,7 @@ const INLINE_STYLE_ELEMENTS = [
|
|
|
76
76
|
// merge a burst, short enough that the next deliberate action still explains itself.
|
|
77
77
|
const FORMATTING_BLOCKED_TOAST_COOLDOWN_MS = 3000
|
|
78
78
|
let lastFormattingBlockedToastAt = 0
|
|
79
|
+
let lastLockedToastAt = 0
|
|
79
80
|
|
|
80
81
|
// Signals listener cleanup on stopEditMode. Aborting removes every listener
|
|
81
82
|
// attached with { signal } in the current edit session in one shot.
|
|
@@ -90,6 +91,21 @@ function notifyFormattingBlocked(): void {
|
|
|
90
91
|
signals.showToast("Formatting isn't available — this text is used as a plain value", 'info')
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
export function notifyLockedElement(): void {
|
|
95
|
+
const now = Date.now()
|
|
96
|
+
if (now - lastLockedToastAt < FORMATTING_BLOCKED_TOAST_COOLDOWN_MS) {
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
lastLockedToastAt = now
|
|
100
|
+
signals.showToast("This text can't be edited here — no source file is linked to it", 'info')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Test-only: reset toast throttle state between test cases. */
|
|
104
|
+
export function _resetToastThrottles(): void {
|
|
105
|
+
lastFormattingBlockedToastAt = 0
|
|
106
|
+
lastLockedToastAt = 0
|
|
107
|
+
}
|
|
108
|
+
|
|
93
109
|
// Uses the Selection/Range API rather than the deprecated document.execCommand('insertText').
|
|
94
110
|
export function insertPlainTextAtRange(range: Range, text: string): boolean {
|
|
95
111
|
if (!text) return false
|
|
@@ -224,6 +240,16 @@ export async function startEditMode(
|
|
|
224
240
|
return
|
|
225
241
|
}
|
|
226
242
|
|
|
243
|
+
// Without a source path, the writer has nowhere to persist text edits — lock
|
|
244
|
+
// the element so it can't be typed into and the user gets told why on click.
|
|
245
|
+
if (!manifestEntry?.sourcePath) {
|
|
246
|
+
logDebug(config.debug, 'Skipping element without source path:', cmsId)
|
|
247
|
+
makeElementNonEditable(el)
|
|
248
|
+
el.setAttribute(CSS.LOCKED_ATTRIBUTE, 'true')
|
|
249
|
+
el.addEventListener('click', notifyLockedElement, { signal: editModeSignal })
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
|
|
227
253
|
makeElementEditable(el)
|
|
228
254
|
|
|
229
255
|
const stylingAllowed = manifestEntry?.allowStyling !== false
|
|
@@ -434,6 +460,7 @@ export function stopEditMode(onStateChange?: () => void): void {
|
|
|
434
460
|
|
|
435
461
|
getAllCmsElements().forEach(el => {
|
|
436
462
|
makeElementNonEditable(el)
|
|
463
|
+
el.removeAttribute(CSS.LOCKED_ATTRIBUTE)
|
|
437
464
|
})
|
|
438
465
|
}
|
|
439
466
|
|
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
|
|