@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,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
|
+
}
|