@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.
Files changed (77) hide show
  1. package/dist/chunk-E4HZYXQ2.js +36 -0
  2. package/dist/chunk-E4HZYXQ2.js.map +1 -0
  3. package/dist/cli.js +799 -4586
  4. package/dist/cli.js.map +1 -1
  5. package/dist/reader-2T45D7JZ.js +7 -0
  6. package/package.json +1 -1
  7. package/templates/init/api/auth-route.ts +3 -0
  8. package/templates/init/api/upload-route.ts +74 -0
  9. package/templates/init/cms-globals.css +200 -0
  10. package/templates/init/components/data-table/data-table-pagination.tsx +90 -0
  11. package/templates/init/components/data-table/data-table-toolbar.tsx +93 -0
  12. package/templates/init/components/data-table/data-table.tsx +188 -0
  13. package/templates/init/components/layout/cms-header.tsx +32 -0
  14. package/templates/init/components/layout/cms-nav-link.tsx +25 -0
  15. package/templates/init/components/layout/cms-providers.tsx +33 -0
  16. package/templates/init/components/layout/cms-search.tsx +25 -0
  17. package/templates/init/components/layout/cms-sidebar.tsx +192 -0
  18. package/templates/init/components/layout/cms-sign-out.tsx +30 -0
  19. package/templates/init/components/shared/delete-dialog.tsx +75 -0
  20. package/templates/init/components/shared/page-header.tsx +23 -0
  21. package/templates/init/components/shared/status-badge.tsx +43 -0
  22. package/templates/init/data/navigation.ts +39 -0
  23. package/templates/init/db/client.ts +8 -0
  24. package/templates/init/db/schema.ts +88 -0
  25. package/{dist/chunk-6JCWMKSY.js → templates/init/drizzle.config.ts} +2 -11
  26. package/templates/init/hooks/use-cms-theme.tsx +78 -0
  27. package/templates/init/hooks/use-editor-image-upload.ts +82 -0
  28. package/templates/init/hooks/use-local-storage.ts +46 -0
  29. package/templates/init/hooks/use-mobile.ts +19 -0
  30. package/templates/init/hooks/use-upload.ts +177 -0
  31. package/templates/init/hooks/use-users.ts +13 -0
  32. package/templates/init/lib/actions/form-settings.ts +126 -0
  33. package/templates/init/lib/actions/profile.ts +62 -0
  34. package/templates/init/lib/actions/upload.ts +153 -0
  35. package/templates/init/lib/actions/users.ts +145 -0
  36. package/templates/init/lib/auth/auth-client.ts +12 -0
  37. package/templates/init/lib/auth/auth.ts +43 -0
  38. package/templates/init/lib/auth/middleware.ts +44 -0
  39. package/templates/init/lib/markdown/cached.ts +7 -0
  40. package/templates/init/lib/markdown/format.ts +55 -0
  41. package/templates/init/lib/markdown/render.ts +182 -0
  42. package/templates/init/lib/r2.ts +55 -0
  43. package/templates/init/pages/account-layout.tsx +63 -0
  44. package/templates/init/pages/authenticated-layout.tsx +26 -0
  45. package/templates/init/pages/cms-layout.tsx +16 -0
  46. package/templates/init/pages/dashboard-page.tsx +91 -0
  47. package/templates/init/pages/login-form.tsx +117 -0
  48. package/templates/init/pages/login-page.tsx +17 -0
  49. package/templates/init/pages/profile/profile-form.tsx +361 -0
  50. package/templates/init/pages/profile/profile-page.tsx +34 -0
  51. package/templates/init/pages/users/columns.tsx +241 -0
  52. package/templates/init/pages/users/create-user-dialog.tsx +116 -0
  53. package/templates/init/pages/users/edit-role-dialog.tsx +92 -0
  54. package/templates/init/pages/users/users-page-content.tsx +29 -0
  55. package/templates/init/pages/users/users-page.tsx +19 -0
  56. package/templates/init/pages/users/users-table.tsx +219 -0
  57. package/templates/init/types/auth.ts +78 -0
  58. package/templates/init/types/index.ts +79 -0
  59. package/templates/init/types/table-meta.ts +16 -0
  60. package/templates/init/utils/cn.ts +6 -0
  61. package/templates/init/utils/mailchimp.ts +39 -0
  62. package/templates/init/utils/seo.ts +90 -0
  63. package/templates/init/utils/validation.ts +105 -0
  64. package/templates/init/utils/webhook.ts +28 -0
  65. package/templates/ui/alert-dialog.tsx +46 -28
  66. package/templates/ui/avatar.tsx +37 -20
  67. package/templates/ui/button.tsx +3 -3
  68. package/templates/ui/card.tsx +30 -18
  69. package/templates/ui/dialog.tsx +46 -22
  70. package/templates/ui/dropdown-menu.tsx +1 -1
  71. package/templates/ui/input.tsx +1 -1
  72. package/templates/ui/select.tsx +42 -34
  73. package/templates/ui/sidebar.tsx +13 -13
  74. package/templates/ui/table.tsx +2 -2
  75. package/dist/chunk-6JCWMKSY.js.map +0 -1
  76. package/dist/drizzle-config-EDKOEZ6G.js +0 -7
  77. /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,7 @@
1
+ 'use cache'
2
+
3
+ import { renderMarkdownSync } from './render'
4
+
5
+ export async function renderMarkdown(src: string): Promise<string> {
6
+ return renderMarkdownSync(src)
7
+ }
@@ -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, '&lt;').replace(/>/g, '&gt;')
169
+ return `<div class="code-block-wrapper not-prose" data-lang="${language}"><button class="copy-button" data-code="${escapedCode.replace(/"/g, '&quot;')}" 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
+ }