@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gravito/monolith",
3
- "version": "3.0.1",
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-threshold=80",
20
- "test:ci": "bun test --coverage --coverage-threshold=80",
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
- "marked": "^11.1.1",
34
+ "@gravito/mass": "^3.0.2",
35
+ "@octokit/rest": "^22.0.1",
33
36
  "gray-matter": "^4.0.3",
34
- "@gravito/mass": "workspace:*"
37
+ "marked": "^11.1.1"
35
38
  },
36
39
  "peerDependencies": {
37
- "@gravito/core": "workspace:*"
40
+ "@gravito/core": "^1.6.1"
38
41
  },
39
42
  "devDependencies": {
40
- "@gravito/core": "workspace:*",
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}%.`)
@@ -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 rootDir - The root directory of the application.
83
+ * @param driver - The content driver to use.
53
84
  */
54
- constructor(private rootDir: string) {}
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
- if (this.cache.has(cacheKey)) {
89
- return this.cache.get(cacheKey)!
119
+ const cachedItem = this.cache.get(cacheKey)
120
+ if (cachedItem) {
121
+ return cachedItem
90
122
  }
91
123
 
92
124
  // Determine path strategy
93
- // Strategy: {root}/{path}/{locale}/{slug}.md
94
- const filePath = join(this.rootDir, config.path, safeLocale, `${safeSlug}.md`)
125
+ // Strategy: {path}/{locale}/{slug}.md
126
+ const filePath = join(config.path, safeLocale, `${safeSlug}.md`)
95
127
 
96
128
  try {
97
- const exists = await stat(filePath)
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 readFile(filePath, 'utf-8')
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(this.rootDir, config.path, safeLocale)
176
+ const dirPath = join(config.path, safeLocale)
146
177
 
147
178
  try {
148
- const files = await readdir(dirPath)
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,5 @@
1
+ export interface ContentDriver {
2
+ read(path: string): Promise<string>
3
+ exists(path: string): Promise<boolean>
4
+ list(dir: string): Promise<string[]>
5
+ }
@@ -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
- const manager = new ContentManager(root)
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
+ })