@nuasite/cms-core 0.43.0-beta.1
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/types/collection-scanner.d.ts +12 -0
- package/dist/types/collection-scanner.d.ts.map +1 -0
- package/dist/types/component-registry.d.ts +15 -0
- package/dist/types/component-registry.d.ts.map +1 -0
- package/dist/types/content-config-ast.d.ts +45 -0
- package/dist/types/content-config-ast.d.ts.map +1 -0
- package/dist/types/core.d.ts +44 -0
- package/dist/types/core.d.ts.map +1 -0
- package/dist/types/fs/glob.d.ts +3 -0
- package/dist/types/fs/glob.d.ts.map +1 -0
- package/dist/types/fs/node-fs.d.ts +7 -0
- package/dist/types/fs/node-fs.d.ts.map +1 -0
- package/dist/types/fs/types.d.ts +33 -0
- package/dist/types/fs/types.d.ts.map +1 -0
- package/dist/types/handlers/entry-ops.d.ts +69 -0
- package/dist/types/handlers/entry-ops.d.ts.map +1 -0
- package/dist/types/handlers/page-ops.d.ts +14 -0
- package/dist/types/handlers/page-ops.d.ts.map +1 -0
- package/dist/types/handlers/redirect-ops.d.ts +10 -0
- package/dist/types/handlers/redirect-ops.d.ts.map +1 -0
- package/dist/types/index.d.ts +12 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/media/contember.d.ts +18 -0
- package/dist/types/media/contember.d.ts.map +1 -0
- package/dist/types/media/index.d.ts +5 -0
- package/dist/types/media/index.d.ts.map +1 -0
- package/dist/types/media/local.d.ts +12 -0
- package/dist/types/media/local.d.ts.map +1 -0
- package/dist/types/media/project-images.d.ts +15 -0
- package/dist/types/media/project-images.d.ts.map +1 -0
- package/dist/types/media/s3.d.ts +12 -0
- package/dist/types/media/s3.d.ts.map +1 -0
- package/dist/types/shared.d.ts +24 -0
- package/dist/types/shared.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +55 -0
- package/src/collection-scanner.ts +935 -0
- package/src/component-registry.ts +308 -0
- package/src/content-config-ast.ts +536 -0
- package/src/core.ts +167 -0
- package/src/fs/glob.ts +32 -0
- package/src/fs/node-fs.ts +138 -0
- package/src/fs/types.ts +26 -0
- package/src/handlers/entry-ops.ts +528 -0
- package/src/handlers/page-ops.ts +203 -0
- package/src/handlers/redirect-ops.ts +139 -0
- package/src/index.ts +41 -0
- package/src/media/contember.ts +90 -0
- package/src/media/index.ts +4 -0
- package/src/media/local.ts +147 -0
- package/src/media/project-images.ts +82 -0
- package/src/media/s3.ts +151 -0
- package/src/shared.ts +65 -0
- package/src/tsconfig.json +9 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
import { parse as parseBabel } from '@babel/parser'
|
|
2
|
+
import type * as t from '@babel/types'
|
|
3
|
+
import { type FieldHints, type FieldType, isFieldType } from '@nuasite/cms-types'
|
|
4
|
+
import type { CmsFileSystem } from './fs/types'
|
|
5
|
+
|
|
6
|
+
export interface ParsedReference {
|
|
7
|
+
target: string
|
|
8
|
+
isArray: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ParsedField {
|
|
12
|
+
name: string
|
|
13
|
+
type?: FieldType
|
|
14
|
+
options?: string[]
|
|
15
|
+
hints?: FieldHints
|
|
16
|
+
required: boolean
|
|
17
|
+
orderBy?: { direction: 'asc' | 'desc' }
|
|
18
|
+
reference?: ParsedReference
|
|
19
|
+
/** True when the field is `image()` from an Astro callback schema, which routes through `astro:assets`. */
|
|
20
|
+
astroImage?: boolean
|
|
21
|
+
/** Element type for `array` fields */
|
|
22
|
+
itemType?: FieldType
|
|
23
|
+
/** Nested fields for `object` fields, or per-item fields for `array` of objects */
|
|
24
|
+
fields?: ParsedField[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ParsedCollection {
|
|
28
|
+
name: string
|
|
29
|
+
fields: ParsedField[]
|
|
30
|
+
loaderPattern?: string
|
|
31
|
+
loaderBase?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ParsedConfig = Map<string, ParsedCollection>
|
|
35
|
+
|
|
36
|
+
/** Cached parse result keyed by config path; invalidated by mtime. */
|
|
37
|
+
export type ParseCache = Map<string, { mtimeMs: number; parsed: ParsedConfig }>
|
|
38
|
+
|
|
39
|
+
const FIELD_HELPER_TYPES = new Set([
|
|
40
|
+
'text',
|
|
41
|
+
'number',
|
|
42
|
+
'image',
|
|
43
|
+
'file',
|
|
44
|
+
'url',
|
|
45
|
+
'email',
|
|
46
|
+
'tel',
|
|
47
|
+
'color',
|
|
48
|
+
'date',
|
|
49
|
+
'datetime',
|
|
50
|
+
'time',
|
|
51
|
+
'year',
|
|
52
|
+
'month',
|
|
53
|
+
'textarea',
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
const VALID_HINT_KEYS = new Set([
|
|
57
|
+
'min',
|
|
58
|
+
'max',
|
|
59
|
+
'step',
|
|
60
|
+
'placeholder',
|
|
61
|
+
'maxLength',
|
|
62
|
+
'minLength',
|
|
63
|
+
'rows',
|
|
64
|
+
'accept',
|
|
65
|
+
])
|
|
66
|
+
|
|
67
|
+
const WRAPPER_METHODS = new Set(['optional', 'nullable', 'nullish', 'default'])
|
|
68
|
+
|
|
69
|
+
/** Map of top-level `const <name> = <expr>` bindings within a single config file. */
|
|
70
|
+
type Bindings = Map<string, t.Node>
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Follow `Identifier` references through same-file `const` bindings until reaching
|
|
74
|
+
* a non-Identifier node. Cycle-safe via the visited set. Returns the original node
|
|
75
|
+
* unchanged when the identifier is unbound or already visited.
|
|
76
|
+
*/
|
|
77
|
+
function resolveExpression(node: t.Node, bindings: Bindings, visited: Set<string> = new Set()): t.Node {
|
|
78
|
+
let current: t.Node = node
|
|
79
|
+
while (current.type === 'Identifier') {
|
|
80
|
+
if (visited.has(current.name)) return current
|
|
81
|
+
visited.add(current.name)
|
|
82
|
+
const next = bindings.get(current.name)
|
|
83
|
+
if (!next) return current
|
|
84
|
+
current = next
|
|
85
|
+
}
|
|
86
|
+
return current
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Parse a TypeScript/JS source string into a Babel `File`. Babel-only — no Astro
|
|
91
|
+
* coupling. Returns null when parsing throws fatally.
|
|
92
|
+
*/
|
|
93
|
+
function parseSource(source: string): t.File | null {
|
|
94
|
+
try {
|
|
95
|
+
return parseBabel(source, {
|
|
96
|
+
sourceType: 'module',
|
|
97
|
+
plugins: ['typescript'],
|
|
98
|
+
errorRecovery: true,
|
|
99
|
+
})
|
|
100
|
+
} catch {
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Parse the project's Astro content config file (TypeScript) into a structured
|
|
107
|
+
* representation of each collection's schema. Returns an empty map if no config
|
|
108
|
+
* file exists or parsing fails. The mtime-keyed cache (via `fs.stat()`) skips
|
|
109
|
+
* re-reading and re-parsing an unchanged config file.
|
|
110
|
+
*/
|
|
111
|
+
export async function parseContentConfig(fs: CmsFileSystem, cache: ParseCache): Promise<ParsedConfig> {
|
|
112
|
+
for (const configPath of ['src/content/config.ts', 'src/content.config.ts']) {
|
|
113
|
+
let stat: Awaited<ReturnType<CmsFileSystem['stat']>>
|
|
114
|
+
try {
|
|
115
|
+
stat = await fs.stat(configPath)
|
|
116
|
+
} catch {
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const cached = cache.get(configPath)
|
|
121
|
+
if (cached && cached.mtimeMs === stat.mtimeMs) {
|
|
122
|
+
if (cached.parsed.size > 0) return cached.parsed
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const content = await fs.readFile(configPath)
|
|
127
|
+
const parsed = parseConfigSource(content, configPath)
|
|
128
|
+
cache.set(configPath, { mtimeMs: stat.mtimeMs, parsed })
|
|
129
|
+
if (parsed.size > 0) return parsed
|
|
130
|
+
}
|
|
131
|
+
return new Map()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Exported for unit testing — operates on a source string directly. */
|
|
135
|
+
export function parseConfigSource(source: string, _sourcePath?: string): ParsedConfig {
|
|
136
|
+
const result: ParsedConfig = new Map()
|
|
137
|
+
const ast = parseSource(source)
|
|
138
|
+
if (!ast) return result
|
|
139
|
+
|
|
140
|
+
// Single pass: collect every top-level `const X = <expr>` binding (so we can
|
|
141
|
+
// later resolve Identifier references like `cs: TestimonialTranslation`),
|
|
142
|
+
// while also picking out `defineCollection({...})` calls and the
|
|
143
|
+
// `export const collections = { name: X, ... }` mapping.
|
|
144
|
+
const bindings: Bindings = new Map()
|
|
145
|
+
const collectionDecls = new Map<string, t.ObjectExpression>()
|
|
146
|
+
const exportMap = new Map<string, string>() // varName → collectionName
|
|
147
|
+
const inlineCollections = new Map<string, t.ObjectExpression>() // collectionName → defineCollection arg (inline form)
|
|
148
|
+
|
|
149
|
+
for (const stmt of ast.program.body) {
|
|
150
|
+
const varDecl = stmt.type === 'ExportNamedDeclaration' && stmt.declaration?.type === 'VariableDeclaration'
|
|
151
|
+
? stmt.declaration
|
|
152
|
+
: stmt.type === 'VariableDeclaration'
|
|
153
|
+
? stmt
|
|
154
|
+
: null
|
|
155
|
+
if (!varDecl) continue
|
|
156
|
+
|
|
157
|
+
for (const decl of varDecl.declarations) {
|
|
158
|
+
if (decl.id.type !== 'Identifier') continue
|
|
159
|
+
if (!decl.init) continue
|
|
160
|
+
|
|
161
|
+
bindings.set(decl.id.name, decl.init)
|
|
162
|
+
|
|
163
|
+
if (decl.id.name === 'collections' && decl.init.type === 'ObjectExpression') {
|
|
164
|
+
for (const prop of decl.init.properties) {
|
|
165
|
+
if (prop.type !== 'ObjectProperty') continue
|
|
166
|
+
const key = propertyKeyName(prop.key)
|
|
167
|
+
if (!key) continue
|
|
168
|
+
if (prop.value.type === 'Identifier') {
|
|
169
|
+
exportMap.set(prop.value.name, key)
|
|
170
|
+
} else if (prop.value.type === 'CallExpression' && isDefineCollectionCallee(prop.value.callee)) {
|
|
171
|
+
// Inline form: `collections = { name: defineCollection({...}) }`
|
|
172
|
+
const inlineArg = prop.value.arguments[0]
|
|
173
|
+
if (inlineArg?.type === 'ObjectExpression') {
|
|
174
|
+
inlineCollections.set(key, inlineArg)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (decl.init.type === 'CallExpression' && isDefineCollectionCallee(decl.init.callee)) {
|
|
182
|
+
const arg = decl.init.arguments[0]
|
|
183
|
+
if (arg?.type === 'ObjectExpression') {
|
|
184
|
+
collectionDecls.set(decl.id.name, arg)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Unify both styles: inline `name: defineCollection({...})` and the
|
|
191
|
+
// `const x = defineCollection({...}); collections = { name: x }` reference form.
|
|
192
|
+
const collectionObjects = new Map<string, t.ObjectExpression>(inlineCollections)
|
|
193
|
+
for (const [varName, collectionName] of exportMap) {
|
|
194
|
+
const decl = collectionDecls.get(varName)
|
|
195
|
+
if (decl) collectionObjects.set(collectionName, decl)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const [collectionName, decl] of collectionObjects) {
|
|
199
|
+
const loaderProperty = decl.properties.find(
|
|
200
|
+
p =>
|
|
201
|
+
p.type === 'ObjectProperty'
|
|
202
|
+
&& propertyKeyName(p.key) === 'loader',
|
|
203
|
+
)
|
|
204
|
+
const loaderOptions = loaderProperty?.type === 'ObjectProperty' ? extractGlobLoaderOptions(loaderProperty.value, bindings) : {}
|
|
205
|
+
const loaderPattern = loaderOptions.pattern
|
|
206
|
+
const loaderBase = loaderOptions.base
|
|
207
|
+
|
|
208
|
+
const schemaProperty = decl.properties.find(
|
|
209
|
+
p =>
|
|
210
|
+
p.type === 'ObjectProperty'
|
|
211
|
+
&& propertyKeyName(p.key) === 'schema',
|
|
212
|
+
)
|
|
213
|
+
if (!schemaProperty || schemaProperty.type !== 'ObjectProperty') {
|
|
214
|
+
if (!loaderPattern) continue
|
|
215
|
+
result.set(collectionName, {
|
|
216
|
+
name: collectionName,
|
|
217
|
+
fields: [],
|
|
218
|
+
loaderPattern,
|
|
219
|
+
loaderBase,
|
|
220
|
+
})
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const schemaObject = unwrapSchemaToObject(schemaProperty.value, bindings)
|
|
225
|
+
if (!schemaObject) {
|
|
226
|
+
if (!loaderPattern) continue
|
|
227
|
+
result.set(collectionName, {
|
|
228
|
+
name: collectionName,
|
|
229
|
+
fields: [],
|
|
230
|
+
loaderPattern,
|
|
231
|
+
loaderBase,
|
|
232
|
+
})
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
result.set(collectionName, {
|
|
237
|
+
name: collectionName,
|
|
238
|
+
fields: parseSchemaFields(schemaObject, bindings),
|
|
239
|
+
loaderPattern,
|
|
240
|
+
loaderBase,
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return result
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function isDefineCollectionCallee(callee: t.Node): boolean {
|
|
248
|
+
return callee.type === 'Identifier' && callee.name === 'defineCollection'
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function propertyKeyName(key: t.Node): string | null {
|
|
252
|
+
if (key.type === 'Identifier') return key.name
|
|
253
|
+
if (key.type === 'StringLiteral') return key.value
|
|
254
|
+
return null
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function extractGlobLoaderOptions(node: t.Node, bindings: Bindings): { pattern?: string; base?: string } {
|
|
258
|
+
const resolved = resolveExpression(node, bindings)
|
|
259
|
+
if (resolved.type !== 'CallExpression') return {}
|
|
260
|
+
if (!isGlobCallee(resolved.callee)) return {}
|
|
261
|
+
|
|
262
|
+
const arg = resolved.arguments[0]
|
|
263
|
+
if (!arg) return {}
|
|
264
|
+
const options = resolveExpression(arg, bindings)
|
|
265
|
+
if (options.type !== 'ObjectExpression') return {}
|
|
266
|
+
|
|
267
|
+
const result: { pattern?: string; base?: string } = {}
|
|
268
|
+
for (const prop of options.properties) {
|
|
269
|
+
if (prop.type !== 'ObjectProperty') continue
|
|
270
|
+
const key = propertyKeyName(prop.key)
|
|
271
|
+
if (key !== 'pattern' && key !== 'base') continue
|
|
272
|
+
const value = extractStaticString(prop.value, bindings)
|
|
273
|
+
if (value !== undefined) result[key] = value
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return result
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function extractStaticString(node: t.Node, bindings: Bindings): string | undefined {
|
|
280
|
+
const resolved = resolveExpression(node, bindings)
|
|
281
|
+
if (resolved.type === 'StringLiteral') return resolved.value
|
|
282
|
+
if (resolved.type === 'TemplateLiteral' && resolved.expressions.length === 0) {
|
|
283
|
+
return resolved.quasis[0]?.value.cooked ?? resolved.quasis[0]?.value.raw
|
|
284
|
+
}
|
|
285
|
+
return undefined
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function isGlobCallee(callee: t.Node): boolean {
|
|
289
|
+
return callee.type === 'Identifier' && callee.name === 'glob'
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Unwrap a `schema:` value down to the top-level (z|n).object({ ... }) ObjectExpression.
|
|
294
|
+
* Handles direct calls, the Astro callback form `({ image }) => z.object({...})`,
|
|
295
|
+
* and same-file variable references like `schema: BlogSchema`.
|
|
296
|
+
*/
|
|
297
|
+
function unwrapSchemaToObject(node: t.Node, bindings: Bindings): t.ObjectExpression | null {
|
|
298
|
+
const resolved = resolveExpression(node, bindings)
|
|
299
|
+
|
|
300
|
+
if (resolved.type === 'ArrowFunctionExpression' || resolved.type === 'FunctionExpression') {
|
|
301
|
+
const body = resolved.body
|
|
302
|
+
if (body.type === 'BlockStatement') {
|
|
303
|
+
for (const stmt of body.body) {
|
|
304
|
+
if (stmt.type === 'ReturnStatement' && stmt.argument) {
|
|
305
|
+
return unwrapSchemaToObject(stmt.argument, bindings)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return null
|
|
309
|
+
}
|
|
310
|
+
return unwrapSchemaToObject(body, bindings)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (resolved.type === 'CallExpression') {
|
|
314
|
+
const callee = resolved.callee
|
|
315
|
+
if (
|
|
316
|
+
callee.type === 'MemberExpression'
|
|
317
|
+
&& callee.object.type === 'Identifier'
|
|
318
|
+
&& (callee.object.name === 'z' || callee.object.name === 'n')
|
|
319
|
+
&& callee.property.type === 'Identifier'
|
|
320
|
+
&& callee.property.name === 'object'
|
|
321
|
+
) {
|
|
322
|
+
const arg = resolved.arguments[0]
|
|
323
|
+
if (!arg) return null
|
|
324
|
+
const resolvedArg = resolveExpression(arg, bindings)
|
|
325
|
+
if (resolvedArg.type === 'ObjectExpression') return resolvedArg
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return null
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function parseSchemaFields(schemaObject: t.ObjectExpression, bindings: Bindings): ParsedField[] {
|
|
333
|
+
const fields: ParsedField[] = []
|
|
334
|
+
for (const prop of schemaObject.properties) {
|
|
335
|
+
if (prop.type !== 'ObjectProperty') continue
|
|
336
|
+
const name = propertyKeyName(prop.key)
|
|
337
|
+
if (!name) continue
|
|
338
|
+
|
|
339
|
+
const field: ParsedField = { name, required: true }
|
|
340
|
+
analyzeFieldExpression(prop.value, field, bindings)
|
|
341
|
+
fields.push(field)
|
|
342
|
+
}
|
|
343
|
+
return fields
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Walk a field's value expression. Each layer is either a wrapper method call
|
|
348
|
+
* (`.optional()`, `.default()`, `.nullable()`, `.nullish()`, `.orderBy(...)`)
|
|
349
|
+
* or the base call (`n.image()`, `image()`, `z.enum([...])`, `n.array(reference(...))`).
|
|
350
|
+
*
|
|
351
|
+
* Resolves same-file `Identifier` references against `bindings` at each layer so
|
|
352
|
+
* patterns like `cs: TestimonialTranslation` and `en: TestimonialTranslation.optional()`
|
|
353
|
+
* are followed back to their defining call.
|
|
354
|
+
*/
|
|
355
|
+
function analyzeFieldExpression(node: t.Node, field: ParsedField, bindings: Bindings): void {
|
|
356
|
+
let current: t.Node | null = resolveExpression(node, bindings)
|
|
357
|
+
while (current) {
|
|
358
|
+
if (current.type !== 'CallExpression') return
|
|
359
|
+
|
|
360
|
+
if (isBaseCall(current)) {
|
|
361
|
+
analyzeBaseCall(current, field, bindings)
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (current.callee.type !== 'MemberExpression') return
|
|
366
|
+
const property = current.callee.property
|
|
367
|
+
const methodName = property.type === 'Identifier' ? property.name : ''
|
|
368
|
+
|
|
369
|
+
if (WRAPPER_METHODS.has(methodName)) {
|
|
370
|
+
field.required = false
|
|
371
|
+
} else if (methodName === 'orderBy') {
|
|
372
|
+
const arg = current.arguments[0]
|
|
373
|
+
const direction = arg?.type === 'StringLiteral' && arg.value === 'desc' ? 'desc' : 'asc'
|
|
374
|
+
field.orderBy = { direction }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
current = resolveExpression(current.callee.object, bindings)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* A "base call" is the innermost call that defines the field's type: a Zod/n
|
|
383
|
+
* helper invocation or a bare `image()` / `reference()` from a callback param.
|
|
384
|
+
*/
|
|
385
|
+
function isBaseCall(node: t.CallExpression): boolean {
|
|
386
|
+
const callee = node.callee
|
|
387
|
+
if (callee.type === 'Identifier') {
|
|
388
|
+
return callee.name === 'image' || callee.name === 'reference'
|
|
389
|
+
}
|
|
390
|
+
if (callee.type === 'MemberExpression') {
|
|
391
|
+
return callee.object.type === 'Identifier'
|
|
392
|
+
&& (callee.object.name === 'n' || callee.object.name === 'z')
|
|
393
|
+
}
|
|
394
|
+
return false
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function analyzeBaseCall(node: t.CallExpression, field: ParsedField, bindings: Bindings): void {
|
|
398
|
+
const callee = node.callee
|
|
399
|
+
|
|
400
|
+
// Bare image() / reference() from the schema callback form
|
|
401
|
+
if (callee.type === 'Identifier') {
|
|
402
|
+
if (callee.name === 'image') {
|
|
403
|
+
field.type = 'image'
|
|
404
|
+
field.astroImage = true
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
if (callee.name === 'reference') {
|
|
408
|
+
const arg = node.arguments[0]
|
|
409
|
+
if (arg?.type === 'StringLiteral') {
|
|
410
|
+
field.reference = { target: arg.value, isArray: false }
|
|
411
|
+
}
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
return
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (callee.type !== 'MemberExpression') return
|
|
418
|
+
if (callee.object.type !== 'Identifier' || callee.property.type !== 'Identifier') return
|
|
419
|
+
const ns = callee.object.name
|
|
420
|
+
const fn = callee.property.name
|
|
421
|
+
|
|
422
|
+
// n.image(), n.url(), n.text(...), etc. — semantic field types from @nuasite/cms.
|
|
423
|
+
// FIELD_HELPER_TYPES gates to the helper subset (excludes boolean/select/array/object/
|
|
424
|
+
// reference, which are inferred elsewhere); isFieldType narrows `fn` to FieldType.
|
|
425
|
+
if (ns === 'n' && FIELD_HELPER_TYPES.has(fn) && isFieldType(fn)) {
|
|
426
|
+
field.type = fn
|
|
427
|
+
const firstArg = node.arguments[0]
|
|
428
|
+
if (firstArg?.type === 'ObjectExpression') {
|
|
429
|
+
const hints = parseHintsFromObject(firstArg)
|
|
430
|
+
if (hints) field.hints = hints
|
|
431
|
+
}
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// (z|n).enum([...]) → select with options
|
|
436
|
+
if ((ns === 'z' || ns === 'n') && fn === 'enum') {
|
|
437
|
+
const arg = node.arguments[0]
|
|
438
|
+
if (arg?.type === 'ArrayExpression') {
|
|
439
|
+
const options: string[] = []
|
|
440
|
+
for (const el of arg.elements) {
|
|
441
|
+
if (el?.type === 'StringLiteral') options.push(el.value)
|
|
442
|
+
}
|
|
443
|
+
if (options.length > 0) {
|
|
444
|
+
field.type = 'select'
|
|
445
|
+
field.options = options
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// (z|n).object({...}) → nested object field
|
|
452
|
+
if ((ns === 'z' || ns === 'n') && fn === 'object') {
|
|
453
|
+
const arg = node.arguments[0]
|
|
454
|
+
if (!arg) return
|
|
455
|
+
const resolved = resolveExpression(arg, bindings)
|
|
456
|
+
if (resolved.type === 'ObjectExpression') {
|
|
457
|
+
field.type = 'object'
|
|
458
|
+
field.fields = parseSchemaFields(resolved, bindings)
|
|
459
|
+
}
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// (z|n).array(<inner>) → array; inspect the element type
|
|
464
|
+
if ((ns === 'z' || ns === 'n') && fn === 'array') {
|
|
465
|
+
const innerRaw = node.arguments[0]
|
|
466
|
+
if (!innerRaw) return
|
|
467
|
+
const inner = resolveExpression(innerRaw, bindings)
|
|
468
|
+
// Array of references: keep the existing flat shape so detectReferenceFields can wire it up.
|
|
469
|
+
if (
|
|
470
|
+
inner.type === 'CallExpression'
|
|
471
|
+
&& inner.callee.type === 'Identifier'
|
|
472
|
+
&& inner.callee.name === 'reference'
|
|
473
|
+
) {
|
|
474
|
+
const target = inner.arguments[0]
|
|
475
|
+
if (target?.type === 'StringLiteral') {
|
|
476
|
+
field.reference = { target: target.value, isArray: true }
|
|
477
|
+
}
|
|
478
|
+
return
|
|
479
|
+
}
|
|
480
|
+
// Array of anything else: analyze the inner expression and lift its type/fields.
|
|
481
|
+
// Note: nested arrays (`n.array(n.array(...))`) collapse here — `itemType` records
|
|
482
|
+
// only the outer element type, the inner element shape is lost. No editor flow
|
|
483
|
+
// currently renders nested arrays, so we don't carry a recursive `itemDefinition`
|
|
484
|
+
// yet; add one when editor support arrives.
|
|
485
|
+
const innerField: ParsedField = { name: '__item__', required: true }
|
|
486
|
+
analyzeFieldExpression(inner, innerField, bindings)
|
|
487
|
+
field.type = 'array'
|
|
488
|
+
if (innerField.type) field.itemType = innerField.type
|
|
489
|
+
if (innerField.fields) field.fields = innerField.fields
|
|
490
|
+
return
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function parseHintsFromObject(obj: t.ObjectExpression): FieldHints | undefined {
|
|
495
|
+
const raw: { [K in keyof FieldHints]: FieldHints[K] } = {}
|
|
496
|
+
for (const prop of obj.properties) {
|
|
497
|
+
if (prop.type !== 'ObjectProperty') continue
|
|
498
|
+
const key = propertyKeyName(prop.key)
|
|
499
|
+
if (!key || !VALID_HINT_KEYS.has(key)) continue
|
|
500
|
+
|
|
501
|
+
const value = prop.value
|
|
502
|
+
if (value.type === 'NumericLiteral') {
|
|
503
|
+
assignHint(raw, key, value.value)
|
|
504
|
+
} else if (
|
|
505
|
+
value.type === 'UnaryExpression'
|
|
506
|
+
&& value.operator === '-'
|
|
507
|
+
&& value.argument.type === 'NumericLiteral'
|
|
508
|
+
) {
|
|
509
|
+
assignHint(raw, key, -value.argument.value)
|
|
510
|
+
} else if (value.type === 'StringLiteral') {
|
|
511
|
+
assignHint(raw, key, value.value)
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (Object.keys(raw).length === 0) return undefined
|
|
515
|
+
return raw
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Assign a parsed hint value onto the hints object, narrowing per the FieldHints shape. */
|
|
519
|
+
function assignHint(hints: FieldHints, key: string, value: string | number): void {
|
|
520
|
+
switch (key) {
|
|
521
|
+
case 'min':
|
|
522
|
+
case 'max':
|
|
523
|
+
hints[key] = value
|
|
524
|
+
return
|
|
525
|
+
case 'step':
|
|
526
|
+
case 'maxLength':
|
|
527
|
+
case 'minLength':
|
|
528
|
+
case 'rows':
|
|
529
|
+
if (typeof value === 'number') hints[key] = value
|
|
530
|
+
return
|
|
531
|
+
case 'placeholder':
|
|
532
|
+
case 'accept':
|
|
533
|
+
if (typeof value === 'string') hints[key] = value
|
|
534
|
+
return
|
|
535
|
+
}
|
|
536
|
+
}
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AddRedirectRequest,
|
|
3
|
+
CollectionDefinition,
|
|
4
|
+
ComponentDefinition,
|
|
5
|
+
CreatePageRequest,
|
|
6
|
+
DeletePageRequest,
|
|
7
|
+
DeleteRedirectRequest,
|
|
8
|
+
DuplicatePageRequest,
|
|
9
|
+
GetRedirectsResponse,
|
|
10
|
+
LayoutInfo,
|
|
11
|
+
MediaStorageAdapter,
|
|
12
|
+
MutationResult,
|
|
13
|
+
PageOperationResponse,
|
|
14
|
+
RedirectOperationResponse,
|
|
15
|
+
UpdateRedirectRequest,
|
|
16
|
+
} from '@nuasite/cms-types'
|
|
17
|
+
import { scanCollections } from './collection-scanner'
|
|
18
|
+
import { scanComponentDefinitions } from './component-registry'
|
|
19
|
+
import { type ParseCache } from './content-config-ast'
|
|
20
|
+
import type { CmsFileSystem } from './fs/types'
|
|
21
|
+
import {
|
|
22
|
+
addArrayItem as addArrayItemOp,
|
|
23
|
+
type AddArrayItemInput,
|
|
24
|
+
createEntry as createEntryOp,
|
|
25
|
+
type CreateEntryInput,
|
|
26
|
+
deleteEntry as deleteEntryOp,
|
|
27
|
+
type EntryOpsDeps,
|
|
28
|
+
getEntry as getEntryOp,
|
|
29
|
+
type GetEntryResult,
|
|
30
|
+
removeArrayItem as removeArrayItemOp,
|
|
31
|
+
type RemoveArrayItemInput,
|
|
32
|
+
renameEntry as renameEntryOp,
|
|
33
|
+
updateEntry as updateEntryOp,
|
|
34
|
+
type UpdateEntryInput,
|
|
35
|
+
} from './handlers/entry-ops'
|
|
36
|
+
import {
|
|
37
|
+
createPage as createPageOp,
|
|
38
|
+
deletePage as deletePageOp,
|
|
39
|
+
duplicatePage as duplicatePageOp,
|
|
40
|
+
getLayouts as getLayoutsOp,
|
|
41
|
+
} from './handlers/page-ops'
|
|
42
|
+
import {
|
|
43
|
+
addRedirect as addRedirectOp,
|
|
44
|
+
deleteRedirect as deleteRedirectOp,
|
|
45
|
+
listRedirects as listRedirectsOp,
|
|
46
|
+
updateRedirect as updateRedirectOp,
|
|
47
|
+
} from './handlers/redirect-ops'
|
|
48
|
+
|
|
49
|
+
export interface CmsCoreOptions {
|
|
50
|
+
/** Content collections directory, relative to the filesystem root. Defaults to `src/content`. */
|
|
51
|
+
contentDir?: string
|
|
52
|
+
/** Pluggable media storage adapter (local / s3 / contember). */
|
|
53
|
+
media?: MediaStorageAdapter
|
|
54
|
+
/** Directories to scan for Astro components (used for MDX import resolution). Defaults to `['src/components']`. */
|
|
55
|
+
componentDirs?: string[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The framework-agnostic CMS brain over a `CmsFileSystem` port.
|
|
60
|
+
*
|
|
61
|
+
* Exposes the read/scan surface plus the structural mutations (entry / array /
|
|
62
|
+
* page / redirect CRUD) and a pluggable media adapter. All I/O flows through the
|
|
63
|
+
* injected `CmsFileSystem`; nothing here knows about Astro, Vite, HTTP or the
|
|
64
|
+
* render-time page manifest.
|
|
65
|
+
*/
|
|
66
|
+
export interface CmsCore {
|
|
67
|
+
// READ / SCAN
|
|
68
|
+
/** Scan all content collections, returning their inferred definitions keyed by name. */
|
|
69
|
+
scanCollections(): Promise<Record<string, CollectionDefinition>>
|
|
70
|
+
/** Resolve the Astro component definitions used for MDX import injection. */
|
|
71
|
+
scanComponents(): Promise<Record<string, ComponentDefinition>>
|
|
72
|
+
/** Read a single entry's frontmatter + body, or `null` when it does not exist. */
|
|
73
|
+
getEntry(collection: string, slug: string): Promise<GetEntryResult | null>
|
|
74
|
+
|
|
75
|
+
// ENTRY MUTATIONS
|
|
76
|
+
createEntry(input: CreateEntryInput): Promise<MutationResult>
|
|
77
|
+
updateEntry(input: UpdateEntryInput): Promise<MutationResult>
|
|
78
|
+
deleteEntry(collection: string, slug: string): Promise<MutationResult>
|
|
79
|
+
renameEntry(collection: string, from: string, to: string): Promise<MutationResult>
|
|
80
|
+
|
|
81
|
+
// ENTRY-FRONTMATTER ARRAY FIELDS
|
|
82
|
+
addArrayItem(input: AddArrayItemInput): Promise<MutationResult>
|
|
83
|
+
removeArrayItem(input: RemoveArrayItemInput): Promise<MutationResult>
|
|
84
|
+
|
|
85
|
+
// PAGES / REDIRECTS
|
|
86
|
+
createPage(input: CreatePageRequest): Promise<PageOperationResponse>
|
|
87
|
+
duplicatePage(input: DuplicatePageRequest): Promise<PageOperationResponse>
|
|
88
|
+
deletePage(input: DeletePageRequest): Promise<PageOperationResponse>
|
|
89
|
+
getLayouts(): Promise<LayoutInfo[]>
|
|
90
|
+
listRedirects(): Promise<GetRedirectsResponse>
|
|
91
|
+
addRedirect(input: AddRedirectRequest): Promise<RedirectOperationResponse>
|
|
92
|
+
updateRedirect(input: UpdateRedirectRequest): Promise<RedirectOperationResponse>
|
|
93
|
+
deleteRedirect(input: DeleteRedirectRequest): Promise<RedirectOperationResponse>
|
|
94
|
+
|
|
95
|
+
// MEDIA (pluggable adapter; undefined when none is configured)
|
|
96
|
+
media?: MediaStorageAdapter
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function createCmsCore(fs: CmsFileSystem, opts: CmsCoreOptions = {}): CmsCore {
|
|
100
|
+
const contentDir = opts.contentDir ?? 'src/content'
|
|
101
|
+
const componentDirs = opts.componentDirs ?? ['src/components']
|
|
102
|
+
// One mtime-keyed content-config parse cache per core instance (per project root).
|
|
103
|
+
const parseCache: ParseCache = new Map()
|
|
104
|
+
|
|
105
|
+
const entryDeps: EntryOpsDeps = {
|
|
106
|
+
fs,
|
|
107
|
+
contentDir,
|
|
108
|
+
parseCache,
|
|
109
|
+
componentDirs,
|
|
110
|
+
resolveComponentDefinitions: () => scanComponentDefinitions(fs, componentDirs),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
scanCollections() {
|
|
115
|
+
return scanCollections(fs, contentDir, parseCache)
|
|
116
|
+
},
|
|
117
|
+
scanComponents() {
|
|
118
|
+
return scanComponentDefinitions(fs, componentDirs)
|
|
119
|
+
},
|
|
120
|
+
getEntry(collection, slug) {
|
|
121
|
+
return getEntryOp(entryDeps, collection, slug)
|
|
122
|
+
},
|
|
123
|
+
createEntry(input) {
|
|
124
|
+
return createEntryOp(entryDeps, input)
|
|
125
|
+
},
|
|
126
|
+
updateEntry(input) {
|
|
127
|
+
return updateEntryOp(entryDeps, input)
|
|
128
|
+
},
|
|
129
|
+
deleteEntry(collection, slug) {
|
|
130
|
+
return deleteEntryOp(entryDeps, collection, slug)
|
|
131
|
+
},
|
|
132
|
+
renameEntry(collection, from, to) {
|
|
133
|
+
return renameEntryOp(entryDeps, collection, from, to)
|
|
134
|
+
},
|
|
135
|
+
addArrayItem(input) {
|
|
136
|
+
return addArrayItemOp(entryDeps, input)
|
|
137
|
+
},
|
|
138
|
+
removeArrayItem(input) {
|
|
139
|
+
return removeArrayItemOp(entryDeps, input)
|
|
140
|
+
},
|
|
141
|
+
createPage(input) {
|
|
142
|
+
return createPageOp({ fs }, input)
|
|
143
|
+
},
|
|
144
|
+
duplicatePage(input) {
|
|
145
|
+
return duplicatePageOp({ fs }, input)
|
|
146
|
+
},
|
|
147
|
+
deletePage(input) {
|
|
148
|
+
return deletePageOp({ fs }, input)
|
|
149
|
+
},
|
|
150
|
+
getLayouts() {
|
|
151
|
+
return getLayoutsOp({ fs })
|
|
152
|
+
},
|
|
153
|
+
listRedirects() {
|
|
154
|
+
return listRedirectsOp({ fs })
|
|
155
|
+
},
|
|
156
|
+
addRedirect(input) {
|
|
157
|
+
return addRedirectOp({ fs }, input)
|
|
158
|
+
},
|
|
159
|
+
updateRedirect(input) {
|
|
160
|
+
return updateRedirectOp({ fs }, input)
|
|
161
|
+
},
|
|
162
|
+
deleteRedirect(input) {
|
|
163
|
+
return deleteRedirectOp({ fs }, input)
|
|
164
|
+
},
|
|
165
|
+
media: opts.media,
|
|
166
|
+
}
|
|
167
|
+
}
|