@nuasite/cms 0.42.1 → 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.
@@ -1,474 +0,0 @@
1
- import type { Dirent } from 'node:fs'
2
- import fs from 'node:fs/promises'
3
- import path from 'node:path'
4
- import yaml from 'yaml'
5
- import { getProjectRoot } from '../config'
6
- import { parseContentConfig } from '../content-config-ast'
7
- import type { ComponentDefinition } from '../types'
8
- import { acquireFileLock, isNodeError, relativeImportPath, resolveAndValidatePath, slugify } from '../utils'
9
-
10
- export interface BlogFrontmatter {
11
- title: string
12
- date: string
13
- author?: string
14
- categories?: string[]
15
- excerpt?: string
16
- featuredImage?: string
17
- draft?: boolean
18
- [key: string]: unknown
19
- }
20
-
21
- export interface CreateMarkdownRequest {
22
- collection: string
23
- title: string
24
- slug: string
25
- frontmatter?: Partial<BlogFrontmatter>
26
- content?: string
27
- /** File extension override for data collections (e.g. 'json', 'yaml') */
28
- fileExtension?: string
29
- }
30
-
31
- export interface CreateMarkdownResponse {
32
- success: boolean
33
- filePath?: string
34
- slug?: string
35
- error?: string
36
- }
37
-
38
- export interface UpdateMarkdownRequest {
39
- filePath: string
40
- frontmatter?: Partial<BlogFrontmatter>
41
- content?: string
42
- }
43
-
44
- export interface UpdateMarkdownResponse {
45
- success: boolean
46
- error?: string
47
- }
48
-
49
- export interface DeleteMarkdownRequest {
50
- filePath: string
51
- }
52
-
53
- export interface DeleteMarkdownResponse {
54
- success: boolean
55
- error?: string
56
- }
57
-
58
- export interface GetMarkdownContentResponse {
59
- content: string
60
- frontmatter: BlogFrontmatter
61
- filePath: string
62
- }
63
-
64
- export async function handleGetMarkdownContent(
65
- filePath: string,
66
- ): Promise<GetMarkdownContentResponse | null> {
67
- try {
68
- const fullPath = resolveAndValidatePath(filePath)
69
- const raw = await fs.readFile(fullPath, 'utf-8')
70
-
71
- if (isDataFile(filePath)) {
72
- const data = filePath.endsWith('.json') ? JSON.parse(raw) : yaml.parse(raw)
73
- return {
74
- content: '',
75
- frontmatter: (data && typeof data === 'object' ? data : {}) as BlogFrontmatter,
76
- filePath,
77
- }
78
- }
79
-
80
- const { frontmatter, content } = parseFrontmatter(raw)
81
- return {
82
- content,
83
- frontmatter: frontmatter as BlogFrontmatter,
84
- filePath,
85
- }
86
- } catch {
87
- return null
88
- }
89
- }
90
-
91
- export async function handleUpdateMarkdown(
92
- request: UpdateMarkdownRequest,
93
- componentDefinitions?: Record<string, ComponentDefinition>,
94
- ): Promise<UpdateMarkdownResponse> {
95
- try {
96
- const fullPath = resolveAndValidatePath(request.filePath)
97
- const release = await acquireFileLock(fullPath)
98
- try {
99
- if (isDataFile(request.filePath)) {
100
- // Data collections: merge and write JSON/YAML directly
101
- const raw = await fs.readFile(fullPath, 'utf-8')
102
- const existing = request.filePath.endsWith('.json') ? JSON.parse(raw) : yaml.parse(raw)
103
- const merged = { ...(existing ?? {}), ...request.frontmatter }
104
-
105
- const output = request.filePath.endsWith('.json')
106
- ? JSON.stringify(merged, null, 2) + '\n'
107
- : yaml.stringify(merged)
108
- await fs.writeFile(fullPath, output, 'utf-8')
109
- } else {
110
- const raw = await fs.readFile(fullPath, 'utf-8')
111
- const existing = parseFrontmatter(raw)
112
-
113
- const mergedFrontmatter: BlogFrontmatter = {
114
- ...(existing.frontmatter as BlogFrontmatter),
115
- ...request.frontmatter,
116
- }
117
-
118
- let finalContent = request.content ?? existing.content
119
-
120
- if (request.filePath.endsWith('.mdx') && componentDefinitions) {
121
- finalContent = ensureMdxImports(finalContent, request.filePath, componentDefinitions)
122
- }
123
-
124
- const markdownContent = serializeFrontmatter(mergedFrontmatter, finalContent)
125
- await fs.writeFile(fullPath, markdownContent, 'utf-8')
126
- }
127
-
128
- return { success: true }
129
- } finally {
130
- release()
131
- }
132
- } catch (error) {
133
- const message = error instanceof Error ? error.message : String(error)
134
- return { success: false, error: message }
135
- }
136
- }
137
-
138
- export async function handleCreateMarkdown(
139
- request: CreateMarkdownRequest,
140
- ): Promise<CreateMarkdownResponse> {
141
- const { collection, title, slug, frontmatter = {}, content = '' } = request
142
-
143
- const normalizedSlug = slugify(slug || title)
144
- if (!normalizedSlug) {
145
- return { success: false, error: 'Could not generate a valid slug from the provided title/slug' }
146
- }
147
-
148
- const allowedExtensions = ['md', 'mdx', 'json', 'yaml', 'yml']
149
- const ext = request.fileExtension ?? 'md'
150
- if (!allowedExtensions.includes(ext)) {
151
- return { success: false, error: `Invalid file extension "${ext}". Allowed: ${allowedExtensions.join(', ')}` }
152
- }
153
- const isData = ext === 'json' || ext === 'yaml' || ext === 'yml'
154
- const layout = isData ? 'flat' : await detectCollectionMarkdownLayout(collection)
155
- const filePath = layout === 'index'
156
- ? `src/content/${collection}/${normalizedSlug}/index.${ext}`
157
- : `src/content/${collection}/${normalizedSlug}.${ext}`
158
- const fullPath = resolveAndValidatePath(filePath)
159
-
160
- let fileContent: string
161
- if (isData) {
162
- const data = { ...frontmatter }
163
- fileContent = ext === 'json'
164
- ? JSON.stringify(data, null, 2) + '\n'
165
- : yaml.stringify(data)
166
- } else {
167
- const fullFrontmatter: BlogFrontmatter = {
168
- title,
169
- date: new Date().toISOString().split('T')[0]!,
170
- ...frontmatter,
171
- }
172
- fileContent = serializeFrontmatter(fullFrontmatter, content)
173
- }
174
-
175
- try {
176
- await fs.mkdir(path.dirname(fullPath), { recursive: true })
177
- await fs.writeFile(fullPath, fileContent, { encoding: 'utf-8', flag: 'wx' })
178
-
179
- return {
180
- success: true,
181
- filePath,
182
- slug: normalizedSlug,
183
- }
184
- } catch (error) {
185
- if (error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'EEXIST') {
186
- return { success: false, error: `File already exists: ${filePath}` }
187
- }
188
- const message = error instanceof Error ? error.message : String(error)
189
- return { success: false, error: message }
190
- }
191
- }
192
-
193
- export async function handleDeleteMarkdown(
194
- request: DeleteMarkdownRequest,
195
- ): Promise<DeleteMarkdownResponse> {
196
- try {
197
- const fullPath = resolveAndValidatePath(request.filePath)
198
-
199
- // Verify the file exists before deleting
200
- await fs.access(fullPath)
201
- await fs.unlink(fullPath)
202
-
203
- return { success: true }
204
- } catch (error) {
205
- if (error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT') {
206
- return { success: false, error: `File not found: ${request.filePath}` }
207
- }
208
- const message = error instanceof Error ? error.message : String(error)
209
- return { success: false, error: message }
210
- }
211
- }
212
-
213
- export interface RenameMarkdownRequest {
214
- filePath: string
215
- newSlug: string
216
- }
217
-
218
- export interface RenameMarkdownResponse {
219
- success: boolean
220
- newFilePath?: string
221
- newSlug?: string
222
- error?: string
223
- }
224
-
225
- export async function handleRenameMarkdown(
226
- request: RenameMarkdownRequest,
227
- ): Promise<RenameMarkdownResponse> {
228
- try {
229
- const fullPath = resolveAndValidatePath(request.filePath)
230
- const normalizedSlug = slugify(request.newSlug)
231
- if (!normalizedSlug) {
232
- return { success: false, error: 'Invalid slug' }
233
- }
234
-
235
- const dir = path.dirname(fullPath)
236
- const ext = path.extname(fullPath)
237
- const newFullPath = path.join(dir, `${normalizedSlug}${ext}`)
238
-
239
- if (fullPath === newFullPath) {
240
- return { success: true, newFilePath: request.filePath, newSlug: normalizedSlug }
241
- }
242
-
243
- // Acquire lock to prevent concurrent access during rename
244
- const release = await acquireFileLock(fullPath)
245
- try {
246
- // Use link+unlink for atomic rename that fails if target exists
247
- try {
248
- await fs.link(fullPath, newFullPath)
249
- } catch (err) {
250
- if (isNodeError(err, 'EEXIST')) {
251
- return { success: false, error: `File already exists: ${normalizedSlug}${ext}` }
252
- }
253
- throw err
254
- }
255
- try {
256
- await fs.unlink(fullPath)
257
- } catch (err) {
258
- // Clean up the new file if unlink of original fails
259
- await fs.unlink(newFullPath).catch(() => {})
260
- throw err
261
- }
262
- } finally {
263
- release()
264
- }
265
-
266
- // Build project-relative path (normalize to forward slashes)
267
- const projectRoot = getProjectRoot()
268
- const newFilePath = path.relative(projectRoot, newFullPath).split(path.sep).join('/')
269
-
270
- return { success: true, newFilePath, newSlug: normalizedSlug }
271
- } catch (error) {
272
- const message = error instanceof Error ? error.message : String(error)
273
- return { success: false, error: message }
274
- }
275
- }
276
-
277
- // --- Internal helpers ---
278
-
279
- function isDataFile(filePath: string): boolean {
280
- return filePath.endsWith('.json') || filePath.endsWith('.yaml') || filePath.endsWith('.yml')
281
- }
282
-
283
- type MarkdownCollectionLayout = 'flat' | 'index'
284
-
285
- async function detectCollectionMarkdownLayout(collection: string): Promise<MarkdownCollectionLayout> {
286
- const existingLayout = await inferLayoutFromExistingEntries(collection)
287
- if (existingLayout) return existingLayout
288
-
289
- const configLayout = await inferLayoutFromContentConfig(collection)
290
- if (configLayout) return configLayout
291
-
292
- return 'flat'
293
- }
294
-
295
- async function inferLayoutFromExistingEntries(collection: string): Promise<MarkdownCollectionLayout | null> {
296
- const collectionPath = path.join(getProjectRoot(), 'src', 'content', collection)
297
-
298
- let dirEntries: Dirent[]
299
- try {
300
- dirEntries = await fs.readdir(collectionPath, { withFileTypes: true })
301
- } catch {
302
- return null
303
- }
304
-
305
- let flatCount = 0
306
- let indexCount = 0
307
- const flatSlugs = new Set<string>()
308
-
309
- for (const entry of dirEntries) {
310
- if (!entry.isFile()) continue
311
- const match = entry.name.match(/^(.+)\.(md|mdx)$/)
312
- if (!match) continue
313
- flatCount++
314
- flatSlugs.add(match[1]!)
315
- }
316
-
317
- const subdirs = dirEntries.filter(entry => entry.isDirectory() && !entry.name.startsWith('_') && !entry.name.startsWith('.'))
318
- const indexLookups = await Promise.all(subdirs.map(async dir => {
319
- if (flatSlugs.has(dir.name)) return false
320
- for (const ext of ['md', 'mdx'] as const) {
321
- try {
322
- await fs.access(path.join(collectionPath, dir.name, `index.${ext}`))
323
- return true
324
- } catch {
325
- // try next extension
326
- }
327
- }
328
- return false
329
- }))
330
- indexCount = indexLookups.filter(Boolean).length
331
-
332
- if (indexCount > flatCount) return 'index'
333
- if (flatCount > 0) return 'flat'
334
- return null
335
- }
336
-
337
- async function inferLayoutFromContentConfig(collection: string): Promise<MarkdownCollectionLayout | null> {
338
- try {
339
- const parsed = await parseContentConfig()
340
- const pattern = parsed.get(collection)?.loaderPattern
341
- if (!pattern) return null
342
- return isIndexStyleGlobPattern(pattern) ? 'index' : 'flat'
343
- } catch {
344
- return null
345
- }
346
- }
347
-
348
- function isIndexStyleGlobPattern(pattern: string): boolean {
349
- return pattern.includes('index.{') || pattern.includes('*/index') || pattern.includes('**/index')
350
- }
351
-
352
- function parseFrontmatter(raw: string): { frontmatter: Record<string, unknown>; content: string } {
353
- const trimmed = raw.trimStart()
354
- if (!trimmed.startsWith('---')) {
355
- return { frontmatter: {}, content: raw }
356
- }
357
-
358
- // Find closing --- on its own line (not inside YAML values)
359
- const lines = trimmed.split('\n')
360
- let endLineIndex = -1
361
- for (let i = 1; i < lines.length; i++) {
362
- if (lines[i]!.trimEnd() === '---') {
363
- endLineIndex = i
364
- break
365
- }
366
- }
367
- if (endLineIndex === -1) {
368
- return { frontmatter: {}, content: raw }
369
- }
370
-
371
- const yamlStr = lines.slice(1, endLineIndex).join('\n').trim()
372
- const content = lines.slice(endLineIndex + 1).join('\n').replace(/^\r?\n/, '')
373
-
374
- let frontmatter: Record<string, unknown> = {}
375
- try {
376
- frontmatter = (yaml.parse(yamlStr) as Record<string, unknown>) ?? {}
377
- } catch {
378
- // Invalid YAML, return empty frontmatter
379
- }
380
-
381
- return { frontmatter, content }
382
- }
383
-
384
- /** Pattern for strings that YAML auto-parses as Date objects */
385
- const YAML_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}/
386
-
387
- function serializeFrontmatter(frontmatter: Record<string, unknown>, content: string): string {
388
- const doc = new yaml.Document(frontmatter)
389
- // Force-quote strings that YAML would auto-parse as dates
390
- yaml.visit(doc, {
391
- Scalar(_key, node) {
392
- if (typeof node.value === 'string' && YAML_DATE_PATTERN.test(node.value)) {
393
- node.type = yaml.Scalar.QUOTE_SINGLE
394
- }
395
- },
396
- })
397
- const yamlStr = doc.toString().trim()
398
- return `---\n${yamlStr}\n---\n${content}`
399
- }
400
-
401
- /**
402
- * Ensure MDX content has import statements for all components used in the body.
403
- * Scans for `<ComponentName` tags, checks for existing imports, and prepends missing ones.
404
- */
405
- /** @internal Exported for testing */
406
- export function ensureMdxImports(
407
- content: string,
408
- filePath: string,
409
- componentDefinitions: Record<string, ComponentDefinition>,
410
- ): string {
411
- // Find all component-like tags (capitalized names)
412
- const usedComponents = new Set<string>()
413
- const tagRegex = /<([A-Z][A-Za-z0-9]*)\b/g
414
- let match
415
- while ((match = tagRegex.exec(content)) !== null) {
416
- if (match[1]) usedComponents.add(match[1])
417
- }
418
- if (usedComponents.size === 0) return content
419
-
420
- // Find already-imported names and track the last import position in a single pass
421
- const importedNames = new Set<string>()
422
- const importLineRegex = /^import\s+(.+)\s+from\s+/gm
423
- let lastImportEnd = -1
424
- while ((match = importLineRegex.exec(content)) !== null) {
425
- lastImportEnd = match.index + match[0].length
426
- // Advance past the `from '...'` portion to find the true line end
427
- const fromRest = content.slice(lastImportEnd)
428
- const lineEnd = fromRest.indexOf('\n')
429
- if (lineEnd >= 0) lastImportEnd += lineEnd
430
- else lastImportEnd = content.length
431
-
432
- const clause = match[1]!
433
- // Extract named imports from braces: { A, B as C }
434
- const braceMatch = clause.match(/\{([^}]+)\}/)
435
- if (braceMatch?.[1]) {
436
- for (const name of braceMatch[1].split(',')) {
437
- const parts = name.trim().split(/\s+as\s+/)
438
- const imported = (parts[1] ?? parts[0])?.trim()
439
- if (imported) importedNames.add(imported)
440
- }
441
- }
442
- // Extract default import and namespace import (* as X)
443
- const withoutBraces = clause.replace(/\{[^}]*\}/, '').replace(/,/g, ' ').trim()
444
- for (const token of withoutBraces.split(/\s+/)) {
445
- if (token === '*' || token === 'as' || token === '') continue
446
- importedNames.add(token)
447
- }
448
- }
449
-
450
- const root = getProjectRoot()
451
- const mdxFullPath = path.join(root, filePath)
452
- const missingImports: string[] = []
453
-
454
- for (const name of usedComponents) {
455
- if (importedNames.has(name)) continue
456
- const def = componentDefinitions[name]
457
- if (!def) continue
458
-
459
- const componentAbsPath = path.join(root, def.file)
460
- const rel = relativeImportPath(mdxFullPath, componentAbsPath)
461
- missingImports.push(`import ${name} from '${rel}'`)
462
- }
463
-
464
- if (missingImports.length === 0) return content
465
-
466
- // Place after any existing import block, or at the top
467
- const importBlock = missingImports.join('\n')
468
-
469
- if (lastImportEnd >= 0) {
470
- return content.slice(0, lastImportEnd) + '\n' + importBlock + content.slice(lastImportEnd)
471
- }
472
-
473
- return importBlock + '\n\n' + content
474
- }
@@ -1,163 +0,0 @@
1
- import fs from 'node:fs/promises'
2
- import path from 'node:path'
3
- import { getProjectRoot } from '../config'
4
- import type { AddRedirectRequest, DeleteRedirectRequest, RedirectOperationResponse, RedirectRule, UpdateRedirectRequest } from '../types'
5
- import { acquireFileLock, isNodeError } from '../utils'
6
-
7
- const DEFAULT_STATUS_CODE = 307
8
- const REDIRECTS_FILE = 'src/_redirects'
9
-
10
- function getRedirectsFilePath(): string {
11
- return path.join(getProjectRoot(), REDIRECTS_FILE)
12
- }
13
-
14
- export async function handleGetRedirects(): Promise<{ rules: RedirectRule[] }> {
15
- const lines = await readRedirectsFile(getRedirectsFilePath())
16
- return { rules: parseRedirectLines(lines) }
17
- }
18
-
19
- export async function handleAddRedirect(request: AddRedirectRequest): Promise<RedirectOperationResponse> {
20
- const { source, destination, statusCode = DEFAULT_STATUS_CODE } = request
21
-
22
- if (!source || !destination) {
23
- return { success: false, error: 'Source and destination are required' }
24
- }
25
- if (!source.startsWith('/')) {
26
- return { success: false, error: 'Source must start with /' }
27
- }
28
- if (!destination.startsWith('/') && !destination.startsWith('http')) {
29
- return { success: false, error: 'Destination must start with / or http' }
30
- }
31
-
32
- const filePath = getRedirectsFilePath()
33
- const release = await acquireFileLock(filePath)
34
-
35
- try {
36
- const lines = await readRedirectsFile(filePath)
37
- const existing = parseRedirectLines(lines)
38
-
39
- if (existing.some(r => r.source === source)) {
40
- return { success: false, error: `Redirect already exists for ${source}` }
41
- }
42
-
43
- lines.push(formatRedirectLine(source, destination, statusCode))
44
- await writeRedirectsFile(filePath, lines)
45
- return { success: true }
46
- } finally {
47
- release()
48
- }
49
- }
50
-
51
- export async function handleUpdateRedirect(request: UpdateRedirectRequest): Promise<RedirectOperationResponse> {
52
- const { lineIndex, source, destination, statusCode = DEFAULT_STATUS_CODE } = request
53
-
54
- if (!source || !destination) {
55
- return { success: false, error: 'Source and destination are required' }
56
- }
57
-
58
- const filePath = getRedirectsFilePath()
59
- const release = await acquireFileLock(filePath)
60
-
61
- try {
62
- const lines = await readRedirectsFile(filePath)
63
-
64
- // Guard against stale line index
65
- const currentLine = lines[lineIndex]?.trim()
66
- if (!currentLine || currentLine.startsWith('#')) {
67
- return { success: false, error: 'Line at index is no longer a redirect rule — please refresh and try again' }
68
- }
69
-
70
- lines[lineIndex] = formatRedirectLine(source, destination, statusCode)
71
- await writeRedirectsFile(filePath, lines)
72
- return { success: true }
73
- } finally {
74
- release()
75
- }
76
- }
77
-
78
- export async function handleDeleteRedirect(request: DeleteRedirectRequest): Promise<RedirectOperationResponse> {
79
- const { lineIndex } = request
80
-
81
- const filePath = getRedirectsFilePath()
82
- const release = await acquireFileLock(filePath)
83
-
84
- try {
85
- const lines = await readRedirectsFile(filePath)
86
-
87
- if (lineIndex < 0 || lineIndex >= lines.length) {
88
- return { success: false, error: `Invalid line index: ${lineIndex}` }
89
- }
90
-
91
- const line = lines[lineIndex]!.trim()
92
- if (!line || line.startsWith('#')) {
93
- return { success: false, error: 'Line is not a redirect rule' }
94
- }
95
-
96
- lines.splice(lineIndex, 1)
97
- await writeRedirectsFile(filePath, lines)
98
- return { success: true }
99
- } finally {
100
- release()
101
- }
102
- }
103
-
104
- // --- Internal helpers ---
105
-
106
- function formatRedirectLine(source: string, destination: string, statusCode: number): string {
107
- return statusCode === DEFAULT_STATUS_CODE
108
- ? `${source} ${destination}`
109
- : `${source} ${destination} ${statusCode}`
110
- }
111
-
112
- async function readRedirectsFile(filePath: string): Promise<string[]> {
113
- try {
114
- const content = await fs.readFile(filePath, 'utf-8')
115
- return content.split('\n')
116
- } catch (error) {
117
- if (isNodeError(error, 'ENOENT')) return []
118
- throw error
119
- }
120
- }
121
-
122
- async function writeRedirectsFile(filePath: string, lines: string[]): Promise<void> {
123
- await fs.mkdir(path.dirname(filePath), { recursive: true })
124
-
125
- const trimmed = lines.slice()
126
- while (trimmed.length > 0 && trimmed[trimmed.length - 1]!.trim() === '') {
127
- trimmed.pop()
128
- }
129
-
130
- if (trimmed.length === 0) {
131
- await fs.writeFile(filePath, '', 'utf-8')
132
- return
133
- }
134
-
135
- await fs.writeFile(filePath, trimmed.join('\n') + '\n', 'utf-8')
136
- }
137
-
138
- function parseRedirectLines(lines: string[]): RedirectRule[] {
139
- const rules: RedirectRule[] = []
140
-
141
- for (let i = 0; i < lines.length; i++) {
142
- const line = lines[i]!.trim()
143
- if (!line || line.startsWith('#')) continue
144
-
145
- const parts = line.split(/\s+/)
146
- if (parts.length < 2) continue
147
-
148
- const source = parts[0]!
149
- if (!source.startsWith('/')) continue
150
-
151
- const destination = parts[1]!
152
- const statusCode = parts[2] ? parseInt(parts[2], 10) : DEFAULT_STATUS_CODE
153
-
154
- rules.push({
155
- source,
156
- destination,
157
- statusCode: Number.isNaN(statusCode) ? DEFAULT_STATUS_CODE : statusCode,
158
- lineIndex: i,
159
- })
160
- }
161
-
162
- return rules
163
- }