@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,528 @@
1
+ import type { CollectionEntryInfo, ComponentDefinition, MutationResult } from '@nuasite/cms-types'
2
+ import yaml from 'yaml'
3
+ import { scanCollections } from '../collection-scanner'
4
+ import { type ParseCache, parseContentConfig } from '../content-config-ast'
5
+ import type { CmsFileSystem } from '../fs/types'
6
+ import { isPlainRecord, relativeImportPath, slugify } from '../shared'
7
+
8
+ /** Frontmatter file extensions that hold markdown content (vs. pure data files). */
9
+ const MARKDOWN_EXTENSIONS = ['md', 'mdx'] as const
10
+
11
+ export interface GetEntryResult {
12
+ /** Markdown body (empty string for data collections). */
13
+ content: string
14
+ /** Parsed frontmatter / data object. */
15
+ frontmatter: Record<string, unknown>
16
+ /** Source file path, root-relative. */
17
+ sourcePath: string
18
+ }
19
+
20
+ export interface EntryOpsDeps {
21
+ fs: CmsFileSystem
22
+ contentDir: string
23
+ parseCache: ParseCache
24
+ /** Directories to scan for Astro components when resolving MDX imports. */
25
+ componentDirs: string[]
26
+ /** Resolve component definitions internally (MDX import injection). */
27
+ resolveComponentDefinitions: () => Promise<Record<string, ComponentDefinition>>
28
+ }
29
+
30
+ // ============================================================================
31
+ // Path / slug resolution
32
+ // ============================================================================
33
+
34
+ function fileExtension(filePath: string): string {
35
+ const idx = filePath.lastIndexOf('.')
36
+ return idx >= 0 ? filePath.slice(idx + 1).toLowerCase() : ''
37
+ }
38
+
39
+ function isDataFile(filePath: string): boolean {
40
+ const ext = fileExtension(filePath)
41
+ return ext === 'json' || ext === 'yaml' || ext === 'yml'
42
+ }
43
+
44
+ /**
45
+ * Resolve a `{collection, slug}` pair to an existing entry's source path.
46
+ *
47
+ * Tries the flat layout first (`<collection>/<slug>.<ext>`) for every supported
48
+ * extension, then the index layout (`<collection>/<slug>/index.{md,mdx}`).
49
+ * Returns `null` when no matching file exists.
50
+ */
51
+ async function resolveEntryPath(deps: EntryOpsDeps, collection: string, slug: string): Promise<string | null> {
52
+ const base = `${deps.contentDir}/${collection}`
53
+ const flatExts = ['md', 'mdx', 'json', 'yaml', 'yml']
54
+ for (const ext of flatExts) {
55
+ const candidate = `${base}/${slug}.${ext}`
56
+ if (await deps.fs.exists(candidate)) return candidate
57
+ }
58
+ for (const ext of MARKDOWN_EXTENSIONS) {
59
+ const candidate = `${base}/${slug}/index.${ext}`
60
+ if (await deps.fs.exists(candidate)) return candidate
61
+ }
62
+ return null
63
+ }
64
+
65
+ // ============================================================================
66
+ // Frontmatter parse / serialize (ported from @nuasite/cms markdown-ops)
67
+ // ============================================================================
68
+
69
+ export function parseFrontmatter(raw: string): { frontmatter: Record<string, unknown>; content: string } {
70
+ const trimmed = raw.trimStart()
71
+ if (!trimmed.startsWith('---')) {
72
+ return { frontmatter: {}, content: raw }
73
+ }
74
+
75
+ const lines = trimmed.split('\n')
76
+ let endLineIndex = -1
77
+ for (let i = 1; i < lines.length; i++) {
78
+ if (lines[i]!.trimEnd() === '---') {
79
+ endLineIndex = i
80
+ break
81
+ }
82
+ }
83
+ if (endLineIndex === -1) {
84
+ return { frontmatter: {}, content: raw }
85
+ }
86
+
87
+ const yamlStr = lines.slice(1, endLineIndex).join('\n').trim()
88
+ const content = lines.slice(endLineIndex + 1).join('\n').replace(/^\r?\n/, '')
89
+
90
+ let frontmatter: Record<string, unknown> = {}
91
+ try {
92
+ const parsed: unknown = yaml.parse(yamlStr)
93
+ if (isPlainRecord(parsed)) {
94
+ frontmatter = parsed
95
+ }
96
+ } catch {
97
+ // Invalid YAML, return empty frontmatter
98
+ }
99
+
100
+ return { frontmatter, content }
101
+ }
102
+
103
+ /** Pattern for strings that YAML auto-parses as Date objects */
104
+ const YAML_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}/
105
+
106
+ export function serializeFrontmatter(frontmatter: Record<string, unknown>, content: string): string {
107
+ const doc = new yaml.Document(frontmatter)
108
+ yaml.visit(doc, {
109
+ Scalar(_key, node) {
110
+ if (typeof node.value === 'string' && YAML_DATE_PATTERN.test(node.value)) {
111
+ node.type = yaml.Scalar.QUOTE_SINGLE
112
+ }
113
+ },
114
+ })
115
+ const yamlStr = doc.toString().trim()
116
+ return `---\n${yamlStr}\n---\n${content}`
117
+ }
118
+
119
+ /**
120
+ * Ensure MDX content has import statements for all components used in the body.
121
+ * Scans for `<ComponentName` tags, checks for existing imports, and prepends missing ones.
122
+ *
123
+ * `filePath` and the component `def.file` are both root-relative, forward-slash paths.
124
+ */
125
+ export function ensureMdxImports(
126
+ content: string,
127
+ filePath: string,
128
+ componentDefinitions: Record<string, ComponentDefinition>,
129
+ ): string {
130
+ const usedComponents = new Set<string>()
131
+ const tagRegex = /<([A-Z][A-Za-z0-9]*)\b/g
132
+ let match
133
+ while ((match = tagRegex.exec(content)) !== null) {
134
+ if (match[1]) usedComponents.add(match[1])
135
+ }
136
+ if (usedComponents.size === 0) return content
137
+
138
+ const importedNames = new Set<string>()
139
+ const importLineRegex = /^import\s+(.+)\s+from\s+/gm
140
+ let lastImportEnd = -1
141
+ while ((match = importLineRegex.exec(content)) !== null) {
142
+ lastImportEnd = match.index + match[0].length
143
+ const fromRest = content.slice(lastImportEnd)
144
+ const lineEnd = fromRest.indexOf('\n')
145
+ if (lineEnd >= 0) lastImportEnd += lineEnd
146
+ else lastImportEnd = content.length
147
+
148
+ const clause = match[1]!
149
+ const braceMatch = clause.match(/\{([^}]+)\}/)
150
+ if (braceMatch?.[1]) {
151
+ for (const name of braceMatch[1].split(',')) {
152
+ const parts = name.trim().split(/\s+as\s+/)
153
+ const imported = (parts[1] ?? parts[0])?.trim()
154
+ if (imported) importedNames.add(imported)
155
+ }
156
+ }
157
+ const withoutBraces = clause.replace(/\{[^}]*\}/, '').replace(/,/g, ' ').trim()
158
+ for (const token of withoutBraces.split(/\s+/)) {
159
+ if (token === '*' || token === 'as' || token === '') continue
160
+ importedNames.add(token)
161
+ }
162
+ }
163
+
164
+ const missingImports: string[] = []
165
+
166
+ for (const name of usedComponents) {
167
+ if (importedNames.has(name)) continue
168
+ const def = componentDefinitions[name]
169
+ if (!def) continue
170
+
171
+ const rel = relativeImportPath(filePath, def.file)
172
+ missingImports.push(`import ${name} from '${rel}'`)
173
+ }
174
+
175
+ if (missingImports.length === 0) return content
176
+
177
+ const importBlock = missingImports.join('\n')
178
+
179
+ if (lastImportEnd >= 0) {
180
+ return content.slice(0, lastImportEnd) + '\n' + importBlock + content.slice(lastImportEnd)
181
+ }
182
+
183
+ return importBlock + '\n\n' + content
184
+ }
185
+
186
+ // ============================================================================
187
+ // Collection markdown layout detection (ported from markdown-ops)
188
+ // ============================================================================
189
+
190
+ type MarkdownCollectionLayout = 'flat' | 'index'
191
+
192
+ async function detectCollectionMarkdownLayout(deps: EntryOpsDeps, collection: string): Promise<MarkdownCollectionLayout> {
193
+ const existingLayout = await inferLayoutFromExistingEntries(deps, collection)
194
+ if (existingLayout) return existingLayout
195
+
196
+ const configLayout = await inferLayoutFromContentConfig(deps, collection)
197
+ if (configLayout) return configLayout
198
+
199
+ return 'flat'
200
+ }
201
+
202
+ async function inferLayoutFromExistingEntries(deps: EntryOpsDeps, collection: string): Promise<MarkdownCollectionLayout | null> {
203
+ const collectionPath = `${deps.contentDir}/${collection}`
204
+
205
+ const dirEntries = await deps.fs.list(collectionPath)
206
+ if (dirEntries.length === 0) return null
207
+
208
+ let flatCount = 0
209
+ const flatSlugs = new Set<string>()
210
+
211
+ for (const entry of dirEntries) {
212
+ if (entry.isDirectory) continue
213
+ const match = entry.name.match(/^(.+)\.(md|mdx)$/)
214
+ if (!match) continue
215
+ flatCount++
216
+ flatSlugs.add(match[1]!)
217
+ }
218
+
219
+ const subdirs = dirEntries.filter(entry => entry.isDirectory && !entry.name.startsWith('_') && !entry.name.startsWith('.'))
220
+ const indexLookups = await Promise.all(subdirs.map(async dir => {
221
+ if (flatSlugs.has(dir.name)) return false
222
+ for (const ext of MARKDOWN_EXTENSIONS) {
223
+ if (await deps.fs.exists(`${collectionPath}/${dir.name}/index.${ext}`)) return true
224
+ }
225
+ return false
226
+ }))
227
+ const indexCount = indexLookups.filter(Boolean).length
228
+
229
+ if (indexCount > flatCount) return 'index'
230
+ if (flatCount > 0) return 'flat'
231
+ return null
232
+ }
233
+
234
+ async function inferLayoutFromContentConfig(deps: EntryOpsDeps, collection: string): Promise<MarkdownCollectionLayout | null> {
235
+ const parsed = await parseContentConfig(deps.fs, deps.parseCache)
236
+ const pattern = parsed.get(collection)?.loaderPattern
237
+ if (!pattern) return null
238
+ return isIndexStyleGlobPattern(pattern) ? 'index' : 'flat'
239
+ }
240
+
241
+ function isIndexStyleGlobPattern(pattern: string): boolean {
242
+ return pattern.includes('index.{') || pattern.includes('*/index') || pattern.includes('**/index')
243
+ }
244
+
245
+ // ============================================================================
246
+ // Entry CRUD
247
+ // ============================================================================
248
+
249
+ export interface CreateEntryInput {
250
+ collection: string
251
+ slug: string
252
+ frontmatter: Record<string, unknown>
253
+ body?: string
254
+ /** File extension override for data collections (e.g. 'json', 'yaml'). Defaults to 'md'. */
255
+ fileExtension?: string
256
+ }
257
+
258
+ export interface UpdateEntryInput {
259
+ collection: string
260
+ slug: string
261
+ frontmatter?: Record<string, unknown>
262
+ body?: string
263
+ }
264
+
265
+ function errorMessage(error: unknown): string {
266
+ return error instanceof Error ? error.message : String(error)
267
+ }
268
+
269
+ export async function getEntry(deps: EntryOpsDeps, collection: string, slug: string): Promise<GetEntryResult | null> {
270
+ const sourcePath = await resolveEntryPath(deps, collection, slug)
271
+ if (!sourcePath) return null
272
+
273
+ const raw = await deps.fs.readFile(sourcePath)
274
+
275
+ if (isDataFile(sourcePath)) {
276
+ const data = sourcePath.endsWith('.json') ? JSON.parse(raw) : yaml.parse(raw)
277
+ return {
278
+ content: '',
279
+ frontmatter: (data && typeof data === 'object') ? data : {},
280
+ sourcePath,
281
+ }
282
+ }
283
+
284
+ const { frontmatter, content } = parseFrontmatter(raw)
285
+ return { content, frontmatter, sourcePath }
286
+ }
287
+
288
+ export async function createEntry(deps: EntryOpsDeps, input: CreateEntryInput): Promise<MutationResult> {
289
+ const { collection, slug, frontmatter, body = '' } = input
290
+
291
+ const normalizedSlug = slugify(slug)
292
+ if (!normalizedSlug) {
293
+ return { success: false, error: 'Could not generate a valid slug from the provided slug' }
294
+ }
295
+
296
+ const allowedExtensions = ['md', 'mdx', 'json', 'yaml', 'yml']
297
+ const ext = input.fileExtension ?? 'md'
298
+ if (!allowedExtensions.includes(ext)) {
299
+ return { success: false, error: `Invalid file extension "${ext}". Allowed: ${allowedExtensions.join(', ')}` }
300
+ }
301
+ const isData = ext === 'json' || ext === 'yaml' || ext === 'yml'
302
+ const layout = isData ? 'flat' : await detectCollectionMarkdownLayout(deps, collection)
303
+ const sourcePath = layout === 'index'
304
+ ? `${deps.contentDir}/${collection}/${normalizedSlug}/index.${ext}`
305
+ : `${deps.contentDir}/${collection}/${normalizedSlug}.${ext}`
306
+
307
+ let fileContent: string
308
+ if (isData) {
309
+ fileContent = ext === 'json'
310
+ ? JSON.stringify({ ...frontmatter }, null, 2) + '\n'
311
+ : yaml.stringify({ ...frontmatter })
312
+ } else {
313
+ fileContent = serializeFrontmatter({ ...frontmatter }, body)
314
+ }
315
+
316
+ if (await deps.fs.exists(sourcePath)) {
317
+ return { success: false, error: `File already exists: ${sourcePath}` }
318
+ }
319
+
320
+ try {
321
+ await deps.fs.writeFile(sourcePath, fileContent)
322
+ return { success: true, sourcePath }
323
+ } catch (error) {
324
+ return { success: false, error: errorMessage(error) }
325
+ }
326
+ }
327
+
328
+ export async function updateEntry(deps: EntryOpsDeps, input: UpdateEntryInput): Promise<MutationResult> {
329
+ const sourcePath = await resolveEntryPath(deps, input.collection, input.slug)
330
+ if (!sourcePath) {
331
+ return { success: false, error: `Entry not found: ${input.collection}/${input.slug}` }
332
+ }
333
+
334
+ try {
335
+ if (isDataFile(sourcePath)) {
336
+ const raw = await deps.fs.readFile(sourcePath)
337
+ const existing = sourcePath.endsWith('.json') ? JSON.parse(raw) : yaml.parse(raw)
338
+ const merged = { ...(existing ?? {}), ...input.frontmatter }
339
+
340
+ const output = sourcePath.endsWith('.json')
341
+ ? JSON.stringify(merged, null, 2) + '\n'
342
+ : yaml.stringify(merged)
343
+ await deps.fs.writeFile(sourcePath, output)
344
+ } else {
345
+ const raw = await deps.fs.readFile(sourcePath)
346
+ const existing = parseFrontmatter(raw)
347
+
348
+ const mergedFrontmatter: Record<string, unknown> = {
349
+ ...existing.frontmatter,
350
+ ...input.frontmatter,
351
+ }
352
+
353
+ let finalContent = input.body ?? existing.content
354
+
355
+ if (sourcePath.endsWith('.mdx')) {
356
+ // Resolve component definitions internally (no manifest needed): scan the
357
+ // component directories so MDX imports can be injected for used components.
358
+ const componentDefinitions = await deps.resolveComponentDefinitions()
359
+ finalContent = ensureMdxImports(finalContent, sourcePath, componentDefinitions)
360
+ }
361
+
362
+ await deps.fs.writeFile(sourcePath, serializeFrontmatter(mergedFrontmatter, finalContent))
363
+ }
364
+
365
+ return { success: true, sourcePath }
366
+ } catch (error) {
367
+ return { success: false, error: errorMessage(error) }
368
+ }
369
+ }
370
+
371
+ export async function deleteEntry(deps: EntryOpsDeps, collection: string, slug: string): Promise<MutationResult> {
372
+ const sourcePath = await resolveEntryPath(deps, collection, slug)
373
+ if (!sourcePath) {
374
+ return { success: false, error: `Entry not found: ${collection}/${slug}` }
375
+ }
376
+
377
+ try {
378
+ await deps.fs.remove(sourcePath)
379
+ return { success: true, sourcePath }
380
+ } catch (error) {
381
+ return { success: false, error: errorMessage(error) }
382
+ }
383
+ }
384
+
385
+ export async function renameEntry(deps: EntryOpsDeps, collection: string, from: string, to: string): Promise<MutationResult> {
386
+ const sourcePath = await resolveEntryPath(deps, collection, from)
387
+ if (!sourcePath) {
388
+ return { success: false, error: `Entry not found: ${collection}/${from}` }
389
+ }
390
+
391
+ const normalizedSlug = slugify(to)
392
+ if (!normalizedSlug) {
393
+ return { success: false, error: 'Invalid slug' }
394
+ }
395
+
396
+ const lastSlash = sourcePath.lastIndexOf('/')
397
+ const dir = lastSlash >= 0 ? sourcePath.slice(0, lastSlash) : ''
398
+ const fileName = lastSlash >= 0 ? sourcePath.slice(lastSlash + 1) : sourcePath
399
+ const ext = fileExtension(fileName)
400
+ const newSourcePath = dir ? `${dir}/${normalizedSlug}.${ext}` : `${normalizedSlug}.${ext}`
401
+
402
+ if (sourcePath === newSourcePath) {
403
+ return { success: true, sourcePath: newSourcePath }
404
+ }
405
+
406
+ if (await deps.fs.exists(newSourcePath)) {
407
+ return { success: false, error: `File already exists: ${normalizedSlug}.${ext}` }
408
+ }
409
+
410
+ try {
411
+ await deps.fs.rename(sourcePath, newSourcePath)
412
+ return { success: true, sourcePath: newSourcePath }
413
+ } catch (error) {
414
+ return { success: false, error: errorMessage(error) }
415
+ }
416
+ }
417
+
418
+ // ============================================================================
419
+ // Entry-frontmatter array ops
420
+ // ============================================================================
421
+
422
+ export interface AddArrayItemInput {
423
+ collection: string
424
+ slug: string
425
+ field: string
426
+ value: unknown
427
+ index?: number
428
+ }
429
+
430
+ export interface RemoveArrayItemInput {
431
+ collection: string
432
+ slug: string
433
+ field: string
434
+ index: number
435
+ }
436
+
437
+ /**
438
+ * Read the entry's frontmatter/data object as a plain object, plus the markdown
439
+ * body (empty for data files). Returns the resolved source path so callers can
440
+ * write back through the same representation.
441
+ */
442
+ async function loadEntryFrontmatter(
443
+ deps: EntryOpsDeps,
444
+ collection: string,
445
+ slug: string,
446
+ ): Promise<{ sourcePath: string; frontmatter: Record<string, unknown>; body: string; data: boolean } | null> {
447
+ const sourcePath = await resolveEntryPath(deps, collection, slug)
448
+ if (!sourcePath) return null
449
+
450
+ const raw = await deps.fs.readFile(sourcePath)
451
+ if (isDataFile(sourcePath)) {
452
+ const parsed = sourcePath.endsWith('.json') ? JSON.parse(raw) : yaml.parse(raw)
453
+ return { sourcePath, frontmatter: (parsed && typeof parsed === 'object') ? parsed : {}, body: '', data: true }
454
+ }
455
+ const { frontmatter, content } = parseFrontmatter(raw)
456
+ return { sourcePath, frontmatter, body: content, data: false }
457
+ }
458
+
459
+ async function writeEntryFrontmatter(
460
+ deps: EntryOpsDeps,
461
+ loaded: { sourcePath: string; frontmatter: Record<string, unknown>; body: string; data: boolean },
462
+ ): Promise<void> {
463
+ if (loaded.data) {
464
+ const output = loaded.sourcePath.endsWith('.json')
465
+ ? JSON.stringify(loaded.frontmatter, null, 2) + '\n'
466
+ : yaml.stringify(loaded.frontmatter)
467
+ await deps.fs.writeFile(loaded.sourcePath, output)
468
+ return
469
+ }
470
+ await deps.fs.writeFile(loaded.sourcePath, serializeFrontmatter(loaded.frontmatter, loaded.body))
471
+ }
472
+
473
+ export async function addArrayItem(deps: EntryOpsDeps, input: AddArrayItemInput): Promise<MutationResult> {
474
+ const loaded = await loadEntryFrontmatter(deps, input.collection, input.slug)
475
+ if (!loaded) {
476
+ return { success: false, error: `Entry not found: ${input.collection}/${input.slug}` }
477
+ }
478
+
479
+ const current = loaded.frontmatter[input.field]
480
+ const array = Array.isArray(current) ? current.slice() : current === undefined ? [] : null
481
+ if (array === null) {
482
+ return { success: false, error: `Field "${input.field}" is not an array` }
483
+ }
484
+
485
+ const index = input.index ?? array.length
486
+ const clamped = Math.max(0, Math.min(index, array.length))
487
+ array.splice(clamped, 0, input.value)
488
+ loaded.frontmatter[input.field] = array
489
+
490
+ try {
491
+ await writeEntryFrontmatter(deps, loaded)
492
+ return { success: true, sourcePath: loaded.sourcePath }
493
+ } catch (error) {
494
+ return { success: false, error: errorMessage(error) }
495
+ }
496
+ }
497
+
498
+ export async function removeArrayItem(deps: EntryOpsDeps, input: RemoveArrayItemInput): Promise<MutationResult> {
499
+ const loaded = await loadEntryFrontmatter(deps, input.collection, input.slug)
500
+ if (!loaded) {
501
+ return { success: false, error: `Entry not found: ${input.collection}/${input.slug}` }
502
+ }
503
+
504
+ const current = loaded.frontmatter[input.field]
505
+ if (!Array.isArray(current)) {
506
+ return { success: false, error: `Field "${input.field}" is not an array` }
507
+ }
508
+ if (input.index < 0 || input.index >= current.length) {
509
+ return { success: false, error: `Index out of bounds: ${input.index}` }
510
+ }
511
+
512
+ const array = current.slice()
513
+ array.splice(input.index, 1)
514
+ loaded.frontmatter[input.field] = array
515
+
516
+ try {
517
+ await writeEntryFrontmatter(deps, loaded)
518
+ return { success: true, sourcePath: loaded.sourcePath }
519
+ } catch (error) {
520
+ return { success: false, error: errorMessage(error) }
521
+ }
522
+ }
523
+
524
+ /** Re-export for tests / parity consumers needing the collection's entry list. */
525
+ export async function listCollectionEntries(deps: EntryOpsDeps, collection: string): Promise<CollectionEntryInfo[]> {
526
+ const collections = await scanCollections(deps.fs, deps.contentDir, deps.parseCache)
527
+ return collections[collection]?.entries ?? []
528
+ }