@rimelight/cms 0.0.2 → 0.0.4

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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/dist/bylines-Csggkl-g.d.mts +405 -0
  3. package/dist/index.d.mts +724 -0
  4. package/dist/index.mjs +2382 -0
  5. package/dist/integration.d.mts +33 -0
  6. package/dist/integration.mjs +161 -0
  7. package/dist/schema/index.d.mts +2063 -0
  8. package/dist/schema/index.mjs +176 -0
  9. package/dist/schema/sqlite.d.mts +2132 -0
  10. package/dist/schema/sqlite.mjs +137 -0
  11. package/dist/storage/index.d.mts +59 -0
  12. package/dist/storage/index.mjs +133 -0
  13. package/package.json +36 -18
  14. package/src/admin/api/media-file.ts +39 -0
  15. package/src/admin/api/media.ts +224 -0
  16. package/src/admin/layouts/CMSDashboardLayout.astro +191 -0
  17. package/src/admin/layouts/DefaultParentLayout.astro +50 -0
  18. package/src/admin/pages/assets.astro +424 -0
  19. package/src/admin/pages/byline-schema.astro +62 -0
  20. package/src/admin/pages/bylines.astro +16 -0
  21. package/src/admin/pages/categories.astro +21 -0
  22. package/src/admin/pages/content-types.astro +55 -0
  23. package/src/admin/pages/dashboard.astro +61 -0
  24. package/src/admin/pages/pages-edit.astro +595 -0
  25. package/src/admin/pages/pages-index.astro +146 -0
  26. package/src/admin/pages/pages-new.astro +130 -0
  27. package/src/admin/pages/pages-preview.astro +111 -0
  28. package/src/admin/pages/pages-review.astro +236 -0
  29. package/src/admin/pages/settings.astro +203 -0
  30. package/src/admin/pages/tags.astro +21 -0
  31. package/src/admin/pages/templates-edit.astro +234 -0
  32. package/src/admin/pages/templates-index.astro +95 -0
  33. package/src/admin/pages/templates-new.astro +193 -0
  34. package/src/admin/pages/users.astro +74 -0
  35. package/src/admin/pages/versions.astro +74 -0
  36. package/src/astro/BlockRenderer.astro +44 -102
  37. package/src/astro/PageRenderer.astro +12 -5
  38. package/src/astro/blocks/CalloutBlock.astro +7 -7
  39. package/src/astro/blocks/CardBlock.astro +5 -5
  40. package/src/astro/blocks/CodeBlock.astro +2 -2
  41. package/src/astro/blocks/FileTreeBlock.astro +7 -7
  42. package/src/astro/blocks/ImageBlock.astro +2 -2
  43. package/src/astro/blocks/OrderedListBlock.astro +2 -2
  44. package/src/astro/blocks/ParagraphBlock.astro +3 -3
  45. package/src/astro/blocks/SectionBlock.astro +6 -6
  46. package/src/astro/blocks/StepItemBlock.astro +3 -3
  47. package/src/astro/blocks/StepsBlock.astro +1 -1
  48. package/src/astro/blocks/TableBlock.astro +6 -6
  49. package/src/astro/blocks/TabsBlock.astro +9 -9
  50. package/src/astro/blocks/UnorderedListBlock.astro +2 -2
  51. package/src/astro/blocks/index.ts +35 -0
  52. package/src/client/components/RimelightBlock.tsx +19 -19
  53. package/src/client/components/RimelightBlockPicker.tsx +9 -13
  54. package/src/client/components/RimelightBylinesManager.tsx +438 -0
  55. package/src/client/components/RimelightEditor.tsx +3 -3
  56. package/src/client/components/RimelightPreview.tsx +37 -45
  57. package/src/client/components/RimelightPropertiesPanel.tsx +368 -26
  58. package/src/client/components/RimelightTaxonomyManager.tsx +369 -0
  59. package/src/client/components/RimelightTaxonomyMasterDetail.tsx +412 -0
  60. package/src/client/components/RimelightTemplateEditor.tsx +6 -8
  61. package/src/client/components/editors/CalloutEditor.tsx +2 -4
  62. package/src/client/components/editors/CardsEditor.tsx +2 -4
  63. package/src/client/components/editors/CodeEditor.tsx +1 -3
  64. package/src/client/components/editors/FileTreeEditor.tsx +2 -4
  65. package/src/client/components/editors/ImageEditor.tsx +3 -3
  66. package/src/client/components/editors/ParagraphEditor.tsx +2 -2
  67. package/src/client/components/editors/SceneEditor.tsx +6 -12
  68. package/src/client/components/editors/ScriptEditor.tsx +8 -16
  69. package/src/client/components/editors/SectionEditor.tsx +4 -4
  70. package/src/client/components/editors/StepItemEditor.tsx +2 -2
  71. package/src/client/components/editors/StepsEditor.tsx +2 -4
  72. package/src/client/components/editors/TabItemEditor.tsx +3 -3
  73. package/src/client/components/editors/TableEditor.tsx +5 -5
  74. package/src/client/components/editors/TabsEditor.tsx +2 -4
  75. package/src/client/components/index.ts +3 -0
  76. package/src/client/loader.ts +3 -0
  77. package/src/core/diff.ts +145 -0
  78. package/src/core/excerpt.ts +159 -0
  79. package/src/core/preview.ts +87 -0
  80. package/src/core/registry.ts +0 -86
  81. package/src/core/types/blocks.ts +6 -1
  82. package/src/core/types/bylines.ts +14 -0
  83. package/src/core/types/media.ts +13 -0
  84. package/src/core/types/taxonomies.ts +20 -0
  85. package/src/cron/scheduled.ts +57 -0
  86. package/src/docs/agent.ts +116 -0
  87. package/src/docs/config.ts +25 -0
  88. package/src/docs/index.ts +5 -0
  89. package/src/docs/routing.ts +104 -0
  90. package/src/docs/sidebar.ts +251 -0
  91. package/src/docs/toc.ts +47 -0
  92. package/src/env.d.ts +19 -0
  93. package/src/index.ts +57 -12
  94. package/src/integration.ts +250 -0
  95. package/src/loader/live.ts +254 -0
  96. package/src/markdown/index.ts +0 -1
  97. package/src/mcp/index.ts +228 -0
  98. package/src/schema/bylines.ts +23 -0
  99. package/src/schema/index.ts +3 -4
  100. package/src/schema/page_versions.ts +2 -0
  101. package/src/schema/pages.ts +2 -0
  102. package/src/schema/site_settings.ts +41 -0
  103. package/src/schema/sqlite.ts +344 -0
  104. package/src/schema/taxonomies.ts +49 -0
  105. package/src/services/bylines.ts +74 -0
  106. package/src/services/search-indexer.ts +61 -0
  107. package/src/services/site-settings.ts +108 -0
  108. package/src/services/taxonomies.ts +276 -0
  109. package/src/storage/index.ts +256 -0
  110. package/src/astro/blocks/ApiDocumentationBlock.astro +0 -350
  111. package/src/astro/blocks/DialogueBlock.astro +0 -23
  112. package/src/astro/blocks/LivePreviewBlock.astro +0 -27
  113. package/src/astro/blocks/SceneBlock.astro +0 -65
  114. package/src/astro/blocks/ScriptBlock.astro +0 -59
  115. package/src/core/template-sync.ts +0 -75
  116. package/src/markdown/parser.ts +0 -347
  117. package/src/migration-tools.ts +0 -334
  118. package/src/migration.ts +0 -155
  119. package/src/schema/component_dependencies.ts +0 -21
  120. package/src/schema/component_metadata.ts +0 -31
  121. package/src/schema/component_metadata_cache.ts +0 -15
  122. package/src/schema/component_versions.ts +0 -19
  123. package/src/search/sync.ts +0 -32
  124. package/src/services/component-metadata.ts +0 -134
