@gravito/monolith 3.0.1 → 3.2.1
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 +22 -0
- package/dist/index.cjs +295 -12
- package/dist/index.d.cts +62 -4
- package/dist/index.d.ts +62 -4
- package/dist/index.js +293 -12
- package/ion/src/index.js +2775 -2559
- package/package.json +13 -10
- package/scripts/check-coverage.ts +64 -0
- package/src/ContentManager.ts +103 -13
- package/src/ContentWatcher.ts +123 -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 +29 -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.1",
|
|
4
4
|
"description": "Enterprise monolith framework for Gravito Galaxy",
|
|
5
5
|
"main": "dist/index.cjs",
|
|
6
6
|
"module": "dist/index.js",
|
|
@@ -15,10 +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
|
-
"test:ci": "bun test --coverage --coverage-
|
|
21
|
-
"typecheck": "bun tsc -p tsconfig.json --noEmit --skipLibCheck"
|
|
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'"
|
|
22
24
|
},
|
|
23
25
|
"keywords": [
|
|
24
26
|
"gravito",
|
|
@@ -29,15 +31,16 @@
|
|
|
29
31
|
"author": "Carl Lee <carllee0520@gmail.com>",
|
|
30
32
|
"license": "MIT",
|
|
31
33
|
"dependencies": {
|
|
32
|
-
"
|
|
34
|
+
"@gravito/mass": "^3.0.2",
|
|
35
|
+
"@octokit/rest": "^22.0.1",
|
|
33
36
|
"gray-matter": "^4.0.3",
|
|
34
|
-
"
|
|
37
|
+
"marked": "^11.1.1"
|
|
35
38
|
},
|
|
36
39
|
"peerDependencies": {
|
|
37
|
-
"@gravito/core": "
|
|
40
|
+
"@gravito/core": "^1.6.1"
|
|
38
41
|
},
|
|
39
42
|
"devDependencies": {
|
|
40
|
-
"@gravito/core": "
|
|
43
|
+
"@gravito/core": "^1.6.1",
|
|
41
44
|
"@types/marked": "^5.0.0",
|
|
42
45
|
"bun-types": "^1.3.5",
|
|
43
46
|
"tsup": "^8.5.1",
|
|
@@ -52,4 +55,4 @@
|
|
|
52
55
|
"url": "git+https://github.com/gravito-framework/gravito.git",
|
|
53
56
|
"directory": "packages/monolith"
|
|
54
57
|
}
|
|
55
|
-
}
|
|
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,7 +1,7 @@
|
|
|
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
6
|
/**
|
|
7
7
|
* Represents a single content item (file).
|
|
@@ -32,6 +32,8 @@ export class ContentManager {
|
|
|
32
32
|
private collections = new Map<string, CollectionConfig>()
|
|
33
33
|
// Simple memory cache: collection:locale:slug -> ContentItem
|
|
34
34
|
private cache = new Map<string, ContentItem>()
|
|
35
|
+
// In-memory search index: term -> Set<cacheKey>
|
|
36
|
+
private searchIndex = new Map<string, Set<string>>()
|
|
35
37
|
private renderer = (() => {
|
|
36
38
|
const renderer = new marked.Renderer()
|
|
37
39
|
renderer.html = (html: string) => this.escapeHtml(html)
|
|
@@ -46,12 +48,41 @@ export class ContentManager {
|
|
|
46
48
|
return renderer
|
|
47
49
|
})()
|
|
48
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
|
+
|
|
49
80
|
/**
|
|
50
81
|
* Create a new ContentManager instance.
|
|
51
82
|
*
|
|
52
|
-
* @param
|
|
83
|
+
* @param driver - The content driver to use.
|
|
53
84
|
*/
|
|
54
|
-
constructor(private
|
|
85
|
+
constructor(private readonly driver: ContentDriver) {}
|
|
55
86
|
|
|
56
87
|
/**
|
|
57
88
|
* Register a new content collection.
|
|
@@ -85,23 +116,22 @@ export class ContentManager {
|
|
|
85
116
|
}
|
|
86
117
|
|
|
87
118
|
const cacheKey = `${collectionName}:${locale}:${slug}`
|
|
88
|
-
|
|
89
|
-
|
|
119
|
+
const cachedItem = this.cache.get(cacheKey)
|
|
120
|
+
if (cachedItem) {
|
|
121
|
+
return cachedItem
|
|
90
122
|
}
|
|
91
123
|
|
|
92
124
|
// Determine path strategy
|
|
93
|
-
// Strategy: {
|
|
94
|
-
const filePath = join(
|
|
125
|
+
// Strategy: {path}/{locale}/{slug}.md
|
|
126
|
+
const filePath = join(config.path, safeLocale, `${safeSlug}.md`)
|
|
95
127
|
|
|
96
128
|
try {
|
|
97
|
-
const exists = await
|
|
98
|
-
.then(() => true)
|
|
99
|
-
.catch(() => false)
|
|
129
|
+
const exists = await this.driver.exists(filePath)
|
|
100
130
|
if (!exists) {
|
|
101
131
|
return null
|
|
102
132
|
}
|
|
103
133
|
|
|
104
|
-
const fileContent = await
|
|
134
|
+
const fileContent = await this.driver.read(filePath)
|
|
105
135
|
const { data, content, excerpt } = matter(fileContent)
|
|
106
136
|
|
|
107
137
|
const html = await marked.parse(content, { renderer: this.renderer })
|
|
@@ -115,6 +145,7 @@ export class ContentManager {
|
|
|
115
145
|
}
|
|
116
146
|
|
|
117
147
|
this.cache.set(cacheKey, item)
|
|
148
|
+
this.buildSearchIndex(cacheKey, item)
|
|
118
149
|
return item
|
|
119
150
|
} catch (e) {
|
|
120
151
|
console.error(`[Orbit-Content] Error reading file: ${filePath}`, e)
|
|
@@ -142,10 +173,10 @@ export class ContentManager {
|
|
|
142
173
|
return []
|
|
143
174
|
}
|
|
144
175
|
|
|
145
|
-
const dirPath = join(
|
|
176
|
+
const dirPath = join(config.path, safeLocale)
|
|
146
177
|
|
|
147
178
|
try {
|
|
148
|
-
const files = await
|
|
179
|
+
const files = await this.driver.list(dirPath)
|
|
149
180
|
const items: ContentItem[] = []
|
|
150
181
|
|
|
151
182
|
for (const file of files) {
|
|
@@ -166,6 +197,65 @@ export class ContentManager {
|
|
|
166
197
|
}
|
|
167
198
|
}
|
|
168
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
|
+
|
|
169
259
|
private sanitizeSegment(value: string): string | null {
|
|
170
260
|
if (!value) {
|
|
171
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
|
+
}
|
|
@@ -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 {
|
|
@@ -13,6 +16,7 @@ declare module '@gravito/core' {
|
|
|
13
16
|
*/
|
|
14
17
|
export interface ContentConfig {
|
|
15
18
|
root?: string // Defaults to process.cwd()
|
|
19
|
+
driver?: ContentDriver
|
|
16
20
|
collections?: Record<string, CollectionConfig>
|
|
17
21
|
}
|
|
18
22
|
|
|
@@ -26,7 +30,18 @@ export class OrbitMonolith implements GravitoOrbit {
|
|
|
26
30
|
|
|
27
31
|
install(core: PlanetCore): void {
|
|
28
32
|
const root = this.config.root || process.cwd()
|
|
29
|
-
|
|
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)
|
|
30
45
|
|
|
31
46
|
// Register Collections from Config
|
|
32
47
|
if (this.config.collections) {
|
|
@@ -44,6 +59,16 @@ export class OrbitMonolith implements GravitoOrbit {
|
|
|
44
59
|
return await next()
|
|
45
60
|
})
|
|
46
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
|
+
|
|
47
72
|
core.logger.info('Orbit Monolith installed ⬛️')
|
|
48
73
|
}
|
|
49
74
|
}
|
|
@@ -51,5 +76,8 @@ export class OrbitMonolith implements GravitoOrbit {
|
|
|
51
76
|
export { Schema } from '@gravito/mass'
|
|
52
77
|
export * from './ContentManager'
|
|
53
78
|
export * from './Controller'
|
|
79
|
+
export * from './driver/ContentDriver'
|
|
80
|
+
export * from './driver/GitHubDriver'
|
|
81
|
+
export * from './driver/LocalDriver'
|
|
54
82
|
export * from './FormRequest'
|
|
55
83
|
export { RouterHelper as Route } from './Router'
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { ContentManager } from '../src/ContentManager'
|
|
4
|
+
import { LocalDriver } from '../src/driver/LocalDriver'
|
|
5
|
+
|
|
6
|
+
describe('ContentManager Cache Invalidation', () => {
|
|
7
|
+
it('should invalidate cache by key', async () => {
|
|
8
|
+
const root = join(import.meta.dir, 'fixtures')
|
|
9
|
+
const manager = new ContentManager(new LocalDriver(root))
|
|
10
|
+
|
|
11
|
+
// @ts-expect-error - private access for testing
|
|
12
|
+
manager.cache.set('docs:en:hello', { slug: 'hello' } as any)
|
|
13
|
+
|
|
14
|
+
// @ts-expect-error - method not implemented yet
|
|
15
|
+
manager.invalidate('docs', 'hello', 'en')
|
|
16
|
+
|
|
17
|
+
// @ts-expect-error - private access
|
|
18
|
+
expect(manager.cache.has('docs:en:hello')).toBe(false)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('should clear all cache', async () => {
|
|
22
|
+
const root = join(import.meta.dir, 'fixtures')
|
|
23
|
+
const manager = new ContentManager(new LocalDriver(root))
|
|
24
|
+
|
|
25
|
+
// @ts-expect-error - private access
|
|
26
|
+
manager.cache.set('a', {} as any)
|
|
27
|
+
// @ts-expect-error - private access
|
|
28
|
+
manager.cache.set('b', {} as any)
|
|
29
|
+
|
|
30
|
+
// @ts-expect-error - method not implemented yet
|
|
31
|
+
manager.clearCache()
|
|
32
|
+
|
|
33
|
+
// @ts-expect-error - private access
|
|
34
|
+
expect(manager.cache.size).toBe(0)
|
|
35
|
+
})
|
|
36
|
+
})
|