@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,203 @@
1
+ import type { CreatePageRequest, DeletePageRequest, DuplicatePageRequest, LayoutInfo, PageOperationResponse } from '@nuasite/cms-types'
2
+ import type { CmsFileSystem } from '../fs/types'
3
+ import { escapeHtml, slugify } from '../shared'
4
+
5
+ const PAGE_EXTENSIONS = ['.astro', '.md', '.mdx']
6
+
7
+ export interface PageOpsDeps {
8
+ fs: CmsFileSystem
9
+ }
10
+
11
+ function errorMessage(error: unknown): string {
12
+ return error instanceof Error ? error.message : String(error)
13
+ }
14
+
15
+ export async function createPage(deps: PageOpsDeps, request: CreatePageRequest): Promise<PageOperationResponse> {
16
+ const { title, slug } = request
17
+ const normalizedSlug = slugify(slug || title)
18
+
19
+ if (!normalizedSlug) {
20
+ return { success: false, error: 'Could not generate a valid slug from the provided title/slug' }
21
+ }
22
+
23
+ const filePath = `src/pages/${normalizedSlug}.astro`
24
+
25
+ if (await deps.fs.exists(filePath)) {
26
+ return { success: false, error: `Page already exists: ${filePath}` }
27
+ }
28
+
29
+ const layoutImport = await resolveLayoutImport(deps, request.layoutPath)
30
+ const content = generatePageContent(title, layoutImport)
31
+
32
+ try {
33
+ await deps.fs.writeFile(filePath, content)
34
+ const url = normalizedSlug === 'index' ? '/' : `/${normalizedSlug}`
35
+ return { success: true, filePath, slug: normalizedSlug, url }
36
+ } catch (error) {
37
+ return { success: false, error: errorMessage(error) }
38
+ }
39
+ }
40
+
41
+ export async function duplicatePage(deps: PageOpsDeps, request: DuplicatePageRequest): Promise<PageOperationResponse> {
42
+ const { sourcePagePath, slug, title } = request
43
+ const normalizedSlug = slugify(slug)
44
+
45
+ if (!normalizedSlug) {
46
+ return { success: false, error: 'Could not generate a valid slug' }
47
+ }
48
+
49
+ const sourceFile = await findPageFile(deps, sourcePagePath)
50
+ if (!sourceFile) {
51
+ return { success: false, error: `Source page not found: ${sourcePagePath}` }
52
+ }
53
+
54
+ let content: string
55
+ try {
56
+ content = await deps.fs.readFile(sourceFile)
57
+ } catch {
58
+ return { success: false, error: `Could not read source file: ${sourceFile}` }
59
+ }
60
+
61
+ if (title) {
62
+ content = replacePageTitle(content, title)
63
+ }
64
+
65
+ const newFilePath = `src/pages/${normalizedSlug}.astro`
66
+
67
+ if (await deps.fs.exists(newFilePath)) {
68
+ return { success: false, error: `Page already exists: ${newFilePath}` }
69
+ }
70
+
71
+ try {
72
+ await deps.fs.writeFile(newFilePath, content)
73
+ const url = normalizedSlug === 'index' ? '/' : `/${normalizedSlug}`
74
+ return { success: true, filePath: newFilePath, slug: normalizedSlug, url }
75
+ } catch (error) {
76
+ return { success: false, error: errorMessage(error) }
77
+ }
78
+ }
79
+
80
+ export async function deletePage(deps: PageOpsDeps, request: DeletePageRequest): Promise<PageOperationResponse> {
81
+ const { pagePath } = request
82
+
83
+ const pageFile = await findPageFile(deps, pagePath)
84
+ if (!pageFile) {
85
+ return { success: false, error: `Page not found: ${pagePath}` }
86
+ }
87
+
88
+ try {
89
+ await deps.fs.remove(pageFile)
90
+ return { success: true, filePath: pageFile, url: pagePath }
91
+ } catch (error) {
92
+ return { success: false, error: errorMessage(error) }
93
+ }
94
+ }
95
+
96
+ export async function getLayouts(deps: PageOpsDeps): Promise<LayoutInfo[]> {
97
+ const entries = await deps.fs.list('src/layouts')
98
+
99
+ const layouts: LayoutInfo[] = []
100
+ for (const entry of entries) {
101
+ if (!entry.isDirectory && entry.name.endsWith('.astro')) {
102
+ layouts.push({
103
+ name: entry.name.slice(0, -'.astro'.length),
104
+ path: `src/layouts/${entry.name}`,
105
+ })
106
+ }
107
+ }
108
+
109
+ return layouts.sort((a, b) => a.name.localeCompare(b.name))
110
+ }
111
+
112
+ export async function checkSlugExists(deps: PageOpsDeps, slug: string): Promise<{ exists: boolean; filePath?: string }> {
113
+ const normalizedSlug = slugify(slug)
114
+ if (!normalizedSlug) return { exists: false }
115
+
116
+ const found = await findPageFile(deps, `/${normalizedSlug}`)
117
+ return found ? { exists: true, filePath: found } : { exists: false }
118
+ }
119
+
120
+ // --- Internal helpers ---
121
+
122
+ async function findPageFile(deps: PageOpsDeps, pagePath: string): Promise<string | null> {
123
+ const normalized = pagePath.replace(/^\//, '').replace(/\/$/, '') || 'index'
124
+
125
+ for (const ext of PAGE_EXTENSIONS) {
126
+ const direct = `src/pages/${normalized}${ext}`
127
+ if (await deps.fs.exists(direct)) return direct
128
+ }
129
+
130
+ for (const ext of PAGE_EXTENSIONS) {
131
+ const indexFile = `src/pages/${normalized}/index${ext}`
132
+ if (await deps.fs.exists(indexFile)) return indexFile
133
+ }
134
+
135
+ return null
136
+ }
137
+
138
+ async function resolveLayoutImport(deps: PageOpsDeps, layoutPath?: string): Promise<{ importPath: string; componentName: string } | null> {
139
+ if (layoutPath) {
140
+ const name = layoutPath.replace(/^.*\//, '').replace(/\.astro$/, '')
141
+ const importPath = `../${layoutPath.replace(/^src\//, '')}`
142
+ return { importPath, componentName: pascalCase(name) }
143
+ }
144
+
145
+ const layouts = await getLayouts(deps)
146
+ if (layouts.length === 0) return null
147
+
148
+ const layout = layouts[0]!
149
+ const importPath = `../${layout.path.replace(/^src\//, '')}`
150
+ return { importPath, componentName: pascalCase(layout.name) }
151
+ }
152
+
153
+ function pascalCase(name: string): string {
154
+ return name.replace(/(^|[-_])(\w)/g, (_, _sep, char) => char.toUpperCase())
155
+ }
156
+
157
+ function generatePageContent(
158
+ title: string,
159
+ layoutImport: { importPath: string; componentName: string } | null,
160
+ ): string {
161
+ const escapedTitle = title.replace(/'/g, "\\'").replace(/`/g, '\\`')
162
+ const htmlTitle = escapeHtml(title)
163
+
164
+ if (layoutImport) {
165
+ const { importPath, componentName } = layoutImport
166
+ return `---
167
+ import ${componentName} from '${importPath}'
168
+ ---
169
+
170
+ <${componentName} title="${escapedTitle}" description="">
171
+ \t<main>
172
+ \t\t<h1>${htmlTitle}</h1>
173
+ \t</main>
174
+ </${componentName}>
175
+ `
176
+ }
177
+
178
+ return `---
179
+
180
+ ---
181
+
182
+ <html lang="en">
183
+ \t<head>
184
+ \t\t<meta charset="utf-8" />
185
+ \t\t<meta name="viewport" content="width=device-width" />
186
+ \t\t<title>${escapedTitle}</title>
187
+ \t</head>
188
+ \t<body>
189
+ \t\t<main>
190
+ \t\t\t<h1>${htmlTitle}</h1>
191
+ \t\t</main>
192
+ \t</body>
193
+ </html>
194
+ `
195
+ }
196
+
197
+ function replacePageTitle(content: string, newTitle: string): string {
198
+ let result = content
199
+ result = result.replace(/(title\s*=\s*")([^"]*)(")/, `$1${newTitle}$3`)
200
+ result = result.replace(/(<title>)([^<]*)(<\/title>)/, `$1${newTitle}$3`)
201
+ result = result.replace(/(<h1[^>]*>)([^<]*)(<\/h1>)/, `$1${escapeHtml(newTitle)}$3`)
202
+ return result
203
+ }
@@ -0,0 +1,139 @@
1
+ import type {
2
+ AddRedirectRequest,
3
+ DeleteRedirectRequest,
4
+ GetRedirectsResponse,
5
+ RedirectOperationResponse,
6
+ RedirectRule,
7
+ UpdateRedirectRequest,
8
+ } from '@nuasite/cms-types'
9
+ import type { CmsFileSystem } from '../fs/types'
10
+
11
+ const DEFAULT_STATUS_CODE = 307
12
+ const REDIRECTS_FILE = 'src/_redirects'
13
+
14
+ export interface RedirectOpsDeps {
15
+ fs: CmsFileSystem
16
+ }
17
+
18
+ export async function listRedirects(deps: RedirectOpsDeps): Promise<GetRedirectsResponse> {
19
+ const lines = await readRedirectsFile(deps)
20
+ return { rules: parseRedirectLines(lines) }
21
+ }
22
+
23
+ export async function addRedirect(deps: RedirectOpsDeps, request: AddRedirectRequest): Promise<RedirectOperationResponse> {
24
+ const { source, destination, statusCode = DEFAULT_STATUS_CODE } = request
25
+
26
+ if (!source || !destination) {
27
+ return { success: false, error: 'Source and destination are required' }
28
+ }
29
+ if (!source.startsWith('/')) {
30
+ return { success: false, error: 'Source must start with /' }
31
+ }
32
+ if (!destination.startsWith('/') && !destination.startsWith('http')) {
33
+ return { success: false, error: 'Destination must start with / or http' }
34
+ }
35
+
36
+ const lines = await readRedirectsFile(deps)
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(deps, lines)
45
+ return { success: true }
46
+ }
47
+
48
+ export async function updateRedirect(deps: RedirectOpsDeps, request: UpdateRedirectRequest): Promise<RedirectOperationResponse> {
49
+ const { lineIndex, source, destination, statusCode = DEFAULT_STATUS_CODE } = request
50
+
51
+ if (!source || !destination) {
52
+ return { success: false, error: 'Source and destination are required' }
53
+ }
54
+
55
+ const lines = await readRedirectsFile(deps)
56
+
57
+ const currentLine = lines[lineIndex]?.trim()
58
+ if (!currentLine || currentLine.startsWith('#')) {
59
+ return { success: false, error: 'Line at index is no longer a redirect rule — please refresh and try again' }
60
+ }
61
+
62
+ lines[lineIndex] = formatRedirectLine(source, destination, statusCode)
63
+ await writeRedirectsFile(deps, lines)
64
+ return { success: true }
65
+ }
66
+
67
+ export async function deleteRedirect(deps: RedirectOpsDeps, request: DeleteRedirectRequest): Promise<RedirectOperationResponse> {
68
+ const { lineIndex } = request
69
+
70
+ const lines = await readRedirectsFile(deps)
71
+
72
+ if (lineIndex < 0 || lineIndex >= lines.length) {
73
+ return { success: false, error: `Invalid line index: ${lineIndex}` }
74
+ }
75
+
76
+ const line = lines[lineIndex]!.trim()
77
+ if (!line || line.startsWith('#')) {
78
+ return { success: false, error: 'Line is not a redirect rule' }
79
+ }
80
+
81
+ lines.splice(lineIndex, 1)
82
+ await writeRedirectsFile(deps, lines)
83
+ return { success: true }
84
+ }
85
+
86
+ // --- Internal helpers ---
87
+
88
+ function formatRedirectLine(source: string, destination: string, statusCode: number): string {
89
+ return statusCode === DEFAULT_STATUS_CODE
90
+ ? `${source} ${destination}`
91
+ : `${source} ${destination} ${statusCode}`
92
+ }
93
+
94
+ async function readRedirectsFile(deps: RedirectOpsDeps): Promise<string[]> {
95
+ if (!(await deps.fs.exists(REDIRECTS_FILE))) return []
96
+ const content = await deps.fs.readFile(REDIRECTS_FILE)
97
+ return content.split('\n')
98
+ }
99
+
100
+ async function writeRedirectsFile(deps: RedirectOpsDeps, lines: string[]): Promise<void> {
101
+ const trimmed = lines.slice()
102
+ while (trimmed.length > 0 && trimmed[trimmed.length - 1]!.trim() === '') {
103
+ trimmed.pop()
104
+ }
105
+
106
+ if (trimmed.length === 0) {
107
+ await deps.fs.writeFile(REDIRECTS_FILE, '')
108
+ return
109
+ }
110
+
111
+ await deps.fs.writeFile(REDIRECTS_FILE, trimmed.join('\n') + '\n')
112
+ }
113
+
114
+ function parseRedirectLines(lines: string[]): RedirectRule[] {
115
+ const rules: RedirectRule[] = []
116
+
117
+ for (let i = 0; i < lines.length; i++) {
118
+ const line = lines[i]!.trim()
119
+ if (!line || line.startsWith('#')) continue
120
+
121
+ const parts = line.split(/\s+/)
122
+ if (parts.length < 2) continue
123
+
124
+ const source = parts[0]!
125
+ if (!source.startsWith('/')) continue
126
+
127
+ const destination = parts[1]!
128
+ const statusCode = parts[2] ? parseInt(parts[2], 10) : DEFAULT_STATUS_CODE
129
+
130
+ rules.push({
131
+ source,
132
+ destination,
133
+ statusCode: Number.isNaN(statusCode) ? DEFAULT_STATUS_CODE : statusCode,
134
+ lineIndex: i,
135
+ })
136
+ }
137
+
138
+ return rules
139
+ }
package/src/index.ts ADDED
@@ -0,0 +1,41 @@
1
+ export { scanCollections } from './collection-scanner'
2
+ export { scanComponentDefinitions } from './component-registry'
3
+ export {
4
+ type ParseCache,
5
+ parseConfigSource,
6
+ parseContentConfig,
7
+ type ParsedCollection,
8
+ type ParsedConfig,
9
+ type ParsedField,
10
+ type ParsedReference,
11
+ } from './content-config-ast'
12
+ export { createCmsCore } from './core'
13
+ export type { CmsCore, CmsCoreOptions } from './core'
14
+ export { globToRegExp } from './fs/glob'
15
+ export { createNodeFs } from './fs/node-fs'
16
+ export type { CmsFileSystem } from './fs/types'
17
+ export {
18
+ type AddArrayItemInput,
19
+ type CreateEntryInput,
20
+ ensureMdxImports,
21
+ type EntryOpsDeps,
22
+ type GetEntryResult,
23
+ parseFrontmatter,
24
+ type RemoveArrayItemInput,
25
+ serializeFrontmatter,
26
+ type UpdateEntryInput,
27
+ } from './handlers/entry-ops'
28
+ export {
29
+ type ContemberStorageOptions,
30
+ createContemberStorageAdapter,
31
+ createLocalStorageAdapter,
32
+ createS3StorageAdapter,
33
+ getFileExtension,
34
+ listProjectImages,
35
+ type ListProjectImagesOptions,
36
+ type LocalStorageOptions,
37
+ MIME_BY_EXT,
38
+ mimeFromExt,
39
+ type S3StorageOptions,
40
+ } from './media/index'
41
+ export { escapeHtml, relativeImportPath, slugify, slugifyHref } from './shared'
@@ -0,0 +1,90 @@
1
+ import type { MediaStorageAdapter } from '@nuasite/cms-types'
2
+
3
+ export interface ContemberStorageOptions {
4
+ /** Base URL of the worker API, e.g. 'https://api.example.com' */
5
+ apiBaseUrl: string
6
+ /** Project slug used in the API path */
7
+ projectSlug: string
8
+ /** Session token for authentication (NUA_SITE_SESSION_TOKEN cookie value) */
9
+ sessionToken?: string
10
+ }
11
+
12
+ /**
13
+ * Media storage adapter that proxies to the Contember worker's CMS media endpoints.
14
+ * Uses the existing /cms/:projectSlug/media/* API backed by R2 storage + Contember database.
15
+ *
16
+ * All connection details (`apiBaseUrl`, `projectSlug`, `sessionToken`) are injected
17
+ * by the caller — the adapter never reads `process.env`.
18
+ */
19
+ export function createContemberStorageAdapter(options: ContemberStorageOptions): MediaStorageAdapter {
20
+ const { apiBaseUrl, projectSlug, sessionToken } = options
21
+ const base = `${apiBaseUrl.replace(/\/$/, '')}/cms/${projectSlug}/media`
22
+
23
+ function headers(): Record<string, string> {
24
+ const h: Record<string, string> = {}
25
+ if (sessionToken) {
26
+ h.Cookie = `NUA_SITE_SESSION_TOKEN=${sessionToken}`
27
+ }
28
+ return h
29
+ }
30
+
31
+ return {
32
+ async list(opts) {
33
+ const params = new URLSearchParams()
34
+ if (opts?.limit) params.set('limit', String(opts.limit))
35
+ if (opts?.cursor) params.set('cursor', opts.cursor)
36
+
37
+ const url = `${base}/list${params.toString() ? `?${params}` : ''}`
38
+ const res = await fetch(url, {
39
+ method: 'GET',
40
+ headers: headers(),
41
+ credentials: 'include',
42
+ })
43
+
44
+ if (!res.ok) {
45
+ throw new Error(`Failed to list media (${res.status}): ${await res.text()}`)
46
+ }
47
+
48
+ const data: unknown = await res.json()
49
+ const payload = (data && typeof data === 'object') ? data : {}
50
+ return { items: [], folders: [], hasMore: false, ...payload }
51
+ },
52
+
53
+ async upload(file, filename, contentType) {
54
+ const blob = new Blob([new Uint8Array(file)], { type: contentType })
55
+ const formData = new FormData()
56
+ formData.append('file', blob, filename)
57
+
58
+ const res = await fetch(`${base}/upload`, {
59
+ method: 'POST',
60
+ headers: headers(),
61
+ body: formData,
62
+ credentials: 'include',
63
+ })
64
+
65
+ if (!res.ok) {
66
+ const text = await res.text().catch(() => '')
67
+ return { success: false, error: `Upload failed (${res.status}): ${text}` }
68
+ }
69
+
70
+ const data: unknown = await res.json()
71
+ const payload = (data && typeof data === 'object') ? data : {}
72
+ return { success: true, ...payload }
73
+ },
74
+
75
+ async delete(id) {
76
+ const res = await fetch(`${base}/${encodeURIComponent(id)}`, {
77
+ method: 'DELETE',
78
+ headers: headers(),
79
+ credentials: 'include',
80
+ })
81
+
82
+ if (!res.ok) {
83
+ const text = await res.text().catch(() => '')
84
+ return { success: false, error: `Delete failed (${res.status}): ${text}` }
85
+ }
86
+
87
+ return { success: true }
88
+ },
89
+ }
90
+ }
@@ -0,0 +1,4 @@
1
+ export { type ContemberStorageOptions, createContemberStorageAdapter } from './contember'
2
+ export { createLocalStorageAdapter, getFileExtension, type LocalStorageOptions, MIME_BY_EXT, mimeFromExt } from './local'
3
+ export { listProjectImages, type ListProjectImagesOptions } from './project-images'
4
+ export { createS3StorageAdapter, type S3StorageOptions } from './s3'
@@ -0,0 +1,147 @@
1
+ import type { MediaFolderItem, MediaListResult, MediaStorageAdapter, MediaUploadResult } from '@nuasite/cms-types'
2
+ import { randomUUID } from 'node:crypto'
3
+ import fs from 'node:fs/promises'
4
+ import path from 'node:path'
5
+
6
+ export interface LocalStorageOptions {
7
+ /** Directory to store media files (relative to project root or absolute). Default: 'public/uploads' */
8
+ dir?: string
9
+ /** URL prefix for serving files. Default: '/uploads' */
10
+ urlPrefix?: string
11
+ }
12
+
13
+ export function createLocalStorageAdapter(options: LocalStorageOptions = {}): MediaStorageAdapter {
14
+ const dir = path.resolve(options.dir ?? 'public/uploads')
15
+ const urlPrefix = (options.urlPrefix ?? '/uploads').replace(/\/$/, '')
16
+
17
+ return {
18
+ staticFiles: { urlPrefix, dir },
19
+
20
+ async list(opts) {
21
+ const limit = opts?.limit ?? 50
22
+ const offset = opts?.cursor ? parseInt(opts.cursor, 10) : 0
23
+ const folder = opts?.folder ?? ''
24
+
25
+ const targetDir = folder ? path.join(dir, folder) : dir
26
+ await fs.mkdir(targetDir, { recursive: true })
27
+
28
+ const entries = await fs.readdir(targetDir, { withFileTypes: true })
29
+
30
+ const folders: MediaFolderItem[] = entries
31
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
32
+ .map((e) => ({
33
+ name: e.name,
34
+ path: folder ? `${folder}/${e.name}` : e.name,
35
+ }))
36
+ .sort((a, b) => a.name.localeCompare(b.name))
37
+
38
+ const files = entries.filter((e) => e.isFile() && !e.name.startsWith('.'))
39
+
40
+ const withStats = await Promise.all(
41
+ files.map(async (f) => {
42
+ const filePath = path.join(targetDir, f.name)
43
+ const stat = await fs.stat(filePath)
44
+ return { name: f.name, stat }
45
+ }),
46
+ )
47
+ withStats.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs)
48
+
49
+ const slice = withStats.slice(offset, offset + limit)
50
+ const hasMore = offset + limit < withStats.length
51
+
52
+ const urlFolder = folder ? `/${folder}` : ''
53
+ const items = slice.map((f) => {
54
+ const ext = path.extname(f.name).toLowerCase()
55
+ const contentType = mimeFromExt(ext)
56
+ return {
57
+ id: folder ? `${folder}/${f.name}` : f.name,
58
+ url: `${urlPrefix}${urlFolder}/${f.name}`,
59
+ filename: f.name,
60
+ contentType,
61
+ uploadedAt: f.stat.mtime.toISOString(),
62
+ folder: folder || undefined,
63
+ }
64
+ })
65
+
66
+ return {
67
+ items,
68
+ folders,
69
+ hasMore,
70
+ cursor: hasMore ? String(offset + limit) : undefined,
71
+ } satisfies MediaListResult
72
+ },
73
+
74
+ async upload(file, filename, contentType, uploadOpts) {
75
+ const folder = uploadOpts?.folder ?? ''
76
+ const targetDir = folder ? path.join(dir, folder) : dir
77
+ await fs.mkdir(targetDir, { recursive: true })
78
+
79
+ const ext = getFileExtension(filename)
80
+ const uuid = randomUUID()
81
+ const newFilename = `${uuid}${ext ? `.${ext}` : ''}`
82
+ const filePath = path.join(targetDir, newFilename)
83
+
84
+ await fs.writeFile(filePath, file)
85
+
86
+ const urlFolder = folder ? `/${folder}` : ''
87
+ const id = folder ? `${folder}/${newFilename}` : newFilename
88
+
89
+ return {
90
+ success: true,
91
+ url: `${urlPrefix}${urlFolder}/${newFilename}`,
92
+ filename: newFilename,
93
+ id,
94
+ } satisfies MediaUploadResult
95
+ },
96
+
97
+ async delete(id) {
98
+ const safePath = id.split('/').map((s) => path.basename(s)).join('/')
99
+ const filePath = path.join(dir, safePath)
100
+ try {
101
+ await fs.unlink(filePath)
102
+ return { success: true }
103
+ } catch (error) {
104
+ const message = error instanceof Error ? error.message : String(error)
105
+ return { success: false, error: message }
106
+ }
107
+ },
108
+
109
+ async createFolder(folder) {
110
+ const segments = folder.split('/').filter(Boolean)
111
+ if (segments.some((s) => s === '..' || s === '.')) {
112
+ return { success: false, error: 'Invalid folder name' }
113
+ }
114
+ try {
115
+ await fs.mkdir(path.join(dir, ...segments), { recursive: true })
116
+ return { success: true }
117
+ } catch (error) {
118
+ const message = error instanceof Error ? error.message : String(error)
119
+ return { success: false, error: message }
120
+ }
121
+ },
122
+ }
123
+ }
124
+
125
+ export function getFileExtension(filename: string): string {
126
+ const parts = filename.split('.')
127
+ const ext = parts.length > 1 ? (parts.pop()?.toLowerCase() ?? '') : ''
128
+ return /^[a-z0-9]+$/.test(ext) ? ext : ''
129
+ }
130
+
131
+ export const MIME_BY_EXT: Record<string, string> = {
132
+ '.jpg': 'image/jpeg',
133
+ '.jpeg': 'image/jpeg',
134
+ '.png': 'image/png',
135
+ '.gif': 'image/gif',
136
+ '.webp': 'image/webp',
137
+ '.avif': 'image/avif',
138
+ '.svg': 'image/svg+xml',
139
+ '.ico': 'image/x-icon',
140
+ '.mp4': 'video/mp4',
141
+ '.webm': 'video/webm',
142
+ '.pdf': 'application/pdf',
143
+ }
144
+
145
+ export function mimeFromExt(ext: string): string {
146
+ return MIME_BY_EXT[ext] ?? 'application/octet-stream'
147
+ }