@@ -0,0 +1,276 @@
1
+ import { taxonomyTerms, pageTaxonomyTerms } from "../schema/taxonomies"
2
+ import { pages } from "../schema/pages"
3
+ import type { TaxonomyTerm, TaxonomyQueryOptions } from "../core/types/taxonomies"
4
+ import { eq, and, isNull, asc, sql } from "drizzle-orm"
5
+
6
+ /**
7
+ * Retrieves all terms for a given taxonomy. If hierarchical, returns a tree structure with
8
+ * `children: TaxonomyTerm[]`.
9
+ */
10
+ export async function getTaxonomyTerms(
11
+ db: any,
12
+ taxonomy: string,
13
+ options: TaxonomyQueryOptions = {}
14
+ ): Promise<TaxonomyTerm[]> {
15
+ if (!db) return []
16
+
17
+ const includeCounts = options.includeCounts ?? true
18
+
19
+ const rawTerms = await db
20
+ .select()
21
+ .from(taxonomyTerms)
22
+ .where(eq(taxonomyTerms.taxonomy, taxonomy))
23
+ .orderBy(asc(taxonomyTerms.displayOrder), asc(taxonomyTerms.createdAt))
24
+
25
+ if (!rawTerms || rawTerms.length === 0) return []
26
+
27
+ // Count active published posts per term if requested
28
+ const countsMap = new Map<string, number>()
29
+ if (includeCounts) {
30
+ try {
31
+ const counts = await db
32
+ .select({
33
+ termId: pageTaxonomyTerms.termId,
34
+ count: sql<number>`count(${pageTaxonomyTerms.pageId})::int`
35
+ })
36
+ .from(pageTaxonomyTerms)
37
+ .innerJoin(pages, eq(pages.id, pageTaxonomyTerms.pageId))
38
+ .where(isNull(pages.deletedAt))
39
+ .groupBy(pageTaxonomyTerms.termId)
40
+
41
+ for (const row of counts) {
42
+ countsMap.set(row.termId, Number(row.count))
43
+ }
44
+ } catch {
45
+ // Fallback if joined counts query fails
46
+ }
47
+ }
48
+
49
+ const termsWithCounts: TaxonomyTerm[] = rawTerms.map((t: any) => ({
50
+ id: t.id,
51
+ taxonomy: t.taxonomy,
52
+ slug: t.slug,
53
+ label: t.label,
54
+ description: t.description,
55
+ parentId: t.parentId,
56
+ displayOrder: t.displayOrder,
57
+ count: countsMap.get(t.id) ?? 0,
58
+ children: [],
59
+ createdAt: t.createdAt,
60
+ updatedAt: t.updatedAt
61
+ }))
62
+
63
+ // Determine if this taxonomy is hierarchical (any term has parentId)
64
+ const isHierarchical = termsWithCounts.some((t) => !!t.parentId)
65
+ if (!isHierarchical) {
66
+ return termsWithCounts
67
+ }
68
+
69
+ // Build tree
70
+ const idMap = new Map<string, TaxonomyTerm>()
71
+ const rootTerms: TaxonomyTerm[] = []
72
+
73
+ termsWithCounts.forEach((term) => {
74
+ idMap.set(term.id, term)
75
+ })
76
+
77
+ termsWithCounts.forEach((term) => {
78
+ if (term.parentId && idMap.has(term.parentId)) {
79
+ const parent = idMap.get(term.parentId)!
80
+ parent.children = parent.children || []
81
+ parent.children.push(term)
82
+ } else {
83
+ rootTerms.push(term)
84
+ }
85
+ })
86
+
87
+ return rootTerms
88
+ }
89
+
90
+ /**
91
+ * Retrieves a single term by taxonomy and slug.
92
+ */
93
+ export async function getTerm(
94
+ db: any,
95
+ taxonomy: string,
96
+ slug: string
97
+ ): Promise<TaxonomyTerm | null> {
98
+ if (!db) return null
99
+
100
+ const results = await db
101
+ .select()
102
+ .from(taxonomyTerms)
103
+ .where(and(eq(taxonomyTerms.taxonomy, taxonomy), eq(taxonomyTerms.slug, slug)))
104
+ .limit(1)
105
+
106
+ if (!results[0]) return null
107
+
108
+ // Get post count
109
+ let count = 0
110
+ try {
111
+ const countResult = await db
112
+ .select({ count: sql<number>`count(${pageTaxonomyTerms.pageId})::int` })
113
+ .from(pageTaxonomyTerms)
114
+ .innerJoin(pages, eq(pages.id, pageTaxonomyTerms.pageId))
115
+ .where(and(eq(pageTaxonomyTerms.termId, results[0].id), isNull(pages.deletedAt)))
116
+ count = Number(countResult[0]?.count ?? 0)
117
+ } catch {
118
+ count = 0
119
+ }
120
+
121
+ return {
122
+ ...results[0],
123
+ count,
124
+ children: []
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Retrieves all terms assigned to a specific page entry.
130
+ */
131
+ export async function getEntryTerms(
132
+ db: any,
133
+ pageId: string,
134
+ taxonomy?: string
135
+ ): Promise<TaxonomyTerm[]> {
136
+ if (!db || !pageId) return []
137
+
138
+ const conditions = [eq(pageTaxonomyTerms.pageId, pageId)]
139
+ if (taxonomy) {
140
+ conditions.push(eq(taxonomyTerms.taxonomy, taxonomy))
141
+ }
142
+
143
+ const results = await db
144
+ .select({
145
+ id: taxonomyTerms.id,
146
+ taxonomy: taxonomyTerms.taxonomy,
147
+ slug: taxonomyTerms.slug,
148
+ label: taxonomyTerms.label,
149
+ description: taxonomyTerms.description,
150
+ parentId: taxonomyTerms.parentId,
151
+ displayOrder: taxonomyTerms.displayOrder
152
+ })
153
+ .from(pageTaxonomyTerms)
154
+ .innerJoin(taxonomyTerms, eq(taxonomyTerms.id, pageTaxonomyTerms.termId))
155
+ .where(and(...conditions))
156
+ .orderBy(asc(taxonomyTerms.displayOrder))
157
+
158
+ return results
159
+ }
160
+
161
+ /**
162
+ * Retrieves pages tagged with a given taxonomy term.
163
+ */
164
+ export async function getEntriesByTerm(
165
+ db: any,
166
+ taxonomy: string,
167
+ slug: string,
168
+ options: { limit?: number; offset?: number; type?: string } = {}
169
+ ) {
170
+ if (!db) return { entries: [], total: 0 }
171
+
172
+ const term = await getTerm(db, taxonomy, slug)
173
+ if (!term) return { entries: [], total: 0 }
174
+
175
+ const conditions: any[] = [eq(pageTaxonomyTerms.termId, term.id), isNull(pages.deletedAt)]
176
+
177
+ if (options.type) {
178
+ conditions.push(eq(pages.type, options.type))
179
+ }
180
+
181
+ let query = db
182
+ .select({ page: pages })
183
+ .from(pageTaxonomyTerms)
184
+ .innerJoin(pages, eq(pages.id, pageTaxonomyTerms.pageId))
185
+ .where(and(...conditions))
186
+ .orderBy(asc(pages.createdAt))
187
+
188
+ if (options.limit) query = query.limit(options.limit)
189
+ if (options.offset) query = query.offset(options.offset)
190
+
191
+ const rows = await query
192
+ const entries = rows.map((r: any) => r.page)
193
+
194
+ return { entries, total: term.count ?? entries.length, term }
195
+ }
196
+
197
+ /**
198
+ * Sets/syncs the assigned terms for a page within a specific taxonomy.
199
+ */
200
+ export async function assignEntryTerms(
201
+ db: any,
202
+ pageId: string,
203
+ taxonomy: string,
204
+ termIds: string[]
205
+ ) {
206
+ if (!db || !pageId) return
207
+
208
+ // Find all current terms in this taxonomy assigned to the page
209
+ const currentAssigned = await getEntryTerms(db, pageId, taxonomy)
210
+ const currentTermIds = new Set(currentAssigned.map((t) => t.id))
211
+ const newTermIds = new Set(termIds)
212
+
213
+ // Remove unassigned
214
+ for (const term of currentAssigned) {
215
+ if (!newTermIds.has(term.id)) {
216
+ await db
217
+ .delete(pageTaxonomyTerms)
218
+ .where(and(eq(pageTaxonomyTerms.pageId, pageId), eq(pageTaxonomyTerms.termId, term.id)))
219
+ }
220
+ }
221
+
222
+ // Insert newly assigned
223
+ for (const termId of termIds) {
224
+ if (!currentTermIds.has(termId)) {
225
+ await db.insert(pageTaxonomyTerms).values({ pageId, termId }).onConflictDoNothing()
226
+ }
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Creates a new taxonomy term.
232
+ */
233
+ export async function createTerm(
234
+ db: any,
235
+ data: {
236
+ taxonomy: string
237
+ slug: string
238
+ label: any
239
+ description?: any
240
+ parentId?: string | null
241
+ displayOrder?: number
242
+ }
243
+ ) {
244
+ const result = await db.insert(taxonomyTerms).values(data).returning()
245
+ return result[0]
246
+ }
247
+
248
+ /**
249
+ * Updates an existing taxonomy term.
250
+ */
251
+ export async function updateTerm(
252
+ db: any,
253
+ id: string,
254
+ updates: Partial<{
255
+ slug: string
256
+ label: any
257
+ description: any
258
+ parentId: string | null
259
+ displayOrder: number
260
+ }>
261
+ ) {
262
+ const result = await db
263
+ .update(taxonomyTerms)
264
+ .set({ ...updates, updatedAt: new Date() })
265
+ .where(eq(taxonomyTerms.id, id))
266
+ .returning()
267
+ return result[0]
268
+ }
269
+
270
+ /**
271
+ * Deletes a taxonomy term.
272
+ */
273
+ export async function deleteTerm(db: any, id: string) {
274
+ await db.delete(taxonomyTerms).where(eq(taxonomyTerms.id, id))
275
+ return { success: true }
276
+ }
@@ -0,0 +1,256 @@
1
+ export interface StorageFile {
2
+ key: string
3
+ name: string
4
+ size: number
5
+ mimeType: string
6
+ uploadedAt: string
7
+ url: string
8
+ etag?: string
9
+ customMetadata?: Record<string, string>
10
+ }
11
+
12
+ export interface StorageListResult {
13
+ files: StorageFile[]
14
+ truncated: boolean
15
+ cursor?: string
16
+ }
17
+
18
+ export interface R2StorageOptions {
19
+ binding?: string | undefined
20
+ publicUrl?: string | undefined
21
+ prefix?: string | undefined
22
+ }
23
+
24
+ export interface StorageConfig {
25
+ provider: "r2" | "local" | "s3"
26
+ binding?: string | undefined
27
+ publicUrl?: string | undefined
28
+ prefix?: string | undefined
29
+ }
30
+
31
+ export interface StorageListOptions {
32
+ prefix?: string | undefined
33
+ cursor?: string | undefined
34
+ limit?: number | undefined
35
+ publicUrl?: string | undefined
36
+ apiBasePath?: string | undefined
37
+ }
38
+
39
+ export interface StorageUploadOptions {
40
+ key?: string | undefined
41
+ prefix?: string | undefined
42
+ publicUrl?: string | undefined
43
+ apiBasePath?: string | undefined
44
+ }
45
+
46
+ export function r2(options: R2StorageOptions = {}): StorageConfig {
47
+ return {
48
+ provider: "r2",
49
+ binding: options.binding ?? "BLOB",
50
+ publicUrl: options.publicUrl,
51
+ prefix: options.prefix ?? "media/"
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Resolves Cloudflare R2 bucket instance from Cloudflare env or Astro locals.
57
+ */
58
+ export async function getR2Bucket(locals?: any, bindingName = "BLOB"): Promise<any> {
59
+ // 1. Try cloudflare:workers standard export (Astro v6+)
60
+ try {
61
+ // @ts-ignore
62
+ const cf = await import("cloudflare:workers")
63
+ if (cf?.env && cf.env[bindingName]) {
64
+ return cf.env[bindingName]
65
+ }
66
+ } catch {}
67
+
68
+ // 2. Direct locals property check (locals.runtime.env, locals.env or locals[bindingName])
69
+ if (locals) {
70
+ if (locals.runtime?.env && locals.runtime.env[bindingName]) {
71
+ return locals.runtime.env[bindingName]
72
+ }
73
+ if (locals.env && locals.env[bindingName]) {
74
+ return locals.env[bindingName]
75
+ }
76
+ if (locals[bindingName]) {
77
+ return locals[bindingName]
78
+ }
79
+ }
80
+
81
+ // 3. Global process.env fallback
82
+ if (typeof process !== "undefined" && process.env && (process.env as any)[bindingName]) {
83
+ return (process.env as any)[bindingName]
84
+ }
85
+
86
+ return null
87
+ }
88
+
89
+ /**
90
+ * Lists files from R2 Bucket under configured prefix.
91
+ */
92
+ export async function listR2Files(
93
+ bucket: any,
94
+ options: StorageListOptions = {}
95
+ ): Promise<StorageListResult> {
96
+ if (!bucket || typeof bucket.list !== "function") {
97
+ return { files: [], truncated: false }
98
+ }
99
+
100
+ const prefix = options.prefix ?? "media/"
101
+ const limit = options.limit ?? 50
102
+
103
+ const result = await bucket.list({
104
+ prefix,
105
+ cursor: options.cursor,
106
+ limit
107
+ })
108
+
109
+ const files: StorageFile[] = (result.objects || []).map((obj: any) => {
110
+ const rawKey = obj.key as string
111
+ const name = rawKey.startsWith(prefix) ? rawKey.slice(prefix.length) : rawKey
112
+ const cleanedName = name.includes("/") ? name.split("/").pop()! : name
113
+ const mimeType = obj.httpMetadata?.contentType || guessMimeType(rawKey)
114
+
115
+ // Resolve public URL: custom domain, or proxy endpoint
116
+ let url = ""
117
+ if (options.publicUrl) {
118
+ const baseUrl = options.publicUrl.endsWith("/")
119
+ ? options.publicUrl.slice(0, -1)
120
+ : options.publicUrl
121
+ url = `${baseUrl}/${rawKey}`
122
+ } else if (options.apiBasePath) {
123
+ const baseApi = options.apiBasePath.endsWith("/")
124
+ ? options.apiBasePath.slice(0, -1)
125
+ : options.apiBasePath
126
+ url = `${baseApi}/${rawKey}`
127
+ } else {
128
+ url = `/cms/api/media/${rawKey}`
129
+ }
130
+
131
+ return {
132
+ key: rawKey,
133
+ name: cleanedName,
134
+ size: obj.size,
135
+ mimeType,
136
+ uploadedAt: obj.uploaded ? new Date(obj.uploaded).toISOString() : new Date().toISOString(),
137
+ url,
138
+ etag: obj.etag,
139
+ customMetadata: obj.customMetadata
140
+ }
141
+ })
142
+
143
+ return {
144
+ files,
145
+ truncated: result.truncated,
146
+ cursor: result.cursor
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Uploads a file buffer/stream into R2 Bucket.
152
+ */
153
+ export async function uploadR2File(
154
+ bucket: any,
155
+ file: File,
156
+ options: StorageUploadOptions = {}
157
+ ): Promise<StorageFile> {
158
+ if (!bucket || typeof bucket.put !== "function") {
159
+ throw new Error("R2 bucket binding is not available in runtime environment.")
160
+ }
161
+
162
+ const prefix = options.prefix ?? "media/"
163
+ const timestamp = Date.now()
164
+ const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_")
165
+ const randomSuffix = Math.random().toString(36).substring(2, 8)
166
+ const key = options.key ?? `${prefix}${timestamp}-${randomSuffix}/${safeName}`
167
+
168
+ const arrayBuffer = await file.arrayBuffer()
169
+
170
+ const r2Object = await bucket.put(key, arrayBuffer, {
171
+ httpMetadata: {
172
+ contentType: file.type || guessMimeType(safeName),
173
+ contentDisposition: `inline; filename="${safeName}"`
174
+ },
175
+ customMetadata: {
176
+ originalName: file.name,
177
+ uploadedAt: new Date().toISOString()
178
+ }
179
+ })
180
+
181
+ let url = ""
182
+ if (options.publicUrl) {
183
+ const baseUrl = options.publicUrl.endsWith("/")
184
+ ? options.publicUrl.slice(0, -1)
185
+ : options.publicUrl
186
+ url = `${baseUrl}/${key}`
187
+ } else if (options.apiBasePath) {
188
+ const baseApi = options.apiBasePath.endsWith("/")
189
+ ? options.apiBasePath.slice(0, -1)
190
+ : options.apiBasePath
191
+ url = `${baseApi}/${key}`
192
+ } else {
193
+ url = `/cms/api/media/${key}`
194
+ }
195
+
196
+ return {
197
+ key,
198
+ name: safeName,
199
+ size: file.size,
200
+ mimeType: file.type || guessMimeType(safeName),
201
+ uploadedAt: new Date().toISOString(),
202
+ url,
203
+ etag: r2Object?.etag
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Deletes a file from R2 Bucket by key.
209
+ */
210
+ export async function deleteR2File(bucket: any, key: string): Promise<boolean> {
211
+ if (!bucket || typeof bucket.delete !== "function") {
212
+ throw new Error("R2 bucket binding is not available in runtime environment.")
213
+ }
214
+ await bucket.delete(key)
215
+ return true
216
+ }
217
+
218
+ /**
219
+ * MIME type guesser fallback.
220
+ */
221
+ function guessMimeType(filename: string): string {
222
+ const ext = filename.split(".").pop()?.toLowerCase()
223
+ switch (ext) {
224
+ case "jpg":
225
+ case "jpeg":
226
+ return "image/jpeg"
227
+ case "png":
228
+ return "image/png"
229
+ case "webp":
230
+ return "image/webp"
231
+ case "gif":
232
+ return "image/gif"
233
+ case "svg":
234
+ return "image/svg+xml"
235
+ case "avif":
236
+ return "image/avif"
237
+ case "mp4":
238
+ return "video/mp4"
239
+ case "webm":
240
+ return "video/webm"
241
+ case "mp3":
242
+ return "audio/mpeg"
243
+ case "wav":
244
+ return "audio/wav"
245
+ case "pdf":
246
+ return "application/pdf"
247
+ case "json":
248
+ return "application/json"
249
+ case "txt":
250
+ return "text/plain"
251
+ case "zip":
252
+ return "application/zip"
253
+ default:
254
+ return "application/octet-stream"
255
+ }
256
+ }