bunigniter 0.2.0
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/README.md +229 -0
- package/dist/LICENSE +21 -0
- package/dist/README.md +229 -0
- package/dist/base/controller.ts +324 -0
- package/dist/base/index.ts +5 -0
- package/dist/base/service.ts +21 -0
- package/dist/cli/index.ts +318 -0
- package/dist/cli/list-routes.ts +72 -0
- package/dist/cli/repl.ts +461 -0
- package/dist/cli/templates.ts +283 -0
- package/dist/client/index.ts +159 -0
- package/dist/db/drizzle.ts +550 -0
- package/dist/db/validators.ts +229 -0
- package/dist/edge-builder.ts +120 -0
- package/dist/edge.ts +69 -0
- package/dist/helpers/cache.ts +173 -0
- package/dist/helpers/cors.ts +103 -0
- package/dist/helpers/csrf.ts +155 -0
- package/dist/helpers/debug.ts +158 -0
- package/dist/helpers/env.ts +147 -0
- package/dist/helpers/handler.ts +158 -0
- package/dist/helpers/http.ts +194 -0
- package/dist/helpers/image.ts +217 -0
- package/dist/helpers/jwt.ts +147 -0
- package/dist/helpers/logger.ts +96 -0
- package/dist/helpers/mail.ts +272 -0
- package/dist/helpers/middleware-loader.ts +116 -0
- package/dist/helpers/middleware.ts +57 -0
- package/dist/helpers/modules.ts +115 -0
- package/dist/helpers/openapi.ts +140 -0
- package/dist/helpers/pagination.ts +159 -0
- package/dist/helpers/queue.ts +186 -0
- package/dist/helpers/request-context.ts +13 -0
- package/dist/helpers/request.ts +376 -0
- package/dist/helpers/schedule.ts +173 -0
- package/dist/helpers/session-middleware.ts +89 -0
- package/dist/helpers/session.ts +286 -0
- package/dist/helpers/sse.ts +90 -0
- package/dist/helpers/throttle.ts +156 -0
- package/dist/helpers/upload.ts +417 -0
- package/dist/helpers/validator.ts +287 -0
- package/dist/helpers/ws.ts +123 -0
- package/dist/index.ts +221 -0
- package/dist/package.json +70 -0
- package/dist/router/file-router.ts +541 -0
- package/dist/router/server-router.ts +103 -0
- package/dist/view/page.ts +96 -0
- package/dist/view/renderer.tsx +390 -0
- package/dist/view/view-response.ts +10 -0
- package/package.json +70 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page — Inertia-style page response for server-rendered views.
|
|
3
|
+
*
|
|
4
|
+
* Combines a component name with props and renders either:
|
|
5
|
+
* - Full HTML (first request, SEO-friendly)
|
|
6
|
+
* - JSON page object (subsequent Inertia navigation)
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* // pages/users.ts
|
|
11
|
+
* export class Users extends Controller {
|
|
12
|
+
* async index() {
|
|
13
|
+
* const users = await this.db.query('SELECT * FROM users')
|
|
14
|
+
* return this.page('Users/Index', { users })
|
|
15
|
+
* }
|
|
16
|
+
* }
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export interface PageOptions {
|
|
20
|
+
/** HTTP status code. Default: 200 */
|
|
21
|
+
status?: number
|
|
22
|
+
|
|
23
|
+
/** Page title (injected into HTML shell). */
|
|
24
|
+
title?: string
|
|
25
|
+
|
|
26
|
+
/** Shared props merged with component props. */
|
|
27
|
+
shared?: Record<string, any>
|
|
28
|
+
|
|
29
|
+
/** Layout to wrap the page. */
|
|
30
|
+
layout?: string | false
|
|
31
|
+
|
|
32
|
+
/** Flash data (shown once then cleared). */
|
|
33
|
+
flash?: Record<string, any>
|
|
34
|
+
|
|
35
|
+
/** Asset version for cache busting. */
|
|
36
|
+
version?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Page response — returned from a controller to render a full page.
|
|
41
|
+
*/
|
|
42
|
+
export class PageResponse {
|
|
43
|
+
public readonly component: string
|
|
44
|
+
public readonly props: Record<string, any>
|
|
45
|
+
public readonly options: PageOptions
|
|
46
|
+
|
|
47
|
+
constructor(component: string, props: Record<string, any> = {}, options: PageOptions = {}) {
|
|
48
|
+
this.component = component
|
|
49
|
+
this.props = props
|
|
50
|
+
this.options = options
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Get the page object as JSON (for Inertia protocol). */
|
|
54
|
+
toInertiaJson(sharedProps: Record<string, any> = {}): string {
|
|
55
|
+
return JSON.stringify({
|
|
56
|
+
component: this.component,
|
|
57
|
+
props: { ...sharedProps, ...this.props },
|
|
58
|
+
url: '', // set by the router
|
|
59
|
+
version: this.options.version ?? null,
|
|
60
|
+
flash: this.options.flash ?? null,
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Render full HTML shell with embedded page data. */
|
|
65
|
+
toHtml(sharedProps: Record<string, any> = {}, url = '/'): string {
|
|
66
|
+
const pageJson = JSON.stringify({
|
|
67
|
+
component: this.component,
|
|
68
|
+
props: { ...sharedProps, ...this.props },
|
|
69
|
+
url,
|
|
70
|
+
version: this.options.version ?? null,
|
|
71
|
+
flash: this.options.flash ?? null,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const title = this.options.title ?? this.component
|
|
75
|
+
const escapedPage = pageJson
|
|
76
|
+
.replace(/&/g, '&')
|
|
77
|
+
.replace(/'/g, ''')
|
|
78
|
+
.replace(/"/g, '"')
|
|
79
|
+
|
|
80
|
+
return `<!DOCTYPE html>
|
|
81
|
+
<html lang="en">
|
|
82
|
+
<head>
|
|
83
|
+
<meta charset="UTF-8">
|
|
84
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
85
|
+
<title>${escapeHtml(title)}</title>
|
|
86
|
+
</head>
|
|
87
|
+
<body>
|
|
88
|
+
<div id="app" data-page='${escapedPage}'></div>
|
|
89
|
+
</body>
|
|
90
|
+
</html>`
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function escapeHtml(s: string): string {
|
|
95
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
96
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* View Renderer — Simple server-side React rendering for Bunigniter.
|
|
3
|
+
*
|
|
4
|
+
* Controllers return `this.view('ComponentName', { props })` and the
|
|
5
|
+
* framework SSR-renders the React component to HTML.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* // views/TodoList.tsx (React component)
|
|
10
|
+
* export default function TodoList({ todos, stats }: any) {
|
|
11
|
+
* return (
|
|
12
|
+
* <div>
|
|
13
|
+
* <h1>📋 Todo App</h1>
|
|
14
|
+
* <ul>{todos.map((t: any) => <li key={t.id}>{t.title}</li>)}</ul>
|
|
15
|
+
* </div>
|
|
16
|
+
* )
|
|
17
|
+
* }
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* // pages/todos.ts (Controller)
|
|
22
|
+
* async index() {
|
|
23
|
+
* const todos = await this.db.query('SELECT * FROM todos')
|
|
24
|
+
* return this.view('TodoList', { todos: todos.rows })
|
|
25
|
+
* }
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
29
|
+
import { join, extname } from 'node:path'
|
|
30
|
+
import React from 'react'
|
|
31
|
+
import { renderToString } from 'react-dom/server'
|
|
32
|
+
|
|
33
|
+
/** Registry of view components. */
|
|
34
|
+
const registry = new Map<string, any>()
|
|
35
|
+
|
|
36
|
+
/** MDX modules cache. */
|
|
37
|
+
const mdxCache = new Map<string, any>()
|
|
38
|
+
|
|
39
|
+
/** Views directory. */
|
|
40
|
+
let viewsDir = 'views'
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Set the views directory.
|
|
44
|
+
* Called automatically from config.
|
|
45
|
+
*/
|
|
46
|
+
export function setViewsDir(dir: string): void {
|
|
47
|
+
viewsDir = dir
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Register a view component.
|
|
52
|
+
* Called automatically when the view file is first loaded.
|
|
53
|
+
*/
|
|
54
|
+
export function registerView(name: string, component: any): void {
|
|
55
|
+
registry.set(name, component)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Render a view component to an HTML string.
|
|
60
|
+
*
|
|
61
|
+
* @param name - Component name (e.g. 'TodoList')
|
|
62
|
+
* @param props - Props object passed to the component
|
|
63
|
+
* @param options - Rendering options
|
|
64
|
+
* @returns HTML string
|
|
65
|
+
*/
|
|
66
|
+
export async function renderView(
|
|
67
|
+
name: string,
|
|
68
|
+
props: Record<string, any> = {},
|
|
69
|
+
options: { title?: string; scripts?: string[] } = {},
|
|
70
|
+
viewBase?: string
|
|
71
|
+
): Promise<string | Response> {
|
|
72
|
+
// Try to load the component from views/ directory
|
|
73
|
+
let Component = registry.get(name)
|
|
74
|
+
|
|
75
|
+
if (!Component) {
|
|
76
|
+
const base = viewBase ?? join(process.cwd(), viewsDir)
|
|
77
|
+
const candidates = [
|
|
78
|
+
join(base, `${name}.tsx`),
|
|
79
|
+
join(base, `${name}.mdx`),
|
|
80
|
+
join(base, `${name}.md`),
|
|
81
|
+
join(base, `${name}.html`),
|
|
82
|
+
join(base, name, 'index.tsx'),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
let targetPath: string | null = null
|
|
86
|
+
for (const p of candidates) {
|
|
87
|
+
if (existsSync(p)) { targetPath = p; break }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (targetPath) {
|
|
91
|
+
const ext = extname(targetPath)
|
|
92
|
+
if (ext === '.html') {
|
|
93
|
+
// HTML templates use Rendu engine (PHP-like <?= ?> syntax)
|
|
94
|
+
return renderHTML(targetPath, props)
|
|
95
|
+
} else if (ext === '.mdx' || ext === '.md') {
|
|
96
|
+
// MDX with Rendu support (<?= ?>, {{ }}) + markdown rendering
|
|
97
|
+
return renderMDXView(targetPath, props)
|
|
98
|
+
} else {
|
|
99
|
+
const mod = await import(targetPath)
|
|
100
|
+
Component = mod.default ?? mod[name]
|
|
101
|
+
}
|
|
102
|
+
if (Component) {
|
|
103
|
+
registry.set(name, Component)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!Component) {
|
|
109
|
+
return renderFallback(name, props, options)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const html = renderToString(React.createElement(Component, props))
|
|
114
|
+
const title = options.title ?? name
|
|
115
|
+
const serialized = JSON.stringify({ component: name, props })
|
|
116
|
+
const scripts = (options.scripts ?? []).map(s => `<script src="${s}"></script>`).join('\n')
|
|
117
|
+
return `<!DOCTYPE html>
|
|
118
|
+
<html lang="en">
|
|
119
|
+
<head>
|
|
120
|
+
<meta charset="UTF-8">
|
|
121
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
122
|
+
<title>${escapeHtml(title)}</title>
|
|
123
|
+
</head>
|
|
124
|
+
<body>
|
|
125
|
+
<div id="app">${html}</div>
|
|
126
|
+
<script id="__NEXUS_DATA__" type="application/json">${escapeHtml(serialized)}</script>
|
|
127
|
+
${scripts}
|
|
128
|
+
</body>
|
|
129
|
+
</html>`
|
|
130
|
+
} catch (err: any) {
|
|
131
|
+
return renderError(err, name, props)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Fallback: render data-page shell (same as before). */
|
|
136
|
+
function renderFallback(name: string, props: Record<string, any>, options: { title?: string; scripts?: string[] }): string {
|
|
137
|
+
const title = options.title ?? name
|
|
138
|
+
const serialized = JSON.stringify({ component: name, props })
|
|
139
|
+
const scripts = (options.scripts ?? []).map(s => `<script src="${s}"></script>`).join('\n')
|
|
140
|
+
|
|
141
|
+
return `<!DOCTYPE html>
|
|
142
|
+
<html lang="en">
|
|
143
|
+
<head>
|
|
144
|
+
<meta charset="UTF-8">
|
|
145
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
146
|
+
<title>${escapeHtml(title)}</title>
|
|
147
|
+
</head>
|
|
148
|
+
<body>
|
|
149
|
+
<div id="app" data-page='${escapeHtml(serialized)}'></div>
|
|
150
|
+
${scripts}
|
|
151
|
+
</body>
|
|
152
|
+
</html>`
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Compile a plain HTML file using Rendu (PHP-like template engine).
|
|
157
|
+
* Supports <?= expr ?>, <?js code ?>, {{ expr }}, {{{ expr }}} syntax.
|
|
158
|
+
*/
|
|
159
|
+
function compileHTML(filePath: string, props: Record<string, any>): any {
|
|
160
|
+
const source = readFileSync(filePath, 'utf-8')
|
|
161
|
+
|
|
162
|
+
// Build a component that renders the template at request time
|
|
163
|
+
const HtmlComponent = () => {
|
|
164
|
+
// Use React state/effect to render on mount, but for SSR we need sync
|
|
165
|
+
// Actually, return a placeholder that gets stream-rendered
|
|
166
|
+
throw new Error('Use renderHTML() instead of React for .html templates')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return HtmlComponent
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Render an HTML template using Rendu engine.
|
|
174
|
+
* Handles PHP-style <?= ?>, control flow <?js ?>, and {{ }} mustache syntax.
|
|
175
|
+
* Returns a Promise<Response> with rendered HTML.
|
|
176
|
+
*/
|
|
177
|
+
export async function renderHTML(filePath: string, props: Record<string, any> = {}): Promise<Response> {
|
|
178
|
+
const source = readFileSync(filePath, 'utf-8')
|
|
179
|
+
|
|
180
|
+
// Compile template using Rendu
|
|
181
|
+
const { compileTemplate } = await import('rendu')
|
|
182
|
+
|
|
183
|
+
/** Include a sub-template (partial) file. */
|
|
184
|
+
const includeSub = async (name: string): Promise<string> => {
|
|
185
|
+
const dir = filePath.substring(0, filePath.lastIndexOf('/'))
|
|
186
|
+
let subPath = join(dir, name)
|
|
187
|
+
if (!existsSync(subPath)) {
|
|
188
|
+
const ext = name.endsWith('.html') ? '' : '.html'
|
|
189
|
+
subPath = join(dir, name + ext)
|
|
190
|
+
}
|
|
191
|
+
if (!existsSync(subPath)) return `<!-- missing partial: ${name} -->`
|
|
192
|
+
|
|
193
|
+
const subSource = readFileSync(subPath, 'utf-8')
|
|
194
|
+
const subFn = compileTemplate(subSource)
|
|
195
|
+
const subStream = await subFn(ctx)
|
|
196
|
+
const reader = subStream.getReader()
|
|
197
|
+
let result = ''
|
|
198
|
+
while (true) {
|
|
199
|
+
const { done, value } = await reader.read()
|
|
200
|
+
if (done) break
|
|
201
|
+
result += new TextDecoder().decode(value)
|
|
202
|
+
}
|
|
203
|
+
return result
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const fn = compileTemplate(source)
|
|
207
|
+
const ctx = {
|
|
208
|
+
htmlspecialchars: (s: unknown) =>
|
|
209
|
+
String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'),
|
|
210
|
+
include: async (name: string) => await includeSub(name),
|
|
211
|
+
...props,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Execute with props + rendu context
|
|
215
|
+
const stream = await fn(ctx)
|
|
216
|
+
|
|
217
|
+
// Collect stream into string
|
|
218
|
+
const reader = stream.getReader()
|
|
219
|
+
const chunks: Uint8Array[] = []
|
|
220
|
+
while (true) {
|
|
221
|
+
const { done, value } = await reader.read()
|
|
222
|
+
if (done) break
|
|
223
|
+
chunks.push(value)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let html = new TextDecoder().decode(concatUint8Arrays(chunks))
|
|
227
|
+
|
|
228
|
+
// Auto-layout: wrap with _layout.html from same directory
|
|
229
|
+
const layoutPath = join(filePath.substring(0, filePath.lastIndexOf('/')), '_layout.html')
|
|
230
|
+
if (existsSync(layoutPath)) {
|
|
231
|
+
const layoutSource = readFileSync(layoutPath, 'utf-8')
|
|
232
|
+
const layoutFn = compileTemplate(layoutSource)
|
|
233
|
+
const layoutStream = await layoutFn({
|
|
234
|
+
htmlspecialchars: (s: unknown) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'),
|
|
235
|
+
slot: html,
|
|
236
|
+
...props,
|
|
237
|
+
})
|
|
238
|
+
const layoutReader = layoutStream.getReader()
|
|
239
|
+
const layoutChunks: Uint8Array[] = []
|
|
240
|
+
while (true) {
|
|
241
|
+
const { done, value } = await layoutReader.read()
|
|
242
|
+
if (done) break
|
|
243
|
+
layoutChunks.push(value)
|
|
244
|
+
}
|
|
245
|
+
html = new TextDecoder().decode(concatUint8Arrays(layoutChunks))
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return new Response(html, {
|
|
249
|
+
headers: { 'content-type': 'text/html; charset=utf-8' }
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function concatUint8Arrays(arrays: Uint8Array[]): Uint8Array {
|
|
254
|
+
const total = arrays.reduce((s, a) => s + a.length, 0)
|
|
255
|
+
const result = new Uint8Array(total)
|
|
256
|
+
let offset = 0
|
|
257
|
+
for (const a of arrays) {
|
|
258
|
+
result.set(a, offset)
|
|
259
|
+
offset += a.length
|
|
260
|
+
}
|
|
261
|
+
return result
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Render an MDX view file to HTML using Rendu for interpolation
|
|
266
|
+
* and Bun.markdown.react() for markdown rendering.
|
|
267
|
+
*
|
|
268
|
+
* Supports:
|
|
269
|
+
* <?= expr ?> — PHP-style output (Rendu)
|
|
270
|
+
* {{ expr }} — Jinja-style escaped output (Rendu)
|
|
271
|
+
* {{{ expr }}} — Jinja-style raw output (Rendu)
|
|
272
|
+
* <?js code ?> — JavaScript control flow (Rendu)
|
|
273
|
+
*
|
|
274
|
+
* Then standard markdown via Bun.markdown.react().
|
|
275
|
+
*/
|
|
276
|
+
export async function renderMDXView(filePath: string, props: Record<string, any> = {}): Promise<Response> {
|
|
277
|
+
const source = readFileSync(filePath, 'utf-8')
|
|
278
|
+
let content = source.replace(/^---[\s\S]*?---\n?/, '')
|
|
279
|
+
|
|
280
|
+
// Step 1: Pre-render partials (_*.html) and replace include() calls
|
|
281
|
+
// with their rendered HTML (added AFTER markdown rendering to avoid conflicts)
|
|
282
|
+
const renderedPartials: string[] = []
|
|
283
|
+
let processed = content.replace(
|
|
284
|
+
/<\?=\s*await\s+include\s*\(\s*['"]([^'"]+)['"]\s*\)\s*\?>/g,
|
|
285
|
+
(_match: string, partialName: string) => {
|
|
286
|
+
const placeholder = `<!--INCLUDE_PLACEHOLDER_${renderedPartials.length}-->`
|
|
287
|
+
renderedPartials.push(partialName)
|
|
288
|
+
return placeholder
|
|
289
|
+
}
|
|
290
|
+
)
|
|
291
|
+
.replace(/`<\?=(.+?)`/g, '`<?=$1`')
|
|
292
|
+
.replace(/`<\?js(.+?)\?>`/g, '`<?js$1?>`')
|
|
293
|
+
|
|
294
|
+
// Step 2: Render through Rendu for <?= ?> and {{ }} interpolation
|
|
295
|
+
const { compileTemplate } = await import('rendu')
|
|
296
|
+
const fn = compileTemplate(processed)
|
|
297
|
+
const ctx = {
|
|
298
|
+
htmlspecialchars: (s: unknown) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'),
|
|
299
|
+
...props,
|
|
300
|
+
}
|
|
301
|
+
const stream = await fn(ctx)
|
|
302
|
+
const reader = stream.getReader()
|
|
303
|
+
let interpolated = ''
|
|
304
|
+
while (true) {
|
|
305
|
+
const { done, value } = await reader.read()
|
|
306
|
+
if (done) break
|
|
307
|
+
interpolated += new TextDecoder().decode(value)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Step 3: Split at placeholders, render markdown parts, insert rendered partials
|
|
311
|
+
const parts = interpolated.split(/<!--INCLUDE_PLACEHOLDER_\d+-->/)
|
|
312
|
+
let finalHtml = ''
|
|
313
|
+
|
|
314
|
+
const mdOverrides = {
|
|
315
|
+
h1: function(p: any) { return React.createElement('h1', { id: p.id, style: { color: '#e94560' } }, p.children) },
|
|
316
|
+
a: function(p: any) { return React.createElement('a', { href: p.href, style: { color: '#70a1ff' } }, p.children) },
|
|
317
|
+
pre: function(p: any) { return React.createElement('pre', { style: { background: '#1a1a3e', padding: 16, borderRadius: 8, overflow: 'auto' } }, p.children) },
|
|
318
|
+
code: function(p: any) { return React.createElement('code', { style: { background: '#333', padding: '2px 6px', borderRadius: 3, fontSize: '0.9em' } }, p.children) },
|
|
319
|
+
}
|
|
320
|
+
const mdOptions = { tables: true, strikethrough: true, tasklists: true, autolinks: true, headings: { ids: true } }
|
|
321
|
+
|
|
322
|
+
for (let i = 0; i < parts.length; i++) {
|
|
323
|
+
const mdPart = parts[i].trim()
|
|
324
|
+
if (mdPart) {
|
|
325
|
+
const elements = Bun.markdown.react(mdPart, mdOverrides, mdOptions)
|
|
326
|
+
finalHtml += renderToString(elements)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Insert rendered partial after each markdown part (except the last)
|
|
330
|
+
if (i < renderedPartials.length) {
|
|
331
|
+
const partialName = renderedPartials[i]
|
|
332
|
+
const dir = filePath.substring(0, filePath.lastIndexOf('/'))
|
|
333
|
+
let subPath = join(dir, partialName)
|
|
334
|
+
if (!existsSync(subPath)) subPath = join(dir, partialName + (partialName.endsWith('.html') ? '' : '.html'))
|
|
335
|
+
|
|
336
|
+
if (existsSync(subPath)) {
|
|
337
|
+
const subSource = readFileSync(subPath, 'utf-8')
|
|
338
|
+
const subFn = compileTemplate(subSource)
|
|
339
|
+
const subStream = await subFn(ctx)
|
|
340
|
+
const subReader = subStream.getReader()
|
|
341
|
+
let subHtml = ''
|
|
342
|
+
while (true) {
|
|
343
|
+
const { done, value } = await subReader.read()
|
|
344
|
+
if (done) break
|
|
345
|
+
subHtml += new TextDecoder().decode(value)
|
|
346
|
+
}
|
|
347
|
+
finalHtml += subHtml
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Auto-layout: wrap with _layout.html from same directory
|
|
353
|
+
const mdxLayoutPath = join(filePath.substring(0, filePath.lastIndexOf('/')), '_layout.html')
|
|
354
|
+
if (existsSync(mdxLayoutPath)) {
|
|
355
|
+
const layoutSource = readFileSync(mdxLayoutPath, 'utf-8')
|
|
356
|
+
const layoutFn = compileTemplate(layoutSource)
|
|
357
|
+
const layoutStream = await layoutFn({
|
|
358
|
+
htmlspecialchars: (s: unknown) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'),
|
|
359
|
+
slot: finalHtml,
|
|
360
|
+
...props,
|
|
361
|
+
})
|
|
362
|
+
const lr = layoutStream.getReader()
|
|
363
|
+
const lc: Uint8Array[] = []
|
|
364
|
+
while (true) { const { done, value } = await lr.read(); if (done) break; lc.push(value) }
|
|
365
|
+
finalHtml = new TextDecoder().decode(concatUint8Arrays(lc))
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Wrap in full HTML document
|
|
369
|
+
return new Response(finalHtml, {
|
|
370
|
+
headers: { 'content-type': 'text/html; charset=utf-8' }
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Error page. */
|
|
375
|
+
function renderError(err: Error, name: string, props: Record<string, any>): string {
|
|
376
|
+
return `<!DOCTYPE html>
|
|
377
|
+
<html lang="en">
|
|
378
|
+
<head><meta charset="UTF-8"><title>View Error</title></head>
|
|
379
|
+
<body>
|
|
380
|
+
<h1>Failed to render view: ${escapeHtml(name)}</h1>
|
|
381
|
+
<pre style="color:red">${escapeHtml(err.message)}</pre>
|
|
382
|
+
<pre>${escapeHtml(JSON.stringify(props, null, 2))}</pre>
|
|
383
|
+
</body>
|
|
384
|
+
</html>`
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function escapeHtml(s: string): string {
|
|
388
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
389
|
+
.replace(/"/g, '"').replace(/'/g, ''')
|
|
390
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ViewResponse — returned by Controller.view() for SSR-rendered views.
|
|
3
|
+
*/
|
|
4
|
+
export class ViewResponse {
|
|
5
|
+
constructor(
|
|
6
|
+
public readonly name: string,
|
|
7
|
+
public readonly props: Record<string, any> = {},
|
|
8
|
+
public readonly options: { title?: string; scripts?: string[] } = {}
|
|
9
|
+
) {}
|
|
10
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bunigniter",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Bun-native fullstack framework — CodeIgniter spirit × Elysia performance × Edge-ready",
|
|
5
|
+
"homepage": "https://github.com/nexus-ts/bunigniter",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist/",
|
|
9
|
+
"README.md",
|
|
10
|
+
"LICENSE"
|
|
11
|
+
],
|
|
12
|
+
"main": "dist/index.ts",
|
|
13
|
+
"module": "dist/index.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./dist/index.ts",
|
|
16
|
+
"./base": "./dist/base/index.ts",
|
|
17
|
+
"./controller": "./dist/base/controller.ts",
|
|
18
|
+
"./helpers/env": "./dist/helpers/env.ts",
|
|
19
|
+
"./helpers/validator": "./dist/helpers/validator.ts",
|
|
20
|
+
"./helpers/http": "./dist/helpers/http.ts",
|
|
21
|
+
"./helpers/image": "./dist/helpers/image.ts",
|
|
22
|
+
"./helpers/pagination": "./dist/helpers/pagination.ts",
|
|
23
|
+
"./helpers/session": "./dist/helpers/session.ts",
|
|
24
|
+
"./helpers/cache": "./dist/helpers/cache.ts",
|
|
25
|
+
"./helpers/queue": "./dist/helpers/queue.ts",
|
|
26
|
+
"./helpers/upload": "./dist/helpers/upload.ts",
|
|
27
|
+
"./helpers/mail": "./dist/helpers/mail.ts",
|
|
28
|
+
"./helpers/modules": "./dist/helpers/modules.ts",
|
|
29
|
+
"./helpers/openapi": "./dist/helpers/openapi.ts",
|
|
30
|
+
"./helpers/jwt": "./dist/helpers/jwt.ts",
|
|
31
|
+
"./helpers/ws": "./dist/helpers/ws.ts",
|
|
32
|
+
"./helpers/sse": "./dist/helpers/sse.ts",
|
|
33
|
+
"./helpers/schedule": "./dist/helpers/schedule.ts"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build:dist": "bun run scripts/build-dist.ts",
|
|
37
|
+
"prepublishOnly": "bun run build:dist",
|
|
38
|
+
"postpublish": "rm -rf dist",
|
|
39
|
+
"dev": "bun --hot run src/index.ts",
|
|
40
|
+
"start": "bun run src/index.ts",
|
|
41
|
+
"build": "bun build ./src/index.ts --outdir ./dist --target bun",
|
|
42
|
+
"bi": "bun run src/cli/index.ts",
|
|
43
|
+
"make:controller": "bun run src/cli/index.ts make:controller",
|
|
44
|
+
"make:model": "bun run src/cli/index.ts make:model",
|
|
45
|
+
"test": "bun x vitest run",
|
|
46
|
+
"test:smoke": "bun x vitest run tests/smoke.test.ts",
|
|
47
|
+
"test:unit": "bun x vitest run tests/ --exclude 'tests/smoke*' ",
|
|
48
|
+
"typecheck": "tsc --noEmit"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"drizzle-orm": "^0.45.0",
|
|
52
|
+
"elysia": "2.0.0-exp.9",
|
|
53
|
+
"openapi-types": "^12.1.3",
|
|
54
|
+
"rendu": "^0.1.0",
|
|
55
|
+
"zod": "^4.4.3"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@types/bun": "^1.3.14",
|
|
59
|
+
"@types/react": "^19.2.17",
|
|
60
|
+
"@types/react-dom": "^19.2.3",
|
|
61
|
+
"react": "^19.2.7",
|
|
62
|
+
"react-dom": "^19.2.7",
|
|
63
|
+
"typescript": "^5.7.0",
|
|
64
|
+
"vitest": "^4.1.9"
|
|
65
|
+
},
|
|
66
|
+
"engines": {
|
|
67
|
+
"bun": ">=1.3.0"
|
|
68
|
+
},
|
|
69
|
+
"license": "MIT"
|
|
70
|
+
}
|