@gravito/monolith 1.0.0-alpha.6 → 1.0.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/ion/src/index.js CHANGED
@@ -2384,7 +2384,7 @@ var require_cjs = __commonJS((exports, module) => {
2384
2384
  });
2385
2385
  // ../core/package.json
2386
2386
  var package_default = {
2387
- name: "gravito-core",
2387
+ name: "@gravito/core",
2388
2388
  version: "1.0.0-beta.2",
2389
2389
  description: "",
2390
2390
  module: "./dist/index.mjs",
@@ -3082,7 +3082,7 @@ async function handleProcessError(kind, error) {
3082
3082
  }
3083
3083
  }));
3084
3084
  } catch (e) {
3085
- console.error("[gravito-core] Failed to handle process-level error:", e);
3085
+ console.error("[@gravito/core] Failed to handle process-level error:", e);
3086
3086
  } finally {
3087
3087
  if (shouldExit) {
3088
3088
  clearTimeout(exitTimer);
package/package.json CHANGED
@@ -1,32 +1,46 @@
1
1
  {
2
2
  "name": "@gravito/monolith",
3
- "version": "1.0.0-alpha.6",
4
- "description": "Content management and Markdown rendering orbit for Gravito",
5
- "main": "dist/index.js",
3
+ "version": "1.0.0",
4
+ "description": "Enterprise monolith framework for Gravito Galaxy",
5
+ "main": "dist/index.cjs",
6
+ "module": "dist/index.js",
7
+ "type": "module",
6
8
  "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
7
16
  "scripts": {
8
- "build": "bun build ./src/index.ts --outdir ./dist --target node",
9
- "test": "bun test"
17
+ "build": "tsup src/index.ts --format esm,cjs --dts",
18
+ "test": "bun test",
19
+ "test:coverage": "bun test --coverage --coverage-threshold=80",
20
+ "typecheck": "bun tsc -p tsconfig.json --noEmit --skipLibCheck"
10
21
  },
11
22
  "keywords": [
12
23
  "gravito",
13
24
  "orbit",
14
- "content",
15
- "markdown",
16
- "cms"
25
+ "monolith",
26
+ "backend"
17
27
  ],
18
28
  "author": "Carl Lee <carllee0520@gmail.com>",
19
29
  "license": "MIT",
20
- "peerDependencies": {
21
- "gravito-core": "1.0.0-beta.5"
22
- },
23
30
  "dependencies": {
24
31
  "marked": "^11.1.1",
25
- "gray-matter": "^4.0.3"
32
+ "gray-matter": "^4.0.3",
33
+ "@gravito/mass": "workspace:*"
34
+ },
35
+ "peerDependencies": {
36
+ "@gravito/core": "workspace:*"
26
37
  },
27
38
  "devDependencies": {
28
- "bun-types": "latest",
29
- "@types/marked": "^5.0.0"
39
+ "@gravito/core": "workspace:*",
40
+ "@types/marked": "^5.0.0",
41
+ "bun-types": "^1.3.5",
42
+ "tsup": "^8.5.1",
43
+ "typescript": "^5.9.3"
30
44
  },
31
45
  "publishConfig": {
32
46
  "access": "public"
@@ -37,4 +51,4 @@
37
51
  "url": "git+https://github.com/gravito-framework/gravito.git",
38
52
  "directory": "packages/monolith"
39
53
  }
40
- }
54
+ }
@@ -20,6 +20,19 @@ export class ContentManager {
20
20
  private collections = new Map<string, CollectionConfig>()
21
21
  // Simple memory cache: collection:locale:slug -> ContentItem
22
22
  private cache = new Map<string, ContentItem>()
23
+ private renderer = (() => {
24
+ const renderer = new marked.Renderer()
25
+ renderer.html = (html: string) => this.escapeHtml(html)
26
+ renderer.link = (href: string | null, title: string | null, text: string) => {
27
+ if (!href || !this.isSafeUrl(href)) {
28
+ return text
29
+ }
30
+ const safeHref = this.escapeHtml(href)
31
+ const titleAttr = title ? ` title="${this.escapeHtml(title)}"` : ''
32
+ return `<a href="${safeHref}"${titleAttr}>${text}</a>`
33
+ }
34
+ return renderer
35
+ })()
23
36
 
24
37
  /**
25
38
  * Create a new ContentManager instance.
@@ -53,6 +66,12 @@ export class ContentManager {
53
66
  throw new Error(`Collection '${collectionName}' not defined`)
54
67
  }
55
68
 
69
+ const safeSlug = this.sanitizeSegment(slug)
70
+ const safeLocale = this.sanitizeSegment(locale)
71
+ if (!safeSlug || !safeLocale) {
72
+ return null
73
+ }
74
+
56
75
  const cacheKey = `${collectionName}:${locale}:${slug}`
57
76
  if (this.cache.has(cacheKey)) {
58
77
  return this.cache.get(cacheKey)!
@@ -60,7 +79,7 @@ export class ContentManager {
60
79
 
61
80
  // Determine path strategy
62
81
  // Strategy: {root}/{path}/{locale}/{slug}.md
63
- const filePath = join(this.rootDir, config.path, locale, `${slug}.md`)
82
+ const filePath = join(this.rootDir, config.path, safeLocale, `${safeSlug}.md`)
64
83
 
65
84
  try {
66
85
  const exists = await stat(filePath)
@@ -71,16 +90,16 @@ export class ContentManager {
71
90
  }
72
91
 
73
92
  const fileContent = await readFile(filePath, 'utf-8')
74
- const { data, content, exemption } = matter(fileContent)
93
+ const { data, content, excerpt } = matter(fileContent)
75
94
 
76
- const html = await marked.parse(content)
95
+ const html = await marked.parse(content, { renderer: this.renderer })
77
96
 
78
97
  const item: ContentItem = {
79
98
  slug,
80
99
  body: html,
81
100
  meta: data,
82
101
  raw: content,
83
- excerpt: exemption,
102
+ excerpt: excerpt,
84
103
  }
85
104
 
86
105
  this.cache.set(cacheKey, item)
@@ -106,7 +125,12 @@ export class ContentManager {
106
125
  throw new Error(`Collection '${collectionName}' not defined`)
107
126
  }
108
127
 
109
- const dirPath = join(this.rootDir, config.path, locale)
128
+ const safeLocale = this.sanitizeSegment(locale)
129
+ if (!safeLocale) {
130
+ return []
131
+ }
132
+
133
+ const dirPath = join(this.rootDir, config.path, safeLocale)
110
134
 
111
135
  try {
112
136
  const files = await readdir(dirPath)
@@ -117,7 +141,7 @@ export class ContentManager {
117
141
  continue
118
142
  }
119
143
  const slug = parse(file).name
120
- const item = await this.find(collectionName, slug, locale)
144
+ const item = await this.find(collectionName, slug, safeLocale)
121
145
  if (item) {
122
146
  items.push(item)
123
147
  }
@@ -129,4 +153,50 @@ export class ContentManager {
129
153
  return []
130
154
  }
131
155
  }
156
+
157
+ private sanitizeSegment(value: string): string | null {
158
+ if (!value) {
159
+ return null
160
+ }
161
+ if (value.includes('\0')) {
162
+ return null
163
+ }
164
+ if (value.includes('/') || value.includes('\\')) {
165
+ return null
166
+ }
167
+ if (value.includes('..')) {
168
+ return null
169
+ }
170
+ return value
171
+ }
172
+
173
+ private escapeHtml(value: string): string {
174
+ return value
175
+ .replace(/&/g, '&amp;')
176
+ .replace(/</g, '&lt;')
177
+ .replace(/>/g, '&gt;')
178
+ .replace(/"/g, '&quot;')
179
+ .replace(/'/g, '&#39;')
180
+ }
181
+
182
+ private isSafeUrl(href: string): boolean {
183
+ const trimmed = href.trim()
184
+ if (!trimmed) {
185
+ return false
186
+ }
187
+ const lower = trimmed.toLowerCase()
188
+ if (
189
+ lower.startsWith('javascript:') ||
190
+ lower.startsWith('vbscript:') ||
191
+ lower.startsWith('data:')
192
+ ) {
193
+ return false
194
+ }
195
+ const schemeMatch = lower.match(/^[a-z][a-z0-9+.-]*:/)
196
+ if (!schemeMatch) {
197
+ return true
198
+ }
199
+ const scheme = schemeMatch[0]
200
+ return scheme === 'http:' || scheme === 'https:' || scheme === 'mailto:'
201
+ }
132
202
  }
@@ -0,0 +1,82 @@
1
+ import type { GravitoContext } from '@gravito/core'
2
+ import { Sanitizer } from './Sanitizer.js'
3
+
4
+ export abstract class BaseController {
5
+ protected sanitizer = new Sanitizer()
6
+
7
+ async call(ctx: GravitoContext, method: string): Promise<Response> {
8
+ const action = (this as any)[method] as (ctx: GravitoContext) => Promise<Response>
9
+ if (typeof action !== 'function') {
10
+ throw new Error(`Method ${method} not found on controller`)
11
+ }
12
+
13
+ return await action.apply(this, [ctx])
14
+ }
15
+ }
16
+
17
+ export abstract class Controller {
18
+ protected context!: GravitoContext
19
+
20
+ /**
21
+ * Set the request context for this controller instance.
22
+ */
23
+ public setContext(context: GravitoContext): this {
24
+ this.context = context
25
+ return this
26
+ }
27
+
28
+ /**
29
+ * Return a JSON response.
30
+ */
31
+ protected json(data: any, status = 200) {
32
+ return this.context.json(data, status as any)
33
+ }
34
+
35
+ /**
36
+ * Return a text response.
37
+ */
38
+ protected text(text: string, status = 200) {
39
+ return this.context.text(text, status as any)
40
+ }
41
+
42
+ /**
43
+ * Redirect to a given URL.
44
+ */
45
+ protected redirect(url: string, status = 302) {
46
+ return this.context.redirect(url, status as any)
47
+ }
48
+
49
+ /**
50
+ * Get an item from the context variables.
51
+ */
52
+ protected get(key: string): any {
53
+ return this.context.get(key as any)
54
+ }
55
+
56
+ /**
57
+ * Get the request object.
58
+ */
59
+ protected get request() {
60
+ return this.context.req
61
+ }
62
+
63
+ /**
64
+ * Validate the request against a schema.
65
+ */
66
+ protected async validate(_schema: any, source: 'json' | 'query' | 'form' = 'json'): Promise<any> {
67
+ const req = this.context.req as any
68
+ return req.valid(source)
69
+ }
70
+
71
+ /**
72
+ * Resolve a controller action into a handler compatible with GravitoContext.
73
+ */
74
+ public static call(method: string): any {
75
+ return async (c: GravitoContext) => {
76
+ const instance = new (this as any)()
77
+ instance.setContext(c)
78
+ const action = instance[method] as (ctx: GravitoContext) => Promise<Response>
79
+ return action.apply(instance, [c])
80
+ }
81
+ }
82
+ }
@@ -0,0 +1,86 @@
1
+ import type { GravitoContext, GravitoNext } from '@gravito/core'
2
+ import { type TSchema, validate } from '@gravito/mass'
3
+ import { Sanitizer } from './Sanitizer.js'
4
+
5
+ export abstract class FormRequest {
6
+ protected context!: GravitoContext
7
+
8
+ /**
9
+ * Define the validation schema.
10
+ */
11
+ abstract schema(): TSchema
12
+
13
+ /**
14
+ * Define the source of data (json, query, form, etc.).
15
+ */
16
+ source(): 'json' | 'query' | 'form' {
17
+ return 'json'
18
+ }
19
+
20
+ /**
21
+ * Determine if the user is authorized to make this request.
22
+ */
23
+ authorize(): boolean {
24
+ return true
25
+ }
26
+
27
+ /**
28
+ * Set the context.
29
+ */
30
+ public setContext(context: GravitoContext): this {
31
+ this.context = context
32
+ return this
33
+ }
34
+
35
+ /**
36
+ * Get the validated data.
37
+ */
38
+ public validated(): any {
39
+ const data = (this.context.req as any).valid(this.source())
40
+ return Sanitizer.clean(data)
41
+ }
42
+
43
+ /**
44
+ * Static helper to create a middleware from this request class.
45
+ */
46
+ public static middleware(): any {
47
+ const RequestClass = this as any
48
+
49
+ return async (c: GravitoContext, next: GravitoNext) => {
50
+ const instance = new RequestClass()
51
+ instance.setContext(c)
52
+
53
+ // 1. Authorization Check
54
+ if (!instance.authorize()) {
55
+ return c.json({ message: 'This action is unauthorized.' }, 403)
56
+ }
57
+
58
+ // 2. Validation using mass
59
+ // Use double cast to bypass strict Hono Context type matching in CI
60
+ const validator = validate(instance.source(), instance.schema(), (result: any, ctx: any) => {
61
+ if (!result.success) {
62
+ const errors: Record<string, string[]> = {}
63
+ const issues = result.error?.issues || []
64
+
65
+ for (const issue of issues) {
66
+ const path = Array.isArray(issue.path) ? issue.path.join('.') : issue.path || 'root'
67
+ const key = path.replace(/^\//, '').replace(/\//g, '.')
68
+ if (!errors[key]) errors[key] = []
69
+ errors[key].push(issue.message || 'Validation failed')
70
+ }
71
+
72
+ return ctx.json(
73
+ {
74
+ message: 'The given data was invalid.',
75
+ errors: Object.keys(errors).length > 0 ? errors : { root: ['Validation failed'] },
76
+ },
77
+ 422
78
+ )
79
+ }
80
+ })
81
+
82
+ // Forced cast to any to ensure CI compatibility
83
+ return (validator as any)(c as any, next as any)
84
+ }
85
+ }
86
+ }
package/src/Router.ts ADDED
@@ -0,0 +1,35 @@
1
+ import type { Hono } from 'hono'
2
+
3
+ export class RouterHelper {
4
+ /**
5
+ * Register standard resource routes for a controller.
6
+ *
7
+ * GET /prefix -> index
8
+ * GET /prefix/create -> create
9
+ * POST /prefix -> store
10
+ * GET /prefix/:id -> show
11
+ * GET /prefix/:id/edit -> edit
12
+ * PUT /prefix/:id -> update
13
+ * DELETE /prefix/:id -> destroy
14
+ */
15
+ public static resource(app: Hono<any, any, any>, prefix: string, controller: any) {
16
+ const p = prefix.startsWith('/') ? prefix : `/${prefix}`
17
+
18
+ // Mapping: Method -> [HTTP Verb, Path Suffix]
19
+ const routes: Record<string, [string, string]> = {
20
+ index: ['get', ''],
21
+ create: ['get', '/create'],
22
+ store: ['post', ''],
23
+ show: ['get', '/:id'],
24
+ edit: ['get', '/:id/edit'],
25
+ update: ['put', '/:id'],
26
+ destroy: ['delete', '/:id'],
27
+ }
28
+
29
+ for (const [method, [verb, suffix]] of Object.entries(routes)) {
30
+ if (typeof controller.prototype[method] === 'function') {
31
+ ;(app as any)[verb](`${p}${suffix}`, controller.call(method))
32
+ }
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Utility to clean up request data.
3
+ */
4
+ export class Sanitizer {
5
+ /**
6
+ * Recursively trim strings and convert empty strings to null.
7
+ */
8
+ public static clean(data: any): any {
9
+ if (!data || typeof data !== 'object') {
10
+ return data
11
+ }
12
+
13
+ const cleaned: any = Array.isArray(data) ? [] : {}
14
+
15
+ for (const key in data) {
16
+ let value = data[key]
17
+
18
+ if (typeof value === 'string') {
19
+ value = value.trim()
20
+ if (value === '') {
21
+ value = null
22
+ }
23
+ } else if (typeof value === 'object' && value !== null) {
24
+ value = this.clean(value)
25
+ }
26
+
27
+ cleaned[key] = value
28
+ }
29
+
30
+ return cleaned
31
+ }
32
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
- import type { GravitoOrbit, PlanetCore } from 'gravito-core'
1
+ import type { GravitoOrbit, PlanetCore } from '@gravito/core'
2
2
  import { type CollectionConfig, ContentManager } from './ContentManager'
3
3
 
4
- declare module 'gravito-core' {
4
+ declare module '@gravito/core' {
5
5
  interface Variables {
6
6
  content: ContentManager
7
7
  }
@@ -40,4 +40,8 @@ export class OrbitMonolith implements GravitoOrbit {
40
40
  }
41
41
  }
42
42
 
43
+ export { Schema } from '@gravito/mass'
43
44
  export * from './ContentManager'
45
+ export * from './Controller'
46
+ export * from './FormRequest'
47
+ export { RouterHelper as Route } from './Router'
@@ -0,0 +1,39 @@
1
+ import type { MiddlewareHandler } from 'hono'
2
+
3
+ /**
4
+ * Automatically trim all strings in the request body and query.
5
+ */
6
+ export const trimStrings = (): MiddlewareHandler => {
7
+ return async (c, next) => {
8
+ // We proxy the req.json and req.query methods to return trimmed data
9
+ // This is more efficient than pre-processing everything if the controller doesn't use it.
10
+
11
+ // However, for best DX, we will attempt to clean the body if it's JSON
12
+ if (c.req.header('Content-Type')?.includes('application/json')) {
13
+ try {
14
+ const body = await c.req.json()
15
+ clean(body, (val) => (typeof val === 'string' ? val.trim() : val))
16
+ // Since Hono's body is already read, we might need to store it in context
17
+ // But for now, let's assume we use a simpler approach for the prototype.
18
+ } catch {
19
+ // Skip if not valid JSON
20
+ }
21
+ }
22
+
23
+ await next()
24
+ }
25
+ }
26
+
27
+ function clean(obj: any, transform: (val: any) => any) {
28
+ if (!obj || typeof obj !== 'object') {
29
+ return
30
+ }
31
+
32
+ for (const key in obj) {
33
+ if (typeof obj[key] === 'object') {
34
+ clean(obj[key], transform)
35
+ } else {
36
+ obj[key] = transform(obj[key])
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,58 @@
1
+ import { beforeAll, describe, expect, it } from 'bun:test'
2
+ import { Photon } from '@gravito/photon'
3
+ import { Controller, FormRequest, Schema } from '../src'
4
+
5
+ // --- 模擬電商控制器與請求 ---
6
+
7
+ class StoreProductRequest extends FormRequest {
8
+ schema() {
9
+ return Schema.Object({
10
+ name: Schema.String({ minLength: 3 }),
11
+ price: Schema.Number({ minimum: 0 }),
12
+ })
13
+ }
14
+ }
15
+
16
+ class ProductController extends Controller {
17
+ async store() {
18
+ const request = new StoreProductRequest().setContext(this.context)
19
+ const data = request.validated()
20
+ return this.json({ success: true, product: data }, 201)
21
+ }
22
+ }
23
+
24
+ // --- 測試 ---
25
+
26
+ describe('Ecommerce MVC Integration (Shared Environment)', () => {
27
+ let app: Photon
28
+
29
+ beforeAll(() => {
30
+ app = new Photon()
31
+ // 手動註冊路由以驗證
32
+ app.post('/products', StoreProductRequest.middleware(), ProductController.call('store'))
33
+ })
34
+
35
+ it('should return 422 when data is invalid', async () => {
36
+ const res = await app.request('/products', {
37
+ method: 'POST',
38
+ headers: { 'Content-Type': 'application/json' },
39
+ body: JSON.stringify({ name: 'Hi', price: -1 }),
40
+ })
41
+
42
+ expect(res.status).toBe(422)
43
+ const data: any = await res.json()
44
+ expect(data.message).toBe('The given data was invalid.')
45
+ })
46
+
47
+ it('should return 201 and sanitized data when valid', async () => {
48
+ const res = await app.request('/products', {
49
+ method: 'POST',
50
+ headers: { 'Content-Type': 'application/json' },
51
+ body: JSON.stringify({ name: ' Galaxy Pro ', price: 999 }),
52
+ })
53
+
54
+ expect(res.status).toBe(201)
55
+ const data: any = await res.json()
56
+ expect(data.product.name).toBe('Galaxy Pro') // 驗證自動 Trim
57
+ })
58
+ })