@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/dist/index.d.ts CHANGED
@@ -4,6 +4,16 @@ import { TSchema } from '@gravito/mass';
4
4
  export { Schema } from '@gravito/mass';
5
5
  import { Hono } from 'hono';
6
6
 
7
+ interface ContentDriver {
8
+ read(path: string): Promise<string>;
9
+ exists(path: string): Promise<boolean>;
10
+ list(dir: string): Promise<string[]>;
11
+ }
12
+
13
+ /**
14
+ * Represents a single content item (file).
15
+ * @public
16
+ */
7
17
  interface ContentItem {
8
18
  slug: string;
9
19
  body: string;
@@ -11,20 +21,42 @@ interface ContentItem {
11
21
  meta: Record<string, any>;
12
22
  raw: string;
13
23
  }
24
+ /**
25
+ * Configuration for a content collection.
26
+ * @public
27
+ */
14
28
  interface CollectionConfig {
15
29
  path: string;
16
30
  }
31
+ /**
32
+ * Manages fetching, parsing, and caching of filesystem-based content.
33
+ * @public
34
+ */
17
35
  declare class ContentManager {
18
- private rootDir;
36
+ private readonly driver;
19
37
  private collections;
20
38
  private cache;
39
+ private searchIndex;
21
40
  private renderer;
41
+ /**
42
+ * Clear all cached content.
43
+ * Useful for hot reload during development.
44
+ */
45
+ clearCache(): void;
46
+ /**
47
+ * Invalidate a specific content item.
48
+ * @param collection - The collection name.
49
+ * @param slug - The file slug.
50
+ * @param locale - The locale. Defaults to 'en'.
51
+ */
52
+ invalidate(collection: string, slug: string, locale?: string): void;
53
+ getCollectionConfig(name: string): CollectionConfig | undefined;
22
54
  /**
23
55
  * Create a new ContentManager instance.
24
56
  *
25
- * @param rootDir - The root directory of the application.
57
+ * @param driver - The content driver to use.
26
58
  */
27
- constructor(rootDir: string);
59
+ constructor(driver: ContentDriver);
28
60
  /**
29
61
  * Register a new content collection.
30
62
  *
@@ -52,6 +84,18 @@ declare class ContentManager {
52
84
  * @throws {Error} If the collection is not defined.
53
85
  */
54
86
  list(collectionName: string, locale?: string): Promise<ContentItem[]>;
87
+ /**
88
+ * Search for content items across collections and locales.
89
+ *
90
+ * @param query - The search query.
91
+ * @param options - Optional filters for collection and locale.
92
+ * @returns An array of matching ContentItems.
93
+ */
94
+ search(query: string, options?: {
95
+ collection?: string;
96
+ locale?: string;
97
+ }): ContentItem[];
98
+ private buildSearchIndex;
55
99
  private sanitizeSegment;
56
100
  private escapeHtml;
57
101
  private isSafeUrl;
@@ -67,10 +111,27 @@ declare class Sanitizer {
67
111
  static clean(data: any): any;
68
112
  }
69
113
 
114
+ /**
115
+ * Base class for all Monolith Controllers.
116
+ *
117
+ * Provides basic functionality for calling actions and sanitizing data.
118
+ *
119
+ * @public
120
+ * @since 3.0.0
121
+ */
70
122
  declare abstract class BaseController {
71
123
  protected sanitizer: Sanitizer;
72
124
  call(ctx: GravitoContext, method: string): Promise<Response>;
73
125
  }
126
+ /**
127
+ * Controller class with request context awareness and helper methods.
128
+ *
129
+ * This class provides a more feature-rich base for controllers that
130
+ * need direct access to the request context and common response helpers.
131
+ *
132
+ * @public
133
+ * @since 3.0.0
134
+ */
74
135
  declare abstract class Controller {
75
136
  protected context: GravitoContext;
76
137
  /**
@@ -107,6 +168,40 @@ declare abstract class Controller {
107
168
  static call(method: string): any;
108
169
  }
109
170
 
171
+ interface GitHubDriverOptions {
172
+ owner: string;
173
+ repo: string;
174
+ ref?: string;
175
+ auth?: string;
176
+ }
177
+ declare class GitHubDriver implements ContentDriver {
178
+ private octokit;
179
+ private owner;
180
+ private repo;
181
+ private ref?;
182
+ constructor(options: GitHubDriverOptions);
183
+ read(path: string): Promise<string>;
184
+ exists(path: string): Promise<boolean>;
185
+ list(dir: string): Promise<string[]>;
186
+ }
187
+
188
+ declare class LocalDriver implements ContentDriver {
189
+ private rootDir;
190
+ constructor(rootDir: string);
191
+ read(path: string): Promise<string>;
192
+ exists(path: string): Promise<boolean>;
193
+ list(dir: string): Promise<string[]>;
194
+ }
195
+
196
+ /**
197
+ * Base class for Monolith Form Requests.
198
+ *
199
+ * Provides a structured way to handle request validation and authorization
200
+ * for the Monolith architecture.
201
+ *
202
+ * @public
203
+ * @since 3.0.0
204
+ */
110
205
  declare abstract class FormRequest {
111
206
  protected context: GravitoContext;
112
207
  /**
@@ -135,6 +230,10 @@ declare abstract class FormRequest {
135
230
  static middleware(): any;
136
231
  }
137
232
 
233
+ /**
234
+ * Utility for registering resourceful routes.
235
+ * @public
236
+ */
138
237
  declare class RouterHelper {
139
238
  /**
140
239
  * Register standard resource routes for a controller.
@@ -155,14 +254,24 @@ declare module '@gravito/core' {
155
254
  content: ContentManager;
156
255
  }
157
256
  }
257
+ /**
258
+ * Configuration for Orbit Monolith (Content Engine).
259
+ * @public
260
+ */
158
261
  interface ContentConfig {
159
262
  root?: string;
263
+ driver?: ContentDriver;
160
264
  collections?: Record<string, CollectionConfig>;
161
265
  }
266
+ /**
267
+ * Orbit Monolith Service.
268
+ * Provides flat-file CMS capabilities to Gravito applications.
269
+ * @public
270
+ */
162
271
  declare class OrbitMonolith implements GravitoOrbit {
163
272
  private config;
164
273
  constructor(config?: ContentConfig);
165
274
  install(core: PlanetCore): void;
166
275
  }
167
276
 
168
- export { BaseController, type CollectionConfig, type ContentConfig, type ContentItem, ContentManager, Controller, FormRequest, OrbitMonolith, RouterHelper as Route };
277
+ export { BaseController, type CollectionConfig, type ContentConfig, type ContentDriver, type ContentItem, ContentManager, Controller, FormRequest, GitHubDriver, type GitHubDriverOptions, LocalDriver, OrbitMonolith, RouterHelper as Route };
package/dist/index.js CHANGED
@@ -1,5 +1,4 @@
1
1
  // src/ContentManager.ts
2
- import { readdir, readFile, stat } from "fs/promises";
3
2
  import { join, parse } from "path";
4
3
  import matter from "gray-matter";
5
4
  import { marked } from "marked";
@@ -7,14 +6,16 @@ var ContentManager = class {
7
6
  /**
8
7
  * Create a new ContentManager instance.
9
8
  *
10
- * @param rootDir - The root directory of the application.
9
+ * @param driver - The content driver to use.
11
10
  */
12
- constructor(rootDir) {
13
- this.rootDir = rootDir;
11
+ constructor(driver) {
12
+ this.driver = driver;
14
13
  }
15
14
  collections = /* @__PURE__ */ new Map();
16
15
  // Simple memory cache: collection:locale:slug -> ContentItem
17
16
  cache = /* @__PURE__ */ new Map();
17
+ // In-memory search index: term -> Set<cacheKey>
18
+ searchIndex = /* @__PURE__ */ new Map();
18
19
  renderer = (() => {
19
20
  const renderer = new marked.Renderer();
20
21
  renderer.html = (html) => this.escapeHtml(html);
@@ -28,6 +29,31 @@ var ContentManager = class {
28
29
  };
29
30
  return renderer;
30
31
  })();
32
+ /**
33
+ * Clear all cached content.
34
+ * Useful for hot reload during development.
35
+ */
36
+ clearCache() {
37
+ this.cache.clear();
38
+ this.searchIndex.clear();
39
+ }
40
+ /**
41
+ * Invalidate a specific content item.
42
+ * @param collection - The collection name.
43
+ * @param slug - The file slug.
44
+ * @param locale - The locale. Defaults to 'en'.
45
+ */
46
+ invalidate(collection, slug, locale = "en") {
47
+ const safeSlug = this.sanitizeSegment(slug);
48
+ const safeLocale = this.sanitizeSegment(locale);
49
+ if (safeSlug && safeLocale) {
50
+ const cacheKey = `${collection}:${safeLocale}:${safeSlug}`;
51
+ this.cache.delete(cacheKey);
52
+ }
53
+ }
54
+ getCollectionConfig(name) {
55
+ return this.collections.get(name);
56
+ }
31
57
  /**
32
58
  * Register a new content collection.
33
59
  *
@@ -57,16 +83,17 @@ var ContentManager = class {
57
83
  return null;
58
84
  }
59
85
  const cacheKey = `${collectionName}:${locale}:${slug}`;
60
- if (this.cache.has(cacheKey)) {
61
- return this.cache.get(cacheKey);
86
+ const cachedItem = this.cache.get(cacheKey);
87
+ if (cachedItem) {
88
+ return cachedItem;
62
89
  }
63
- const filePath = join(this.rootDir, config.path, safeLocale, `${safeSlug}.md`);
90
+ const filePath = join(config.path, safeLocale, `${safeSlug}.md`);
64
91
  try {
65
- const exists = await stat(filePath).then(() => true).catch(() => false);
92
+ const exists = await this.driver.exists(filePath);
66
93
  if (!exists) {
67
94
  return null;
68
95
  }
69
- const fileContent = await readFile(filePath, "utf-8");
96
+ const fileContent = await this.driver.read(filePath);
70
97
  const { data, content, excerpt } = matter(fileContent);
71
98
  const html = await marked.parse(content, { renderer: this.renderer });
72
99
  const item = {
@@ -77,6 +104,7 @@ var ContentManager = class {
77
104
  excerpt
78
105
  };
79
106
  this.cache.set(cacheKey, item);
107
+ this.buildSearchIndex(cacheKey, item);
80
108
  return item;
81
109
  } catch (e) {
82
110
  console.error(`[Orbit-Content] Error reading file: ${filePath}`, e);
@@ -101,9 +129,9 @@ var ContentManager = class {
101
129
  if (!safeLocale) {
102
130
  return [];
103
131
  }
104
- const dirPath = join(this.rootDir, config.path, safeLocale);
132
+ const dirPath = join(config.path, safeLocale);
105
133
  try {
106
- const files = await readdir(dirPath);
134
+ const files = await this.driver.list(dirPath);
107
135
  const items = [];
108
136
  for (const file of files) {
109
137
  if (!file.endsWith(".md")) {
@@ -120,6 +148,54 @@ var ContentManager = class {
120
148
  return [];
121
149
  }
122
150
  }
151
+ /**
152
+ * Search for content items across collections and locales.
153
+ *
154
+ * @param query - The search query.
155
+ * @param options - Optional filters for collection and locale.
156
+ * @returns An array of matching ContentItems.
157
+ */
158
+ search(query, options = {}) {
159
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
160
+ if (terms.length === 0) {
161
+ return [];
162
+ }
163
+ const matches = /* @__PURE__ */ new Set();
164
+ for (const term of terms) {
165
+ const keys = this.searchIndex.get(term);
166
+ if (keys) {
167
+ for (const key of keys) {
168
+ matches.add(key);
169
+ }
170
+ }
171
+ }
172
+ const results = [];
173
+ for (const key of matches) {
174
+ const item = this.cache.get(key);
175
+ if (!item) {
176
+ continue;
177
+ }
178
+ const [collection, locale] = key.split(":");
179
+ if (options.collection && options.collection !== collection) {
180
+ continue;
181
+ }
182
+ if (options.locale && options.locale !== locale) {
183
+ continue;
184
+ }
185
+ results.push(item);
186
+ }
187
+ return results;
188
+ }
189
+ buildSearchIndex(cacheKey, item) {
190
+ const text = `${item.slug} ${item.meta.title || ""} ${item.raw} ${item.excerpt || ""}`.toLowerCase();
191
+ const terms = text.split(/[^\w\d]+/).filter((t) => t.length > 2);
192
+ for (const term of terms) {
193
+ if (!this.searchIndex.has(term)) {
194
+ this.searchIndex.set(term, /* @__PURE__ */ new Set());
195
+ }
196
+ this.searchIndex.get(term)?.add(cacheKey);
197
+ }
198
+ }
123
199
  sanitizeSegment(value) {
124
200
  if (!value) {
125
201
  return null;
@@ -156,6 +232,128 @@ var ContentManager = class {
156
232
  }
157
233
  };
158
234
 
235
+ // src/ContentWatcher.ts
236
+ import { readdirSync, watch } from "fs";
237
+ import { join as join2 } from "path";
238
+ var ContentWatcher = class {
239
+ constructor(contentManager, rootDir, options = {}) {
240
+ this.contentManager = contentManager;
241
+ this.rootDir = rootDir;
242
+ this.options = options;
243
+ }
244
+ watchers = [];
245
+ debounceTimers = /* @__PURE__ */ new Map();
246
+ watch(collectionName) {
247
+ const config = this.contentManager.getCollectionConfig(collectionName);
248
+ if (!config) {
249
+ console.warn(`[ContentWatcher] Collection '${collectionName}' not found`);
250
+ return;
251
+ }
252
+ const watchPath = join2(this.rootDir, config.path);
253
+ this.addWatcher(watchPath, collectionName);
254
+ try {
255
+ const entries = readdirSync(watchPath, { withFileTypes: true });
256
+ for (const entry of entries) {
257
+ if (entry.isDirectory()) {
258
+ this.addWatcher(join2(watchPath, entry.name), collectionName, entry.name);
259
+ }
260
+ }
261
+ } catch (_e) {
262
+ }
263
+ }
264
+ addWatcher(watchPath, collection, localePrefix) {
265
+ try {
266
+ const watcher = watch(watchPath, { recursive: true }, (_eventType, filename) => {
267
+ if (!filename) {
268
+ return;
269
+ }
270
+ const key = `${collection}:${localePrefix || ""}:${filename}`;
271
+ if (this.debounceTimers.has(key)) {
272
+ clearTimeout(this.debounceTimers.get(key));
273
+ }
274
+ this.debounceTimers.set(
275
+ key,
276
+ setTimeout(() => {
277
+ this.handleFileChange(collection, filename.toString(), localePrefix);
278
+ this.debounceTimers.delete(key);
279
+ }, this.options.debounceMs ?? 100)
280
+ );
281
+ });
282
+ this.watchers.push(watcher);
283
+ } catch (_e) {
284
+ try {
285
+ const watcher = watch(watchPath, { recursive: false }, (_eventType, filename) => {
286
+ if (!filename) {
287
+ return;
288
+ }
289
+ this.handleFileChange(collection, filename.toString(), localePrefix);
290
+ });
291
+ this.watchers.push(watcher);
292
+ } catch (err) {
293
+ console.error(`[ContentWatcher] Failed to watch ${watchPath}:`, err);
294
+ }
295
+ }
296
+ }
297
+ handleFileChange(collection, filename, localePrefix) {
298
+ const parts = filename.split(/[/\\]/);
299
+ let locale;
300
+ let file;
301
+ if (parts.length >= 2) {
302
+ locale = parts[parts.length - 2];
303
+ file = parts[parts.length - 1];
304
+ } else if (localePrefix) {
305
+ locale = localePrefix;
306
+ file = parts[0];
307
+ } else {
308
+ return;
309
+ }
310
+ if (!file.endsWith(".md")) {
311
+ return;
312
+ }
313
+ const slug = file.replace(/\.md$/, "");
314
+ this.contentManager.invalidate(collection, slug, locale);
315
+ }
316
+ close() {
317
+ for (const watcher of this.watchers) {
318
+ watcher.close();
319
+ }
320
+ this.watchers = [];
321
+ for (const timer of this.debounceTimers.values()) {
322
+ clearTimeout(timer);
323
+ }
324
+ this.debounceTimers.clear();
325
+ }
326
+ };
327
+
328
+ // src/driver/LocalDriver.ts
329
+ import { readdir, readFile, stat } from "fs/promises";
330
+ import { join as join3 } from "path";
331
+ var LocalDriver = class {
332
+ constructor(rootDir) {
333
+ this.rootDir = rootDir;
334
+ }
335
+ async read(path) {
336
+ const fullPath = join3(this.rootDir, path);
337
+ return readFile(fullPath, "utf-8");
338
+ }
339
+ async exists(path) {
340
+ try {
341
+ await stat(join3(this.rootDir, path));
342
+ return true;
343
+ } catch {
344
+ return false;
345
+ }
346
+ }
347
+ async list(dir) {
348
+ try {
349
+ const fullPath = join3(this.rootDir, dir);
350
+ return await readdir(fullPath);
351
+ } catch {
352
+ return [];
353
+ }
354
+ }
355
+ };
356
+
159
357
  // src/index.ts
160
358
  import { Schema } from "@gravito/mass";
161
359
 
@@ -255,6 +453,69 @@ var Controller = class {
255
453
  }
256
454
  };
257
455
 
456
+ // src/driver/GitHubDriver.ts
457
+ import { Octokit } from "@octokit/rest";
458
+ var GitHubDriver = class {
459
+ octokit;
460
+ owner;
461
+ repo;
462
+ ref;
463
+ constructor(options) {
464
+ this.octokit = new Octokit({ auth: options.auth });
465
+ this.owner = options.owner;
466
+ this.repo = options.repo;
467
+ this.ref = options.ref;
468
+ }
469
+ async read(path) {
470
+ try {
471
+ const { data } = await this.octokit.rest.repos.getContent({
472
+ owner: this.owner,
473
+ repo: this.repo,
474
+ path,
475
+ ref: this.ref
476
+ });
477
+ if (Array.isArray(data) || !("content" in data)) {
478
+ throw new Error(`Path is not a file: ${path}`);
479
+ }
480
+ return Buffer.from(data.content, "base64").toString("utf-8");
481
+ } catch (e) {
482
+ if (e.status === 404) {
483
+ throw new Error(`File not found: ${path}`);
484
+ }
485
+ throw e;
486
+ }
487
+ }
488
+ async exists(path) {
489
+ try {
490
+ await this.octokit.rest.repos.getContent({
491
+ owner: this.owner,
492
+ repo: this.repo,
493
+ path,
494
+ ref: this.ref
495
+ });
496
+ return true;
497
+ } catch {
498
+ return false;
499
+ }
500
+ }
501
+ async list(dir) {
502
+ try {
503
+ const { data } = await this.octokit.rest.repos.getContent({
504
+ owner: this.owner,
505
+ repo: this.repo,
506
+ path: dir,
507
+ ref: this.ref
508
+ });
509
+ if (!Array.isArray(data)) {
510
+ return [];
511
+ }
512
+ return data.map((item) => item.name);
513
+ } catch {
514
+ return [];
515
+ }
516
+ }
517
+ };
518
+
258
519
  // src/FormRequest.ts
259
520
  import { validate } from "@gravito/mass";
260
521
  var FormRequest = class {
@@ -303,7 +564,9 @@ var FormRequest = class {
303
564
  for (const issue of issues) {
304
565
  const path = Array.isArray(issue.path) ? issue.path.join(".") : issue.path || "root";
305
566
  const key = path.replace(/^\//, "").replace(/\//g, ".");
306
- if (!errors[key]) errors[key] = [];
567
+ if (!errors[key]) {
568
+ errors[key] = [];
569
+ }
307
570
  errors[key].push(issue.message || "Validation failed");
308
571
  }
309
572
  return ctx.json(
@@ -360,7 +623,16 @@ var OrbitMonolith = class {
360
623
  }
361
624
  install(core) {
362
625
  const root = this.config.root || process.cwd();
363
- const manager = new ContentManager(root);
626
+ let driver;
627
+ let isLocal = false;
628
+ if (this.config.driver) {
629
+ driver = this.config.driver;
630
+ isLocal = driver instanceof LocalDriver;
631
+ } else {
632
+ driver = new LocalDriver(root);
633
+ isLocal = true;
634
+ }
635
+ const manager = new ContentManager(driver);
364
636
  if (this.config.collections) {
365
637
  for (const [name, config] of Object.entries(this.config.collections)) {
366
638
  manager.defineCollection(name, config);
@@ -370,6 +642,15 @@ var OrbitMonolith = class {
370
642
  c.set("content", manager);
371
643
  return await next();
372
644
  });
645
+ if (process.env.NODE_ENV === "development" && isLocal) {
646
+ const watcher = new ContentWatcher(manager, root);
647
+ if (this.config.collections) {
648
+ for (const name of Object.keys(this.config.collections)) {
649
+ watcher.watch(name);
650
+ }
651
+ }
652
+ core.logger.info("Orbit Monolith Hot Reload Active \u{1F525}");
653
+ }
373
654
  core.logger.info("Orbit Monolith installed \u2B1B\uFE0F");
374
655
  }
375
656
  };
@@ -378,6 +659,8 @@ export {
378
659
  ContentManager,
379
660
  Controller,
380
661
  FormRequest,
662
+ GitHubDriver,
663
+ LocalDriver,
381
664
  OrbitMonolith,
382
665
  RouterHelper as Route,
383
666
  Schema