@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.
Files changed (54) hide show
  1. package/dist/types/collection-scanner.d.ts +12 -0
  2. package/dist/types/collection-scanner.d.ts.map +1 -0
  3. package/dist/types/component-registry.d.ts +15 -0
  4. package/dist/types/component-registry.d.ts.map +1 -0
  5. package/dist/types/content-config-ast.d.ts +45 -0
  6. package/dist/types/content-config-ast.d.ts.map +1 -0
  7. package/dist/types/core.d.ts +44 -0
  8. package/dist/types/core.d.ts.map +1 -0
  9. package/dist/types/fs/glob.d.ts +3 -0
  10. package/dist/types/fs/glob.d.ts.map +1 -0
  11. package/dist/types/fs/node-fs.d.ts +7 -0
  12. package/dist/types/fs/node-fs.d.ts.map +1 -0
  13. package/dist/types/fs/types.d.ts +33 -0
  14. package/dist/types/fs/types.d.ts.map +1 -0
  15. package/dist/types/handlers/entry-ops.d.ts +69 -0
  16. package/dist/types/handlers/entry-ops.d.ts.map +1 -0
  17. package/dist/types/handlers/page-ops.d.ts +14 -0
  18. package/dist/types/handlers/page-ops.d.ts.map +1 -0
  19. package/dist/types/handlers/redirect-ops.d.ts +10 -0
  20. package/dist/types/handlers/redirect-ops.d.ts.map +1 -0
  21. package/dist/types/index.d.ts +12 -0
  22. package/dist/types/index.d.ts.map +1 -0
  23. package/dist/types/media/contember.d.ts +18 -0
  24. package/dist/types/media/contember.d.ts.map +1 -0
  25. package/dist/types/media/index.d.ts +5 -0
  26. package/dist/types/media/index.d.ts.map +1 -0
  27. package/dist/types/media/local.d.ts +12 -0
  28. package/dist/types/media/local.d.ts.map +1 -0
  29. package/dist/types/media/project-images.d.ts +15 -0
  30. package/dist/types/media/project-images.d.ts.map +1 -0
  31. package/dist/types/media/s3.d.ts +12 -0
  32. package/dist/types/media/s3.d.ts.map +1 -0
  33. package/dist/types/shared.d.ts +24 -0
  34. package/dist/types/shared.d.ts.map +1 -0
  35. package/dist/types/tsconfig.tsbuildinfo +1 -0
  36. package/package.json +55 -0
  37. package/src/collection-scanner.ts +935 -0
  38. package/src/component-registry.ts +308 -0
  39. package/src/content-config-ast.ts +536 -0
  40. package/src/core.ts +167 -0
  41. package/src/fs/glob.ts +32 -0
  42. package/src/fs/node-fs.ts +138 -0
  43. package/src/fs/types.ts +26 -0
  44. package/src/handlers/entry-ops.ts +528 -0
  45. package/src/handlers/page-ops.ts +203 -0
  46. package/src/handlers/redirect-ops.ts +139 -0
  47. package/src/index.ts +41 -0
  48. package/src/media/contember.ts +90 -0
  49. package/src/media/index.ts +4 -0
  50. package/src/media/local.ts +147 -0
  51. package/src/media/project-images.ts +82 -0
  52. package/src/media/s3.ts +151 -0
  53. package/src/shared.ts +65 -0
  54. 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
+ }