@gravito/monolith 3.0.0 → 3.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/CHANGELOG.md +15 -0
- package/dist/index.cjs +298 -13
- package/dist/index.d.cts +113 -4
- package/dist/index.d.ts +113 -4
- package/dist/index.js +296 -13
- package/ion/src/index.js +2775 -2559
- package/package.json +11 -7
- package/scripts/check-coverage.ts +64 -0
- package/src/ContentManager.ts +115 -13
- package/src/ContentWatcher.ts +123 -0
- package/src/Controller.ts +17 -0
- package/src/FormRequest.ts +12 -1
- package/src/Router.ts +4 -0
- package/src/driver/ContentDriver.ts +5 -0
- package/src/driver/GitHubDriver.ts +80 -0
- package/src/driver/LocalDriver.ts +30 -0
- package/src/index.ts +38 -1
- package/tests/content-cache.test.ts +36 -0
- package/tests/content-search.test.ts +79 -0
- package/tests/content-watcher.test.ts +56 -0
- package/tests/content.test.ts +2 -1
- package/tests/extra.test.ts +2 -1
- package/tests/hot-reload.test.ts +74 -0
- package/tsconfig.json +13 -19
- package/dist/src/index.js +0 -5624
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gravito/monolith",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"description": "Enterprise monolith framework for Gravito Galaxy",
|
|
5
5
|
"main": "dist/index.cjs",
|
|
6
6
|
"module": "dist/index.js",
|
|
@@ -15,9 +15,12 @@
|
|
|
15
15
|
},
|
|
16
16
|
"scripts": {
|
|
17
17
|
"build": "tsup src/index.ts --format esm,cjs --dts",
|
|
18
|
-
"test": "bun test",
|
|
19
|
-
"test:coverage": "bun test --coverage --coverage-
|
|
20
|
-
"
|
|
18
|
+
"test": "bun test --timeout=10000",
|
|
19
|
+
"test:coverage": "bun test --timeout=10000 --coverage --coverage-reporter=lcov --coverage-dir coverage && bun run --bun scripts/check-coverage.ts",
|
|
20
|
+
"test:ci": "bun test --timeout=10000 --coverage --coverage-reporter=lcov --coverage-dir coverage && bun run --bun scripts/check-coverage.ts",
|
|
21
|
+
"typecheck": "bun tsc -p tsconfig.json --noEmit --skipLibCheck",
|
|
22
|
+
"test:unit": "bun test tests/ --timeout=10000",
|
|
23
|
+
"test:integration": "test $(find tests -name '*.integration.test.ts' 2>/dev/null | wc -l) -gt 0 && find tests -name '*.integration.test.ts' -print0 | xargs -0 bun test --timeout=10000 || echo 'No integration tests found'"
|
|
21
24
|
},
|
|
22
25
|
"keywords": [
|
|
23
26
|
"gravito",
|
|
@@ -28,9 +31,10 @@
|
|
|
28
31
|
"author": "Carl Lee <carllee0520@gmail.com>",
|
|
29
32
|
"license": "MIT",
|
|
30
33
|
"dependencies": {
|
|
31
|
-
"
|
|
34
|
+
"@gravito/mass": "workspace:*",
|
|
35
|
+
"@octokit/rest": "^22.0.1",
|
|
32
36
|
"gray-matter": "^4.0.3",
|
|
33
|
-
"
|
|
37
|
+
"marked": "^11.1.1"
|
|
34
38
|
},
|
|
35
39
|
"peerDependencies": {
|
|
36
40
|
"@gravito/core": "workspace:*"
|
|
@@ -51,4 +55,4 @@
|
|
|
51
55
|
"url": "git+https://github.com/gravito-framework/gravito.git",
|
|
52
56
|
"directory": "packages/monolith"
|
|
53
57
|
}
|
|
54
|
-
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const lcovPath = process.argv[2] ?? 'coverage/lcov.info'
|
|
5
|
+
const threshold = Number.parseFloat(process.env.COVERAGE_THRESHOLD ?? '80')
|
|
6
|
+
|
|
7
|
+
const root = resolve(process.cwd())
|
|
8
|
+
const srcRoot = `${resolve(root, 'src')}/`
|
|
9
|
+
|
|
10
|
+
// 檢查 lcov.info 是否存在
|
|
11
|
+
if (!existsSync(lcovPath)) {
|
|
12
|
+
console.error(`Coverage file not found: ${lcovPath}`)
|
|
13
|
+
process.exit(1)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let content: string
|
|
17
|
+
try {
|
|
18
|
+
content = readFileSync(lcovPath, 'utf-8')
|
|
19
|
+
} catch (error) {
|
|
20
|
+
console.error(`Failed to read coverage file: ${error}`)
|
|
21
|
+
process.exit(1)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const lines = content.split('\n')
|
|
25
|
+
|
|
26
|
+
let currentFile: string | null = null
|
|
27
|
+
let total = 0
|
|
28
|
+
let hit = 0
|
|
29
|
+
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
if (line.startsWith('SF:')) {
|
|
32
|
+
const filePath = line.slice(3).trim()
|
|
33
|
+
const abs = resolve(root, filePath)
|
|
34
|
+
currentFile = abs.startsWith(srcRoot) ? abs : null
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!currentFile) {
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (line.startsWith('DA:')) {
|
|
43
|
+
const parts = line.slice(3).split(',')
|
|
44
|
+
if (parts.length >= 2) {
|
|
45
|
+
total += 1
|
|
46
|
+
const count = Number.parseInt(parts[1] ?? '0', 10)
|
|
47
|
+
if (count > 0) {
|
|
48
|
+
hit += 1
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const percent = total === 0 ? 0 : (hit / total) * 100
|
|
55
|
+
const rounded = Math.round(percent * 100) / 100
|
|
56
|
+
|
|
57
|
+
if (rounded < threshold) {
|
|
58
|
+
console.error(
|
|
59
|
+
`monolith coverage ${rounded}% is below threshold ${threshold}%. Covered lines: ${hit}/${total}.`
|
|
60
|
+
)
|
|
61
|
+
process.exit(1)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
console.log(`monolith coverage ${rounded}% (${hit}/${total}) meets threshold ${threshold}%.`)
|
package/src/ContentManager.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import { readdir, readFile, stat } from 'node:fs/promises'
|
|
2
1
|
import { join, parse } from 'node:path'
|
|
3
2
|
import matter from 'gray-matter'
|
|
4
3
|
import { marked } from 'marked'
|
|
4
|
+
import type { ContentDriver } from './driver/ContentDriver'
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Represents a single content item (file).
|
|
8
|
+
* @public
|
|
9
|
+
*/
|
|
6
10
|
export interface ContentItem {
|
|
7
11
|
slug: string
|
|
8
12
|
body: string // The HTML content
|
|
@@ -12,14 +16,24 @@ export interface ContentItem {
|
|
|
12
16
|
raw: string // The raw markdown
|
|
13
17
|
}
|
|
14
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Configuration for a content collection.
|
|
21
|
+
* @public
|
|
22
|
+
*/
|
|
15
23
|
export interface CollectionConfig {
|
|
16
24
|
path: string // e.g., 'resources/content/docs'
|
|
17
25
|
}
|
|
18
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Manages fetching, parsing, and caching of filesystem-based content.
|
|
29
|
+
* @public
|
|
30
|
+
*/
|
|
19
31
|
export class ContentManager {
|
|
20
32
|
private collections = new Map<string, CollectionConfig>()
|
|
21
33
|
// Simple memory cache: collection:locale:slug -> ContentItem
|
|
22
34
|
private cache = new Map<string, ContentItem>()
|
|
35
|
+
// In-memory search index: term -> Set<cacheKey>
|
|
36
|
+
private searchIndex = new Map<string, Set<string>>()
|
|
23
37
|
private renderer = (() => {
|
|
24
38
|
const renderer = new marked.Renderer()
|
|
25
39
|
renderer.html = (html: string) => this.escapeHtml(html)
|
|
@@ -34,12 +48,41 @@ export class ContentManager {
|
|
|
34
48
|
return renderer
|
|
35
49
|
})()
|
|
36
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Clear all cached content.
|
|
53
|
+
* Useful for hot reload during development.
|
|
54
|
+
*/
|
|
55
|
+
clearCache(): void {
|
|
56
|
+
this.cache.clear()
|
|
57
|
+
this.searchIndex.clear()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Invalidate a specific content item.
|
|
62
|
+
* @param collection - The collection name.
|
|
63
|
+
* @param slug - The file slug.
|
|
64
|
+
* @param locale - The locale. Defaults to 'en'.
|
|
65
|
+
*/
|
|
66
|
+
invalidate(collection: string, slug: string, locale = 'en'): void {
|
|
67
|
+
const safeSlug = this.sanitizeSegment(slug)
|
|
68
|
+
const safeLocale = this.sanitizeSegment(locale)
|
|
69
|
+
|
|
70
|
+
if (safeSlug && safeLocale) {
|
|
71
|
+
const cacheKey = `${collection}:${safeLocale}:${safeSlug}`
|
|
72
|
+
this.cache.delete(cacheKey)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
getCollectionConfig(name: string): CollectionConfig | undefined {
|
|
77
|
+
return this.collections.get(name)
|
|
78
|
+
}
|
|
79
|
+
|
|
37
80
|
/**
|
|
38
81
|
* Create a new ContentManager instance.
|
|
39
82
|
*
|
|
40
|
-
* @param
|
|
83
|
+
* @param driver - The content driver to use.
|
|
41
84
|
*/
|
|
42
|
-
constructor(private
|
|
85
|
+
constructor(private readonly driver: ContentDriver) {}
|
|
43
86
|
|
|
44
87
|
/**
|
|
45
88
|
* Register a new content collection.
|
|
@@ -73,23 +116,22 @@ export class ContentManager {
|
|
|
73
116
|
}
|
|
74
117
|
|
|
75
118
|
const cacheKey = `${collectionName}:${locale}:${slug}`
|
|
76
|
-
|
|
77
|
-
|
|
119
|
+
const cachedItem = this.cache.get(cacheKey)
|
|
120
|
+
if (cachedItem) {
|
|
121
|
+
return cachedItem
|
|
78
122
|
}
|
|
79
123
|
|
|
80
124
|
// Determine path strategy
|
|
81
|
-
// Strategy: {
|
|
82
|
-
const filePath = join(
|
|
125
|
+
// Strategy: {path}/{locale}/{slug}.md
|
|
126
|
+
const filePath = join(config.path, safeLocale, `${safeSlug}.md`)
|
|
83
127
|
|
|
84
128
|
try {
|
|
85
|
-
const exists = await
|
|
86
|
-
.then(() => true)
|
|
87
|
-
.catch(() => false)
|
|
129
|
+
const exists = await this.driver.exists(filePath)
|
|
88
130
|
if (!exists) {
|
|
89
131
|
return null
|
|
90
132
|
}
|
|
91
133
|
|
|
92
|
-
const fileContent = await
|
|
134
|
+
const fileContent = await this.driver.read(filePath)
|
|
93
135
|
const { data, content, excerpt } = matter(fileContent)
|
|
94
136
|
|
|
95
137
|
const html = await marked.parse(content, { renderer: this.renderer })
|
|
@@ -103,6 +145,7 @@ export class ContentManager {
|
|
|
103
145
|
}
|
|
104
146
|
|
|
105
147
|
this.cache.set(cacheKey, item)
|
|
148
|
+
this.buildSearchIndex(cacheKey, item)
|
|
106
149
|
return item
|
|
107
150
|
} catch (e) {
|
|
108
151
|
console.error(`[Orbit-Content] Error reading file: ${filePath}`, e)
|
|
@@ -130,10 +173,10 @@ export class ContentManager {
|
|
|
130
173
|
return []
|
|
131
174
|
}
|
|
132
175
|
|
|
133
|
-
const dirPath = join(
|
|
176
|
+
const dirPath = join(config.path, safeLocale)
|
|
134
177
|
|
|
135
178
|
try {
|
|
136
|
-
const files = await
|
|
179
|
+
const files = await this.driver.list(dirPath)
|
|
137
180
|
const items: ContentItem[] = []
|
|
138
181
|
|
|
139
182
|
for (const file of files) {
|
|
@@ -154,6 +197,65 @@ export class ContentManager {
|
|
|
154
197
|
}
|
|
155
198
|
}
|
|
156
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Search for content items across collections and locales.
|
|
202
|
+
*
|
|
203
|
+
* @param query - The search query.
|
|
204
|
+
* @param options - Optional filters for collection and locale.
|
|
205
|
+
* @returns An array of matching ContentItems.
|
|
206
|
+
*/
|
|
207
|
+
search(query: string, options: { collection?: string; locale?: string } = {}): ContentItem[] {
|
|
208
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
|
|
209
|
+
if (terms.length === 0) {
|
|
210
|
+
return []
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const matches = new Set<string>()
|
|
214
|
+
|
|
215
|
+
for (const term of terms) {
|
|
216
|
+
const keys = this.searchIndex.get(term)
|
|
217
|
+
if (keys) {
|
|
218
|
+
for (const key of keys) {
|
|
219
|
+
matches.add(key)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const results: ContentItem[] = []
|
|
225
|
+
for (const key of matches) {
|
|
226
|
+
const item = this.cache.get(key)
|
|
227
|
+
if (!item) {
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const [collection, locale] = key.split(':')
|
|
232
|
+
|
|
233
|
+
if (options.collection && options.collection !== collection) {
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
236
|
+
if (options.locale && options.locale !== locale) {
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
results.push(item)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return results
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private buildSearchIndex(cacheKey: string, item: ContentItem): void {
|
|
247
|
+
const text =
|
|
248
|
+
`${item.slug} ${item.meta.title || ''} ${item.raw} ${item.excerpt || ''}`.toLowerCase()
|
|
249
|
+
const terms = text.split(/[^\w\d]+/).filter((t) => t.length > 2)
|
|
250
|
+
|
|
251
|
+
for (const term of terms) {
|
|
252
|
+
if (!this.searchIndex.has(term)) {
|
|
253
|
+
this.searchIndex.set(term, new Set())
|
|
254
|
+
}
|
|
255
|
+
this.searchIndex.get(term)?.add(cacheKey)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
157
259
|
private sanitizeSegment(value: string): string | null {
|
|
158
260
|
if (!value) {
|
|
159
261
|
return null
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { type FSWatcher, readdirSync, watch } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import type { ContentManager } from './ContentManager'
|
|
4
|
+
|
|
5
|
+
interface WatcherOptions {
|
|
6
|
+
debounceMs?: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class ContentWatcher {
|
|
10
|
+
private watchers: FSWatcher[] = []
|
|
11
|
+
private debounceTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
12
|
+
|
|
13
|
+
constructor(
|
|
14
|
+
private contentManager: ContentManager,
|
|
15
|
+
private rootDir: string,
|
|
16
|
+
private options: WatcherOptions = {}
|
|
17
|
+
) {}
|
|
18
|
+
|
|
19
|
+
watch(collectionName: string): void {
|
|
20
|
+
const config = this.contentManager.getCollectionConfig(collectionName)
|
|
21
|
+
if (!config) {
|
|
22
|
+
console.warn(`[ContentWatcher] Collection '${collectionName}' not found`)
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const watchPath = join(this.rootDir, config.path)
|
|
27
|
+
|
|
28
|
+
// Watch the collection directory (recursive if supported)
|
|
29
|
+
this.addWatcher(watchPath, collectionName)
|
|
30
|
+
|
|
31
|
+
// On some platforms (like Linux), recursive watch is not supported.
|
|
32
|
+
// We manually watch the immediate subdirectories (locales).
|
|
33
|
+
try {
|
|
34
|
+
const entries = readdirSync(watchPath, { withFileTypes: true })
|
|
35
|
+
for (const entry of entries) {
|
|
36
|
+
if (entry.isDirectory()) {
|
|
37
|
+
this.addWatcher(join(watchPath, entry.name), collectionName, entry.name)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
} catch (_e) {
|
|
41
|
+
// Directory might not exist yet
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private addWatcher(watchPath: string, collection: string, localePrefix?: string) {
|
|
46
|
+
try {
|
|
47
|
+
const watcher = watch(watchPath, { recursive: true }, (_eventType, filename) => {
|
|
48
|
+
if (!filename) {
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const key = `${collection}:${localePrefix || ''}:${filename}`
|
|
53
|
+
if (this.debounceTimers.has(key)) {
|
|
54
|
+
clearTimeout(this.debounceTimers.get(key))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
this.debounceTimers.set(
|
|
58
|
+
key,
|
|
59
|
+
setTimeout(() => {
|
|
60
|
+
this.handleFileChange(collection, filename.toString(), localePrefix)
|
|
61
|
+
this.debounceTimers.delete(key)
|
|
62
|
+
}, this.options.debounceMs ?? 100)
|
|
63
|
+
)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
this.watchers.push(watcher)
|
|
67
|
+
} catch (_e) {
|
|
68
|
+
// If recursive fails, try without it
|
|
69
|
+
try {
|
|
70
|
+
const watcher = watch(watchPath, { recursive: false }, (_eventType, filename) => {
|
|
71
|
+
if (!filename) {
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
this.handleFileChange(collection, filename.toString(), localePrefix)
|
|
75
|
+
})
|
|
76
|
+
this.watchers.push(watcher)
|
|
77
|
+
} catch (err) {
|
|
78
|
+
console.error(`[ContentWatcher] Failed to watch ${watchPath}:`, err)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private handleFileChange(collection: string, filename: string, localePrefix?: string) {
|
|
84
|
+
// Handle both / and \ as separators
|
|
85
|
+
const parts = filename.split(/[/\\]/)
|
|
86
|
+
|
|
87
|
+
let locale: string
|
|
88
|
+
let file: string
|
|
89
|
+
|
|
90
|
+
if (parts.length >= 2) {
|
|
91
|
+
// If filename is "en/test.md"
|
|
92
|
+
locale = parts[parts.length - 2]
|
|
93
|
+
file = parts[parts.length - 1]
|
|
94
|
+
} else if (localePrefix) {
|
|
95
|
+
// If we are watching a locale directory directly
|
|
96
|
+
locale = localePrefix
|
|
97
|
+
file = parts[0]
|
|
98
|
+
} else {
|
|
99
|
+
// No locale found, and no prefix. Monolith expects collection/locale/slug.md
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!file.endsWith('.md')) {
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const slug = file.replace(/\.md$/, '')
|
|
108
|
+
|
|
109
|
+
this.contentManager.invalidate(collection, slug, locale)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
close(): void {
|
|
113
|
+
for (const watcher of this.watchers) {
|
|
114
|
+
watcher.close()
|
|
115
|
+
}
|
|
116
|
+
this.watchers = []
|
|
117
|
+
|
|
118
|
+
for (const timer of this.debounceTimers.values()) {
|
|
119
|
+
clearTimeout(timer)
|
|
120
|
+
}
|
|
121
|
+
this.debounceTimers.clear()
|
|
122
|
+
}
|
|
123
|
+
}
|
package/src/Controller.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { GravitoContext } from '@gravito/core'
|
|
2
2
|
import { Sanitizer } from './Sanitizer.js'
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Base class for all Monolith Controllers.
|
|
6
|
+
*
|
|
7
|
+
* Provides basic functionality for calling actions and sanitizing data.
|
|
8
|
+
*
|
|
9
|
+
* @public
|
|
10
|
+
* @since 3.0.0
|
|
11
|
+
*/
|
|
4
12
|
export abstract class BaseController {
|
|
5
13
|
protected sanitizer = new Sanitizer()
|
|
6
14
|
|
|
@@ -14,6 +22,15 @@ export abstract class BaseController {
|
|
|
14
22
|
}
|
|
15
23
|
}
|
|
16
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Controller class with request context awareness and helper methods.
|
|
27
|
+
*
|
|
28
|
+
* This class provides a more feature-rich base for controllers that
|
|
29
|
+
* need direct access to the request context and common response helpers.
|
|
30
|
+
*
|
|
31
|
+
* @public
|
|
32
|
+
* @since 3.0.0
|
|
33
|
+
*/
|
|
17
34
|
export abstract class Controller {
|
|
18
35
|
protected context!: GravitoContext
|
|
19
36
|
|
package/src/FormRequest.ts
CHANGED
|
@@ -2,6 +2,15 @@ import type { GravitoContext, GravitoNext } from '@gravito/core'
|
|
|
2
2
|
import { type TSchema, validate } from '@gravito/mass'
|
|
3
3
|
import { Sanitizer } from './Sanitizer.js'
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Base class for Monolith Form Requests.
|
|
7
|
+
*
|
|
8
|
+
* Provides a structured way to handle request validation and authorization
|
|
9
|
+
* for the Monolith architecture.
|
|
10
|
+
*
|
|
11
|
+
* @public
|
|
12
|
+
* @since 3.0.0
|
|
13
|
+
*/
|
|
5
14
|
export abstract class FormRequest {
|
|
6
15
|
protected context!: GravitoContext
|
|
7
16
|
|
|
@@ -65,7 +74,9 @@ export abstract class FormRequest {
|
|
|
65
74
|
for (const issue of issues) {
|
|
66
75
|
const path = Array.isArray(issue.path) ? issue.path.join('.') : issue.path || 'root'
|
|
67
76
|
const key = path.replace(/^\//, '').replace(/\//g, '.')
|
|
68
|
-
if (!errors[key])
|
|
77
|
+
if (!errors[key]) {
|
|
78
|
+
errors[key] = []
|
|
79
|
+
}
|
|
69
80
|
errors[key].push(issue.message || 'Validation failed')
|
|
70
81
|
}
|
|
71
82
|
|
package/src/Router.ts
CHANGED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Octokit } from '@octokit/rest'
|
|
2
|
+
import type { ContentDriver } from './ContentDriver'
|
|
3
|
+
|
|
4
|
+
export interface GitHubDriverOptions {
|
|
5
|
+
owner: string
|
|
6
|
+
repo: string
|
|
7
|
+
ref?: string
|
|
8
|
+
auth?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class GitHubDriver implements ContentDriver {
|
|
12
|
+
private octokit: Octokit
|
|
13
|
+
private owner: string
|
|
14
|
+
private repo: string
|
|
15
|
+
private ref?: string
|
|
16
|
+
|
|
17
|
+
constructor(options: GitHubDriverOptions) {
|
|
18
|
+
this.octokit = new Octokit({ auth: options.auth })
|
|
19
|
+
this.owner = options.owner
|
|
20
|
+
this.repo = options.repo
|
|
21
|
+
this.ref = options.ref
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async read(path: string): Promise<string> {
|
|
25
|
+
try {
|
|
26
|
+
const { data } = await this.octokit.rest.repos.getContent({
|
|
27
|
+
owner: this.owner,
|
|
28
|
+
repo: this.repo,
|
|
29
|
+
path,
|
|
30
|
+
ref: this.ref,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
if (Array.isArray(data) || !('content' in data)) {
|
|
34
|
+
throw new Error(`Path is not a file: ${path}`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return Buffer.from(data.content, 'base64').toString('utf-8')
|
|
38
|
+
} catch (e: any) {
|
|
39
|
+
if (e.status === 404) {
|
|
40
|
+
throw new Error(`File not found: ${path}`)
|
|
41
|
+
}
|
|
42
|
+
throw e
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async exists(path: string): Promise<boolean> {
|
|
47
|
+
try {
|
|
48
|
+
// Use HEAD request if possible, or just lightweight get
|
|
49
|
+
// getContents with defaults gets JSON metadata
|
|
50
|
+
await this.octokit.rest.repos.getContent({
|
|
51
|
+
owner: this.owner,
|
|
52
|
+
repo: this.repo,
|
|
53
|
+
path,
|
|
54
|
+
ref: this.ref,
|
|
55
|
+
})
|
|
56
|
+
return true
|
|
57
|
+
} catch {
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async list(dir: string): Promise<string[]> {
|
|
63
|
+
try {
|
|
64
|
+
const { data } = await this.octokit.rest.repos.getContent({
|
|
65
|
+
owner: this.owner,
|
|
66
|
+
repo: this.repo,
|
|
67
|
+
path: dir,
|
|
68
|
+
ref: this.ref,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
if (!Array.isArray(data)) {
|
|
72
|
+
return []
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return data.map((item) => item.name)
|
|
76
|
+
} catch {
|
|
77
|
+
return []
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import type { ContentDriver } from './ContentDriver'
|
|
4
|
+
|
|
5
|
+
export class LocalDriver implements ContentDriver {
|
|
6
|
+
constructor(private rootDir: string) {}
|
|
7
|
+
|
|
8
|
+
async read(path: string): Promise<string> {
|
|
9
|
+
const fullPath = join(this.rootDir, path)
|
|
10
|
+
return readFile(fullPath, 'utf-8')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async exists(path: string): Promise<boolean> {
|
|
14
|
+
try {
|
|
15
|
+
await stat(join(this.rootDir, path))
|
|
16
|
+
return true
|
|
17
|
+
} catch {
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async list(dir: string): Promise<string[]> {
|
|
23
|
+
try {
|
|
24
|
+
const fullPath = join(this.rootDir, dir)
|
|
25
|
+
return await readdir(fullPath)
|
|
26
|
+
} catch {
|
|
27
|
+
return []
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { GravitoOrbit, PlanetCore } from '@gravito/core'
|
|
2
2
|
import { type CollectionConfig, ContentManager } from './ContentManager'
|
|
3
|
+
import { ContentWatcher } from './ContentWatcher'
|
|
4
|
+
import type { ContentDriver } from './driver/ContentDriver'
|
|
5
|
+
import { LocalDriver } from './driver/LocalDriver'
|
|
3
6
|
|
|
4
7
|
declare module '@gravito/core' {
|
|
5
8
|
interface Variables {
|
|
@@ -7,17 +10,38 @@ declare module '@gravito/core' {
|
|
|
7
10
|
}
|
|
8
11
|
}
|
|
9
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Configuration for Orbit Monolith (Content Engine).
|
|
15
|
+
* @public
|
|
16
|
+
*/
|
|
10
17
|
export interface ContentConfig {
|
|
11
18
|
root?: string // Defaults to process.cwd()
|
|
19
|
+
driver?: ContentDriver
|
|
12
20
|
collections?: Record<string, CollectionConfig>
|
|
13
21
|
}
|
|
14
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Orbit Monolith Service.
|
|
25
|
+
* Provides flat-file CMS capabilities to Gravito applications.
|
|
26
|
+
* @public
|
|
27
|
+
*/
|
|
15
28
|
export class OrbitMonolith implements GravitoOrbit {
|
|
16
29
|
constructor(private config: ContentConfig = {}) {}
|
|
17
30
|
|
|
18
31
|
install(core: PlanetCore): void {
|
|
19
32
|
const root = this.config.root || process.cwd()
|
|
20
|
-
|
|
33
|
+
let driver: ContentDriver
|
|
34
|
+
let isLocal = false
|
|
35
|
+
|
|
36
|
+
if (this.config.driver) {
|
|
37
|
+
driver = this.config.driver
|
|
38
|
+
isLocal = driver instanceof LocalDriver
|
|
39
|
+
} else {
|
|
40
|
+
driver = new LocalDriver(root)
|
|
41
|
+
isLocal = true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const manager = new ContentManager(driver)
|
|
21
45
|
|
|
22
46
|
// Register Collections from Config
|
|
23
47
|
if (this.config.collections) {
|
|
@@ -35,6 +59,16 @@ export class OrbitMonolith implements GravitoOrbit {
|
|
|
35
59
|
return await next()
|
|
36
60
|
})
|
|
37
61
|
|
|
62
|
+
if (process.env.NODE_ENV === 'development' && isLocal) {
|
|
63
|
+
const watcher = new ContentWatcher(manager, root)
|
|
64
|
+
if (this.config.collections) {
|
|
65
|
+
for (const name of Object.keys(this.config.collections)) {
|
|
66
|
+
watcher.watch(name)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
core.logger.info('Orbit Monolith Hot Reload Active 🔥')
|
|
70
|
+
}
|
|
71
|
+
|
|
38
72
|
core.logger.info('Orbit Monolith installed ⬛️')
|
|
39
73
|
}
|
|
40
74
|
}
|
|
@@ -42,5 +76,8 @@ export class OrbitMonolith implements GravitoOrbit {
|
|
|
42
76
|
export { Schema } from '@gravito/mass'
|
|
43
77
|
export * from './ContentManager'
|
|
44
78
|
export * from './Controller'
|
|
79
|
+
export * from './driver/ContentDriver'
|
|
80
|
+
export * from './driver/GitHubDriver'
|
|
81
|
+
export * from './driver/LocalDriver'
|
|
45
82
|
export * from './FormRequest'
|
|
46
83
|
export { RouterHelper as Route } from './Router'
|