@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.
- package/LICENSE +21 -0
- package/dist/bylines-Csggkl-g.d.mts +405 -0
- package/dist/index.d.mts +724 -0
- package/dist/index.mjs +2382 -0
- package/dist/integration.d.mts +33 -0
- package/dist/integration.mjs +161 -0
- package/dist/schema/index.d.mts +2063 -0
- package/dist/schema/index.mjs +176 -0
- package/dist/schema/sqlite.d.mts +2132 -0
- package/dist/schema/sqlite.mjs +137 -0
- package/dist/storage/index.d.mts +59 -0
- package/dist/storage/index.mjs +133 -0
- package/package.json +36 -18
- package/src/admin/api/media-file.ts +39 -0
- package/src/admin/api/media.ts +224 -0
- package/src/admin/layouts/CMSDashboardLayout.astro +191 -0
- package/src/admin/layouts/DefaultParentLayout.astro +50 -0
- package/src/admin/pages/assets.astro +424 -0
- package/src/admin/pages/byline-schema.astro +62 -0
- package/src/admin/pages/bylines.astro +16 -0
- package/src/admin/pages/categories.astro +21 -0
- package/src/admin/pages/content-types.astro +55 -0
- package/src/admin/pages/dashboard.astro +61 -0
- package/src/admin/pages/pages-edit.astro +595 -0
- package/src/admin/pages/pages-index.astro +146 -0
- package/src/admin/pages/pages-new.astro +130 -0
- package/src/admin/pages/pages-preview.astro +111 -0
- package/src/admin/pages/pages-review.astro +236 -0
- package/src/admin/pages/settings.astro +203 -0
- package/src/admin/pages/tags.astro +21 -0
- package/src/admin/pages/templates-edit.astro +234 -0
- package/src/admin/pages/templates-index.astro +95 -0
- package/src/admin/pages/templates-new.astro +193 -0
- package/src/admin/pages/users.astro +74 -0
- package/src/admin/pages/versions.astro +74 -0
- package/src/astro/BlockRenderer.astro +44 -102
- package/src/astro/PageRenderer.astro +12 -5
- package/src/astro/blocks/CalloutBlock.astro +7 -7
- package/src/astro/blocks/CardBlock.astro +5 -5
- package/src/astro/blocks/CodeBlock.astro +2 -2
- package/src/astro/blocks/FileTreeBlock.astro +7 -7
- package/src/astro/blocks/ImageBlock.astro +2 -2
- package/src/astro/blocks/OrderedListBlock.astro +2 -2
- package/src/astro/blocks/ParagraphBlock.astro +3 -3
- package/src/astro/blocks/SectionBlock.astro +6 -6
- package/src/astro/blocks/StepItemBlock.astro +3 -3
- package/src/astro/blocks/StepsBlock.astro +1 -1
- package/src/astro/blocks/TableBlock.astro +6 -6
- package/src/astro/blocks/TabsBlock.astro +9 -9
- package/src/astro/blocks/UnorderedListBlock.astro +2 -2
- package/src/astro/blocks/index.ts +35 -0
- package/src/client/components/RimelightBlock.tsx +19 -19
- package/src/client/components/RimelightBlockPicker.tsx +9 -13
- package/src/client/components/RimelightBylinesManager.tsx +438 -0
- package/src/client/components/RimelightEditor.tsx +3 -3
- package/src/client/components/RimelightPreview.tsx +37 -45
- package/src/client/components/RimelightPropertiesPanel.tsx +368 -26
- package/src/client/components/RimelightTaxonomyManager.tsx +369 -0
- package/src/client/components/RimelightTaxonomyMasterDetail.tsx +412 -0
- package/src/client/components/RimelightTemplateEditor.tsx +6 -8
- package/src/client/components/editors/CalloutEditor.tsx +2 -4
- package/src/client/components/editors/CardsEditor.tsx +2 -4
- package/src/client/components/editors/CodeEditor.tsx +1 -3
- package/src/client/components/editors/FileTreeEditor.tsx +2 -4
- package/src/client/components/editors/ImageEditor.tsx +3 -3
- package/src/client/components/editors/ParagraphEditor.tsx +2 -2
- package/src/client/components/editors/SceneEditor.tsx +6 -12
- package/src/client/components/editors/ScriptEditor.tsx +8 -16
- package/src/client/components/editors/SectionEditor.tsx +4 -4
- package/src/client/components/editors/StepItemEditor.tsx +2 -2
- package/src/client/components/editors/StepsEditor.tsx +2 -4
- package/src/client/components/editors/TabItemEditor.tsx +3 -3
- package/src/client/components/editors/TableEditor.tsx +5 -5
- package/src/client/components/editors/TabsEditor.tsx +2 -4
- package/src/client/components/index.ts +3 -0
- package/src/client/loader.ts +3 -0
- package/src/core/diff.ts +145 -0
- package/src/core/excerpt.ts +159 -0
- package/src/core/preview.ts +87 -0
- package/src/core/registry.ts +0 -86
- package/src/core/types/blocks.ts +6 -1
- package/src/core/types/bylines.ts +14 -0
- package/src/core/types/media.ts +13 -0
- package/src/core/types/taxonomies.ts +20 -0
- package/src/cron/scheduled.ts +57 -0
- package/src/docs/agent.ts +116 -0
- package/src/docs/config.ts +25 -0
- package/src/docs/index.ts +5 -0
- package/src/docs/routing.ts +104 -0
- package/src/docs/sidebar.ts +251 -0
- package/src/docs/toc.ts +47 -0
- package/src/env.d.ts +19 -0
- package/src/index.ts +57 -12
- package/src/integration.ts +250 -0
- package/src/loader/live.ts +254 -0
- package/src/markdown/index.ts +0 -1
- package/src/mcp/index.ts +228 -0
- package/src/schema/bylines.ts +23 -0
- package/src/schema/index.ts +3 -4
- package/src/schema/page_versions.ts +2 -0
- package/src/schema/pages.ts +2 -0
- package/src/schema/site_settings.ts +41 -0
- package/src/schema/sqlite.ts +344 -0
- package/src/schema/taxonomies.ts +49 -0
- package/src/services/bylines.ts +74 -0
- package/src/services/search-indexer.ts +61 -0
- package/src/services/site-settings.ts +108 -0
- package/src/services/taxonomies.ts +276 -0
- package/src/storage/index.ts +256 -0
- package/src/astro/blocks/ApiDocumentationBlock.astro +0 -350
- package/src/astro/blocks/DialogueBlock.astro +0 -23
- package/src/astro/blocks/LivePreviewBlock.astro +0 -27
- package/src/astro/blocks/SceneBlock.astro +0 -65
- package/src/astro/blocks/ScriptBlock.astro +0 -59
- package/src/core/template-sync.ts +0 -75
- package/src/markdown/parser.ts +0 -347
- package/src/migration-tools.ts +0 -334
- package/src/migration.ts +0 -155
- package/src/schema/component_dependencies.ts +0 -21
- package/src/schema/component_metadata.ts +0 -31
- package/src/schema/component_metadata_cache.ts +0 -15
- package/src/schema/component_versions.ts +0 -19
- package/src/search/sync.ts +0 -32
- 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
|
+
}
|