@betterstart/cli 0.1.69 → 0.1.71
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-E4HZYXQ2.js +36 -0
- package/dist/chunk-E4HZYXQ2.js.map +1 -0
- package/dist/cli.js +799 -4586
- package/dist/cli.js.map +1 -1
- package/dist/reader-2T45D7JZ.js +7 -0
- package/package.json +1 -1
- package/templates/init/api/auth-route.ts +3 -0
- package/templates/init/api/upload-route.ts +74 -0
- package/templates/init/cms-globals.css +200 -0
- package/templates/init/components/data-table/data-table-pagination.tsx +90 -0
- package/templates/init/components/data-table/data-table-toolbar.tsx +93 -0
- package/templates/init/components/data-table/data-table.tsx +188 -0
- package/templates/init/components/layout/cms-header.tsx +32 -0
- package/templates/init/components/layout/cms-nav-link.tsx +25 -0
- package/templates/init/components/layout/cms-providers.tsx +33 -0
- package/templates/init/components/layout/cms-search.tsx +25 -0
- package/templates/init/components/layout/cms-sidebar.tsx +192 -0
- package/templates/init/components/layout/cms-sign-out.tsx +30 -0
- package/templates/init/components/shared/delete-dialog.tsx +75 -0
- package/templates/init/components/shared/page-header.tsx +23 -0
- package/templates/init/components/shared/status-badge.tsx +43 -0
- package/templates/init/data/navigation.ts +39 -0
- package/templates/init/db/client.ts +8 -0
- package/templates/init/db/schema.ts +88 -0
- package/{dist/chunk-6JCWMKSY.js → templates/init/drizzle.config.ts} +2 -11
- package/templates/init/hooks/use-cms-theme.tsx +78 -0
- package/templates/init/hooks/use-editor-image-upload.ts +82 -0
- package/templates/init/hooks/use-local-storage.ts +46 -0
- package/templates/init/hooks/use-mobile.ts +19 -0
- package/templates/init/hooks/use-upload.ts +177 -0
- package/templates/init/hooks/use-users.ts +13 -0
- package/templates/init/lib/actions/form-settings.ts +126 -0
- package/templates/init/lib/actions/profile.ts +62 -0
- package/templates/init/lib/actions/upload.ts +153 -0
- package/templates/init/lib/actions/users.ts +145 -0
- package/templates/init/lib/auth/auth-client.ts +12 -0
- package/templates/init/lib/auth/auth.ts +43 -0
- package/templates/init/lib/auth/middleware.ts +44 -0
- package/templates/init/lib/markdown/cached.ts +7 -0
- package/templates/init/lib/markdown/format.ts +55 -0
- package/templates/init/lib/markdown/render.ts +182 -0
- package/templates/init/lib/r2.ts +55 -0
- package/templates/init/pages/account-layout.tsx +63 -0
- package/templates/init/pages/authenticated-layout.tsx +26 -0
- package/templates/init/pages/cms-layout.tsx +16 -0
- package/templates/init/pages/dashboard-page.tsx +91 -0
- package/templates/init/pages/login-form.tsx +117 -0
- package/templates/init/pages/login-page.tsx +17 -0
- package/templates/init/pages/profile/profile-form.tsx +361 -0
- package/templates/init/pages/profile/profile-page.tsx +34 -0
- package/templates/init/pages/users/columns.tsx +241 -0
- package/templates/init/pages/users/create-user-dialog.tsx +116 -0
- package/templates/init/pages/users/edit-role-dialog.tsx +92 -0
- package/templates/init/pages/users/users-page-content.tsx +29 -0
- package/templates/init/pages/users/users-page.tsx +19 -0
- package/templates/init/pages/users/users-table.tsx +219 -0
- package/templates/init/types/auth.ts +78 -0
- package/templates/init/types/index.ts +79 -0
- package/templates/init/types/table-meta.ts +16 -0
- package/templates/init/utils/cn.ts +6 -0
- package/templates/init/utils/mailchimp.ts +39 -0
- package/templates/init/utils/seo.ts +90 -0
- package/templates/init/utils/validation.ts +105 -0
- package/templates/init/utils/webhook.ts +28 -0
- package/templates/ui/alert-dialog.tsx +46 -28
- package/templates/ui/avatar.tsx +37 -20
- package/templates/ui/button.tsx +3 -3
- package/templates/ui/card.tsx +30 -18
- package/templates/ui/dialog.tsx +46 -22
- package/templates/ui/dropdown-menu.tsx +1 -1
- package/templates/ui/input.tsx +1 -1
- package/templates/ui/select.tsx +42 -34
- package/templates/ui/sidebar.tsx +13 -13
- package/templates/ui/table.tsx +2 -2
- package/dist/chunk-6JCWMKSY.js.map +0 -1
- package/dist/drizzle-config-EDKOEZ6G.js +0 -7
- /package/dist/{drizzle-config-EDKOEZ6G.js.map → reader-2T45D7JZ.js.map} +0 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { PutObjectCommand } from '@aws-sdk/client-s3'
|
|
4
|
+
import { BUCKET_NAME, generateFilePath, getPublicUrl, getR2Client } from '@cms/lib/r2'
|
|
5
|
+
|
|
6
|
+
interface UploadedFile {
|
|
7
|
+
key: string
|
|
8
|
+
url: string
|
|
9
|
+
filename: string
|
|
10
|
+
size: number
|
|
11
|
+
contentType: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface UploadResult {
|
|
15
|
+
success: boolean
|
|
16
|
+
files?: UploadedFile[]
|
|
17
|
+
error?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface FileValidationConfig {
|
|
21
|
+
maxFiles?: number
|
|
22
|
+
maxSizeInBytes?: number
|
|
23
|
+
allowedTypes?: string[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Upload multiple files to Cloudflare R2
|
|
28
|
+
*/
|
|
29
|
+
export async function uploadFiles(
|
|
30
|
+
formData: FormData,
|
|
31
|
+
config: FileValidationConfig = {},
|
|
32
|
+
): Promise<UploadResult> {
|
|
33
|
+
try {
|
|
34
|
+
const files: File[] = []
|
|
35
|
+
const prefix = formData.get('prefix')?.toString() || 'uploads'
|
|
36
|
+
|
|
37
|
+
for (const [key, value] of formData.entries()) {
|
|
38
|
+
if (value instanceof File && key.startsWith('file')) {
|
|
39
|
+
files.push(value)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (files.length === 0) {
|
|
44
|
+
return { success: false, error: 'No files provided' }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (config.maxFiles && files.length > config.maxFiles) {
|
|
48
|
+
return { success: false, error: `Too many files. Maximum is ${config.maxFiles}` }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const uploadedFiles: UploadedFile[] = []
|
|
52
|
+
|
|
53
|
+
for (const file of files) {
|
|
54
|
+
if (config.maxSizeInBytes && file.size > config.maxSizeInBytes) {
|
|
55
|
+
return { success: false, error: `File ${file.name} exceeds size limit` }
|
|
56
|
+
}
|
|
57
|
+
if (config.allowedTypes && !config.allowedTypes.includes(file.type)) {
|
|
58
|
+
return { success: false, error: `File type ${file.type} is not allowed` }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const key = generateFilePath(file.name, prefix)
|
|
62
|
+
const arrayBuffer = await file.arrayBuffer()
|
|
63
|
+
const buffer = Buffer.from(arrayBuffer)
|
|
64
|
+
|
|
65
|
+
const command = new PutObjectCommand({
|
|
66
|
+
Bucket: BUCKET_NAME,
|
|
67
|
+
Key: key,
|
|
68
|
+
Body: buffer,
|
|
69
|
+
ContentType: file.type,
|
|
70
|
+
ContentLength: file.size,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
await getR2Client().send(command)
|
|
74
|
+
|
|
75
|
+
uploadedFiles.push({
|
|
76
|
+
key,
|
|
77
|
+
url: getPublicUrl(key),
|
|
78
|
+
filename: file.name,
|
|
79
|
+
size: file.size,
|
|
80
|
+
contentType: file.type,
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { success: true, files: uploadedFiles }
|
|
85
|
+
} catch (error) {
|
|
86
|
+
const message = error instanceof Error ? error.message : 'Failed to upload files'
|
|
87
|
+
return { success: false, error: message }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Upload a single file to Cloudflare R2
|
|
93
|
+
*/
|
|
94
|
+
export async function uploadFile(
|
|
95
|
+
formData: FormData,
|
|
96
|
+
config: FileValidationConfig = {},
|
|
97
|
+
): Promise<UploadResult> {
|
|
98
|
+
return uploadFiles(formData, { ...config, maxFiles: 1 })
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Upload an image from a URL to Cloudflare R2
|
|
103
|
+
*/
|
|
104
|
+
export async function uploadImageFromUrl(
|
|
105
|
+
imageUrl: string,
|
|
106
|
+
prefix = 'images',
|
|
107
|
+
): Promise<{ success: boolean; url?: string; error?: string }> {
|
|
108
|
+
try {
|
|
109
|
+
const url = new URL(imageUrl)
|
|
110
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
111
|
+
return { success: false, error: 'Only HTTP and HTTPS URLs are allowed' }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const response = await fetch(imageUrl, {
|
|
115
|
+
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; ImageFetcher/1.0)' },
|
|
116
|
+
signal: AbortSignal.timeout(30000),
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
return { success: false, error: `Failed to fetch image: ${response.status}` }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const contentType = response.headers.get('content-type') || 'image/jpeg'
|
|
124
|
+
if (!contentType.startsWith('image/')) {
|
|
125
|
+
return { success: false, error: `Invalid content type: ${contentType}` }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const arrayBuffer = await response.arrayBuffer()
|
|
129
|
+
const buffer = Buffer.from(arrayBuffer)
|
|
130
|
+
|
|
131
|
+
if (buffer.length === 0) return { success: false, error: 'Image file is empty' }
|
|
132
|
+
if (buffer.length > 10 * 1024 * 1024) {
|
|
133
|
+
return { success: false, error: 'Image exceeds 10MB limit' }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const filename = url.pathname.split('/').pop()?.split('?')[0] || 'image.jpg'
|
|
137
|
+
const key = generateFilePath(filename, prefix)
|
|
138
|
+
|
|
139
|
+
const command = new PutObjectCommand({
|
|
140
|
+
Bucket: BUCKET_NAME,
|
|
141
|
+
Key: key,
|
|
142
|
+
Body: buffer,
|
|
143
|
+
ContentType: contentType,
|
|
144
|
+
ContentLength: buffer.length,
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
await getR2Client().send(command)
|
|
148
|
+
return { success: true, url: getPublicUrl(key) }
|
|
149
|
+
} catch (error) {
|
|
150
|
+
const message = error instanceof Error ? error.message : 'Failed to upload image from URL'
|
|
151
|
+
return { success: false, error: message }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { auth } from '@cms/auth'
|
|
4
|
+
import db from '@cms/db'
|
|
5
|
+
import { user } from '@cms/db/schema'
|
|
6
|
+
import { eq } from 'drizzle-orm'
|
|
7
|
+
|
|
8
|
+
export interface UserData {
|
|
9
|
+
id: string
|
|
10
|
+
email: string
|
|
11
|
+
name: string
|
|
12
|
+
emailVerified: boolean
|
|
13
|
+
createdAt: string
|
|
14
|
+
updatedAt: string
|
|
15
|
+
role: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface UsersResponse {
|
|
19
|
+
users: UserData[]
|
|
20
|
+
total: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CreateUserInput {
|
|
24
|
+
email: string
|
|
25
|
+
name: string
|
|
26
|
+
password: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CreateUserResult {
|
|
30
|
+
success: boolean
|
|
31
|
+
error?: string
|
|
32
|
+
user?: UserData
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface UpdateUserRoleResult {
|
|
36
|
+
success: boolean
|
|
37
|
+
error?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface DeleteUserResult {
|
|
41
|
+
success: boolean
|
|
42
|
+
error?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Create a new user via Better Auth's built-in API
|
|
47
|
+
*/
|
|
48
|
+
export async function createUser(input: CreateUserInput): Promise<CreateUserResult> {
|
|
49
|
+
try {
|
|
50
|
+
const result = await auth.api.signUpEmail({
|
|
51
|
+
body: {
|
|
52
|
+
email: input.email,
|
|
53
|
+
password: input.password,
|
|
54
|
+
name: input.name,
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
if (!result?.user) {
|
|
59
|
+
return { success: false, error: 'Failed to create user' }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
success: true,
|
|
64
|
+
user: {
|
|
65
|
+
id: result.user.id,
|
|
66
|
+
email: result.user.email,
|
|
67
|
+
name: input.name,
|
|
68
|
+
emailVerified: false,
|
|
69
|
+
createdAt: new Date().toISOString(),
|
|
70
|
+
updatedAt: new Date().toISOString(),
|
|
71
|
+
role: 'member',
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return {
|
|
76
|
+
success: false,
|
|
77
|
+
error: error instanceof Error ? error.message : 'Failed to create user',
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get all users
|
|
84
|
+
*/
|
|
85
|
+
export async function getUsers(): Promise<UsersResponse> {
|
|
86
|
+
try {
|
|
87
|
+
const users = await db
|
|
88
|
+
.select({
|
|
89
|
+
id: user.id,
|
|
90
|
+
email: user.email,
|
|
91
|
+
name: user.name,
|
|
92
|
+
emailVerified: user.emailVerified,
|
|
93
|
+
createdAt: user.createdAt,
|
|
94
|
+
updatedAt: user.updatedAt,
|
|
95
|
+
role: user.role,
|
|
96
|
+
})
|
|
97
|
+
.from(user)
|
|
98
|
+
.orderBy(user.createdAt)
|
|
99
|
+
|
|
100
|
+
const userData: UserData[] = users.map((u) => ({
|
|
101
|
+
id: u.id,
|
|
102
|
+
email: u.email,
|
|
103
|
+
name: u.name,
|
|
104
|
+
emailVerified: u.emailVerified,
|
|
105
|
+
createdAt: u.createdAt.toISOString(),
|
|
106
|
+
updatedAt: u.updatedAt.toISOString(),
|
|
107
|
+
role: u.role || 'member',
|
|
108
|
+
}))
|
|
109
|
+
|
|
110
|
+
return { users: userData, total: userData.length }
|
|
111
|
+
} catch {
|
|
112
|
+
return { users: [], total: 0 }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Update a user's role
|
|
118
|
+
*/
|
|
119
|
+
export async function updateUserRole(userId: string, role: string): Promise<UpdateUserRoleResult> {
|
|
120
|
+
try {
|
|
121
|
+
await db.update(user).set({ role, updatedAt: new Date() }).where(eq(user.id, userId))
|
|
122
|
+
|
|
123
|
+
return { success: true }
|
|
124
|
+
} catch (error) {
|
|
125
|
+
return {
|
|
126
|
+
success: false,
|
|
127
|
+
error: error instanceof Error ? error.message : 'Failed to update user role',
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Delete a user
|
|
134
|
+
*/
|
|
135
|
+
export async function deleteUser(userId: string): Promise<DeleteUserResult> {
|
|
136
|
+
try {
|
|
137
|
+
await db.delete(user).where(eq(user.id, userId))
|
|
138
|
+
return { success: true }
|
|
139
|
+
} catch (error) {
|
|
140
|
+
return {
|
|
141
|
+
success: false,
|
|
142
|
+
error: error instanceof Error ? error.message : 'Failed to delete user',
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { createAuthClient } from 'better-auth/react'
|
|
4
|
+
|
|
5
|
+
export const authClient = createAuthClient({
|
|
6
|
+
baseURL:
|
|
7
|
+
process.env.NEXT_PUBLIC_BETTERSTART_AUTH_URL ||
|
|
8
|
+
(typeof window !== 'undefined' ? window.location.origin : ''),
|
|
9
|
+
basePath: '/api/cms/auth',
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
export const { signIn, signUp, signOut, useSession } = authClient
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import db from '@cms/db'
|
|
2
|
+
import * as schema from '@cms/db/schema'
|
|
3
|
+
import { betterAuth } from 'better-auth'
|
|
4
|
+
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
|
5
|
+
import { toNextJsHandler } from 'better-auth/next-js'
|
|
6
|
+
|
|
7
|
+
export { toNextJsHandler }
|
|
8
|
+
|
|
9
|
+
export const auth = betterAuth({
|
|
10
|
+
secret: process.env.BETTERSTART_AUTH_SECRET,
|
|
11
|
+
baseURL: process.env.BETTERSTART_AUTH_URL,
|
|
12
|
+
basePath: process.env.BETTERSTART_AUTH_BASE_PATH || '/api/cms/auth',
|
|
13
|
+
database: drizzleAdapter(db, {
|
|
14
|
+
provider: 'pg',
|
|
15
|
+
schema: {
|
|
16
|
+
user: schema.user,
|
|
17
|
+
session: schema.session,
|
|
18
|
+
account: schema.account,
|
|
19
|
+
verification: schema.verification,
|
|
20
|
+
},
|
|
21
|
+
}),
|
|
22
|
+
emailAndPassword: {
|
|
23
|
+
enabled: true,
|
|
24
|
+
minPasswordLength: 8,
|
|
25
|
+
},
|
|
26
|
+
session: {
|
|
27
|
+
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
|
28
|
+
updateAge: 60 * 60 * 24, // 1 day
|
|
29
|
+
},
|
|
30
|
+
user: {
|
|
31
|
+
additionalFields: {
|
|
32
|
+
role: {
|
|
33
|
+
type: 'string',
|
|
34
|
+
required: false,
|
|
35
|
+
defaultValue: 'member',
|
|
36
|
+
input: false,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
export type Session = typeof auth.$Infer.Session
|
|
43
|
+
export type User = typeof auth.$Infer.Session.user
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { headers } from 'next/headers'
|
|
2
|
+
import { redirect } from 'next/navigation'
|
|
3
|
+
import { auth, type User } from './auth'
|
|
4
|
+
|
|
5
|
+
export enum UserRole {
|
|
6
|
+
ADMIN = 'admin',
|
|
7
|
+
EDITOR = 'editor',
|
|
8
|
+
MEMBER = 'member',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function isUserRole(value: unknown): value is UserRole {
|
|
12
|
+
return typeof value === 'string' && Object.values(UserRole).includes(value as UserRole)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get the current session from Better Auth
|
|
17
|
+
*/
|
|
18
|
+
export async function getSession() {
|
|
19
|
+
const session = await auth.api.getSession({
|
|
20
|
+
headers: await headers(),
|
|
21
|
+
})
|
|
22
|
+
return session
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Require the user to have one of the specified roles.
|
|
27
|
+
* Redirects to /cms/login if not authenticated or unauthorized.
|
|
28
|
+
*/
|
|
29
|
+
export async function requireRole(allowedRoles: UserRole[]): Promise<User> {
|
|
30
|
+
const session = await getSession()
|
|
31
|
+
|
|
32
|
+
if (!session?.user) {
|
|
33
|
+
redirect('/cms/login')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const user = session.user
|
|
37
|
+
const role = isUserRole(user.role) ? user.role : UserRole.MEMBER
|
|
38
|
+
|
|
39
|
+
if (!allowedRoles.includes(role)) {
|
|
40
|
+
redirect('/cms/login')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return user
|
|
44
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format markdown content for consistent styling and readability
|
|
3
|
+
*/
|
|
4
|
+
export function formatMarkdown(content: string): string {
|
|
5
|
+
if (!content || !content.trim()) {
|
|
6
|
+
return ''
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
let formatted = content
|
|
10
|
+
|
|
11
|
+
// Normalize line endings
|
|
12
|
+
formatted = formatted.replace(/\r\n/g, '\n')
|
|
13
|
+
|
|
14
|
+
// Trim trailing whitespace from each line
|
|
15
|
+
formatted = formatted
|
|
16
|
+
.split('\n')
|
|
17
|
+
.map((line) => line.trimEnd())
|
|
18
|
+
.join('\n')
|
|
19
|
+
|
|
20
|
+
// Normalize multiple blank lines to maximum of 2 (one blank line)
|
|
21
|
+
formatted = formatted.replace(/\n{3,}/g, '\n\n')
|
|
22
|
+
|
|
23
|
+
// Ensure blank line before headings (unless at start)
|
|
24
|
+
formatted = formatted.replace(/([^\n])\n(#{1,6}\s)/g, '$1\n\n$2')
|
|
25
|
+
|
|
26
|
+
// Ensure blank line after headings
|
|
27
|
+
formatted = formatted.replace(/(#{1,6}\s.*)\n([^\n#])/g, '$1\n\n$2')
|
|
28
|
+
|
|
29
|
+
// Ensure blank line before code blocks
|
|
30
|
+
formatted = formatted.replace(/([^\n])\n(```)/g, '$1\n\n$2')
|
|
31
|
+
|
|
32
|
+
// Ensure blank line after code blocks
|
|
33
|
+
formatted = formatted.replace(/(```)\n([^\n])/g, '$1\n\n$2')
|
|
34
|
+
|
|
35
|
+
// Ensure blank line before horizontal rules
|
|
36
|
+
formatted = formatted.replace(/([^\n])\n(---+)/g, '$1\n\n$2')
|
|
37
|
+
|
|
38
|
+
// Ensure blank line after horizontal rules
|
|
39
|
+
formatted = formatted.replace(/(---+)\n([^\n])/g, '$1\n\n$2')
|
|
40
|
+
|
|
41
|
+
// Ensure blank line before blockquotes (unless nested)
|
|
42
|
+
formatted = formatted.replace(/([^\n>])\n(>\s)/g, '$1\n\n$2')
|
|
43
|
+
|
|
44
|
+
// Normalize list item spacing
|
|
45
|
+
formatted = formatted.replace(/^(\s*)([-*+])\s+/gm, '$1$2 ')
|
|
46
|
+
formatted = formatted.replace(/^(\s*)(\d+\.)\s+/gm, '$1$2 ')
|
|
47
|
+
|
|
48
|
+
// Remove leading blank lines
|
|
49
|
+
formatted = formatted.replace(/^\n+/, '')
|
|
50
|
+
|
|
51
|
+
// Ensure single trailing newline
|
|
52
|
+
formatted = `${formatted.trimEnd()}\n`
|
|
53
|
+
|
|
54
|
+
return formatted
|
|
55
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { transformerNotationDiff, transformerNotationHighlight } from '@shikijs/transformers'
|
|
2
|
+
import { renderToString } from 'katex'
|
|
3
|
+
import MarkdownIt from 'markdown-it'
|
|
4
|
+
import { createHighlighterCoreSync } from 'shiki/core'
|
|
5
|
+
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'
|
|
6
|
+
import css from 'shiki/langs/css.mjs'
|
|
7
|
+
import go from 'shiki/langs/go.mjs'
|
|
8
|
+
import javascript from 'shiki/langs/javascript.mjs'
|
|
9
|
+
import json from 'shiki/langs/json.mjs'
|
|
10
|
+
import jsx from 'shiki/langs/jsx.mjs'
|
|
11
|
+
import markdown from 'shiki/langs/markdown.mjs'
|
|
12
|
+
import python from 'shiki/langs/python.mjs'
|
|
13
|
+
import rust from 'shiki/langs/rust.mjs'
|
|
14
|
+
import shellscript from 'shiki/langs/shellscript.mjs'
|
|
15
|
+
import sql from 'shiki/langs/sql.mjs'
|
|
16
|
+
import tsx from 'shiki/langs/tsx.mjs'
|
|
17
|
+
import typescript from 'shiki/langs/typescript.mjs'
|
|
18
|
+
import yaml from 'shiki/langs/yaml.mjs'
|
|
19
|
+
import githubDark from 'shiki/themes/github-dark.mjs'
|
|
20
|
+
import githubLight from 'shiki/themes/github-light.mjs'
|
|
21
|
+
|
|
22
|
+
// --- Helpers ---
|
|
23
|
+
|
|
24
|
+
function slugify(text: string): string {
|
|
25
|
+
return text
|
|
26
|
+
.toLowerCase()
|
|
27
|
+
.trim()
|
|
28
|
+
.replace(/[^\w\s-]/g, '')
|
|
29
|
+
.replace(/\s+/g, '-')
|
|
30
|
+
.replace(/-+/g, '-')
|
|
31
|
+
.replace(/^-+|-+$/g, '')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function trimMathBlock(content: string): string {
|
|
35
|
+
return content.replace(/\$\$([\s\S]*?)\$\$/g, (_match, mathContent: string) => {
|
|
36
|
+
const trimmed = mathContent.trim()
|
|
37
|
+
if (!trimmed) return '$$\n$$'
|
|
38
|
+
const lines = trimmed.split('\n').filter((line: string) => line.trim().length > 0)
|
|
39
|
+
return `$$\n${lines.join('\n')}\n$$`
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// --- Shiki Highlighter ---
|
|
44
|
+
|
|
45
|
+
const shiki = createHighlighterCoreSync({
|
|
46
|
+
themes: [githubDark, githubLight],
|
|
47
|
+
langs: [
|
|
48
|
+
javascript,
|
|
49
|
+
typescript,
|
|
50
|
+
jsx,
|
|
51
|
+
tsx,
|
|
52
|
+
python,
|
|
53
|
+
rust,
|
|
54
|
+
go,
|
|
55
|
+
json,
|
|
56
|
+
yaml,
|
|
57
|
+
css,
|
|
58
|
+
sql,
|
|
59
|
+
shellscript,
|
|
60
|
+
markdown,
|
|
61
|
+
],
|
|
62
|
+
engine: createJavaScriptRegexEngine(),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const loadedLangs = shiki.getLoadedLanguages()
|
|
66
|
+
|
|
67
|
+
// --- Heading Anchor Plugin ---
|
|
68
|
+
|
|
69
|
+
function headingAnchorPlugin(md: MarkdownIt) {
|
|
70
|
+
md.core.ruler.push('heading_anchors', (state) => {
|
|
71
|
+
const tokens = state.tokens
|
|
72
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
73
|
+
const token = tokens[i]
|
|
74
|
+
if (token.type === 'heading_open') {
|
|
75
|
+
const contentToken = tokens[i + 1]
|
|
76
|
+
if (contentToken && contentToken.type === 'inline' && contentToken.content) {
|
|
77
|
+
const slug = slugify(contentToken.content)
|
|
78
|
+
const idIndex = token.attrIndex('id')
|
|
79
|
+
if (idIndex < 0) {
|
|
80
|
+
token.attrPush(['id', slug])
|
|
81
|
+
} else if (token.attrs) {
|
|
82
|
+
token.attrs[idIndex][1] = slug
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return true
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// --- Inline KaTeX math plugin (replaces markdown-it-dollarmath) ---
|
|
92
|
+
|
|
93
|
+
function mathPlugin(md: MarkdownIt) {
|
|
94
|
+
// Inline: $...$
|
|
95
|
+
md.inline.ruler.after('escape', 'math_inline', (state, silent) => {
|
|
96
|
+
if (state.src.charCodeAt(state.pos) !== 0x24) return false
|
|
97
|
+
if (state.src.charCodeAt(state.pos + 1) === 0x24) return false
|
|
98
|
+
const start = state.pos + 1
|
|
99
|
+
let end = start
|
|
100
|
+
while (end < state.posMax && state.src.charCodeAt(end) !== 0x24) {
|
|
101
|
+
if (state.src.charCodeAt(end) === 0x5C) end++
|
|
102
|
+
end++
|
|
103
|
+
}
|
|
104
|
+
if (end >= state.posMax) return false
|
|
105
|
+
const content = state.src.slice(start, end).trim()
|
|
106
|
+
if (!content) return false
|
|
107
|
+
if (!silent) {
|
|
108
|
+
const token = state.push('math_inline', 'math', 0)
|
|
109
|
+
token.content = content
|
|
110
|
+
token.markup = '$'
|
|
111
|
+
}
|
|
112
|
+
state.pos = end + 1
|
|
113
|
+
return true
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
md.renderer.rules.math_inline = (tokens, idx) => {
|
|
117
|
+
return renderToString(tokens[idx].content, { displayMode: false, throwOnError: false, strict: 'ignore' })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Block: $$...$$
|
|
121
|
+
md.block.ruler.after('blockquote', 'math_block', (state, startLine, endLine, silent) => {
|
|
122
|
+
const startPos = state.bMarks[startLine] + state.tShift[startLine]
|
|
123
|
+
if (state.src.charCodeAt(startPos) !== 0x24 || state.src.charCodeAt(startPos + 1) !== 0x24) return false
|
|
124
|
+
if (silent) return true
|
|
125
|
+
let nextLine = startLine
|
|
126
|
+
let found = false
|
|
127
|
+
while (nextLine < endLine) {
|
|
128
|
+
nextLine++
|
|
129
|
+
if (nextLine >= endLine) break
|
|
130
|
+
const pos = state.bMarks[nextLine] + state.tShift[nextLine]
|
|
131
|
+
const max = state.eMarks[nextLine]
|
|
132
|
+
const line = state.src.slice(pos, max).trim()
|
|
133
|
+
if (line === '$$') { found = true; break }
|
|
134
|
+
}
|
|
135
|
+
if (!found) return false
|
|
136
|
+
const contentLines: string[] = []
|
|
137
|
+
for (let i = startLine + 1; i < nextLine; i++) {
|
|
138
|
+
contentLines.push(state.src.slice(state.bMarks[i] + state.tShift[i], state.eMarks[i]))
|
|
139
|
+
}
|
|
140
|
+
const token = state.push('math_block', 'math', 0)
|
|
141
|
+
token.content = contentLines.join('\n').trim()
|
|
142
|
+
token.markup = '$$'
|
|
143
|
+
token.map = [startLine, nextLine + 1]
|
|
144
|
+
state.line = nextLine + 1
|
|
145
|
+
return true
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
md.renderer.rules.math_block = (tokens, idx) => {
|
|
149
|
+
return '<div class="math-display">' + renderToString(tokens[idx].content, { displayMode: true, throwOnError: false, strict: 'ignore' }) + '</div>\n'
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- Markdown-it Instance ---
|
|
154
|
+
|
|
155
|
+
const md = MarkdownIt({
|
|
156
|
+
html: true,
|
|
157
|
+
linkify: true,
|
|
158
|
+
typographer: true,
|
|
159
|
+
breaks: true,
|
|
160
|
+
highlight: (code, lang) => {
|
|
161
|
+
const language = loadedLangs.includes(lang) ? lang : 'bash'
|
|
162
|
+
const highlighted = shiki.codeToHtml(code, {
|
|
163
|
+
lang: language,
|
|
164
|
+
themes: { light: 'github-light', dark: 'github-dark' },
|
|
165
|
+
defaultColor: false,
|
|
166
|
+
transformers: [transformerNotationHighlight(), transformerNotationDiff()],
|
|
167
|
+
})
|
|
168
|
+
const escapedCode = code.replace(/</g, '<').replace(/>/g, '>')
|
|
169
|
+
return `<div class="code-block-wrapper not-prose" data-lang="${language}"><button class="copy-button" data-code="${escapedCode.replace(/"/g, '"')}" aria-label="Copy code">Copy</button>${highlighted}</div>`
|
|
170
|
+
},
|
|
171
|
+
})
|
|
172
|
+
.use(headingAnchorPlugin)
|
|
173
|
+
.use(mathPlugin)
|
|
174
|
+
|
|
175
|
+
export function renderMarkdownSync(src: string): string {
|
|
176
|
+
const trimmedContent = trimMathBlock(src)
|
|
177
|
+
return md.render(trimmedContent)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function renderMarkdownInline(src: string): string {
|
|
181
|
+
return md.renderInline(src)
|
|
182
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { S3Client } from '@aws-sdk/client-s3'
|
|
2
|
+
|
|
3
|
+
let _r2Client: S3Client | null = null
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Get or create the R2 client (lazy initialization, server-side only)
|
|
7
|
+
*/
|
|
8
|
+
export function getR2Client(): S3Client {
|
|
9
|
+
if (typeof window !== 'undefined') {
|
|
10
|
+
throw new Error('R2 client can only be used on the server side')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (_r2Client) return _r2Client
|
|
14
|
+
|
|
15
|
+
if (!process.env.BETTERSTART_R2_ACCESS_KEY_ID) {
|
|
16
|
+
throw new Error('BETTERSTART_R2_ACCESS_KEY_ID is not set')
|
|
17
|
+
}
|
|
18
|
+
if (!process.env.BETTERSTART_R2_SECRET_ACCESS_KEY) {
|
|
19
|
+
throw new Error('BETTERSTART_R2_SECRET_ACCESS_KEY is not set')
|
|
20
|
+
}
|
|
21
|
+
if (!process.env.BETTERSTART_R2_ACCOUNT_ID) {
|
|
22
|
+
throw new Error('BETTERSTART_R2_ACCOUNT_ID is not set')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
_r2Client = new S3Client({
|
|
26
|
+
region: 'auto',
|
|
27
|
+
endpoint: `https://${process.env.BETTERSTART_R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
|
28
|
+
credentials: {
|
|
29
|
+
accessKeyId: process.env.BETTERSTART_R2_ACCESS_KEY_ID,
|
|
30
|
+
secretAccessKey: process.env.BETTERSTART_R2_SECRET_ACCESS_KEY,
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
return _r2Client
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const BUCKET_NAME = process.env.BETTERSTART_R2_BUCKET_NAME || ''
|
|
38
|
+
export const PUBLIC_URL = process.env.BETTERSTART_R2_PUBLIC_URL || ''
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Generate a unique file path for uploads
|
|
42
|
+
*/
|
|
43
|
+
export function generateFilePath(filename: string, prefix = 'uploads'): string {
|
|
44
|
+
const timestamp = Date.now()
|
|
45
|
+
const randomStr = Math.random().toString(36).substring(2, 15)
|
|
46
|
+
const sanitizedFilename = filename.replace(/[^a-zA-Z0-9.-]/g, '_')
|
|
47
|
+
return `${prefix}/${timestamp}-${randomStr}-${sanitizedFilename}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Get the public URL for a file
|
|
52
|
+
*/
|
|
53
|
+
export function getPublicUrl(key: string): string {
|
|
54
|
+
return `${PUBLIC_URL}/${key}`
|
|
55
|
+
}
|