@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 CHANGED
@@ -1,5 +1,20 @@
1
1
  # @gravito/monolith
2
2
 
3
+ ## 3.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - feat: implement ContentWatcher for hot reload during development
8
+ - feat: implement in-memory full-text search index in ContentManager
9
+
10
+ ## 3.0.1
11
+
12
+ ### Patch Changes
13
+
14
+ - Updated dependencies
15
+ - @gravito/core@1.2.1
16
+ - @gravito/mass@3.0.1
17
+
3
18
  ## 3.0.0
4
19
 
5
20
  ### Patch Changes
package/dist/index.cjs CHANGED
@@ -34,6 +34,8 @@ __export(index_exports, {
34
34
  ContentManager: () => ContentManager,
35
35
  Controller: () => Controller,
36
36
  FormRequest: () => FormRequest,
37
+ GitHubDriver: () => GitHubDriver,
38
+ LocalDriver: () => LocalDriver,
37
39
  OrbitMonolith: () => OrbitMonolith,
38
40
  Route: () => RouterHelper,
39
41
  Schema: () => import_mass2.Schema
@@ -41,7 +43,6 @@ __export(index_exports, {
41
43
  module.exports = __toCommonJS(index_exports);
42
44
 
43
45
  // src/ContentManager.ts
44
- var import_promises = require("fs/promises");
45
46
  var import_node_path = require("path");
46
47
  var import_gray_matter = __toESM(require("gray-matter"), 1);
47
48
  var import_marked = require("marked");
@@ -49,14 +50,16 @@ var ContentManager = class {
49
50
  /**
50
51
  * Create a new ContentManager instance.
51
52
  *
52
- * @param rootDir - The root directory of the application.
53
+ * @param driver - The content driver to use.
53
54
  */
54
- constructor(rootDir) {
55
- this.rootDir = rootDir;
55
+ constructor(driver) {
56
+ this.driver = driver;
56
57
  }
57
58
  collections = /* @__PURE__ */ new Map();
58
59
  // Simple memory cache: collection:locale:slug -> ContentItem
59
60
  cache = /* @__PURE__ */ new Map();
61
+ // In-memory search index: term -> Set<cacheKey>
62
+ searchIndex = /* @__PURE__ */ new Map();
60
63
  renderer = (() => {
61
64
  const renderer = new import_marked.marked.Renderer();
62
65
  renderer.html = (html) => this.escapeHtml(html);
@@ -70,6 +73,31 @@ var ContentManager = class {
70
73
  };
71
74
  return renderer;
72
75
  })();
76
+ /**
77
+ * Clear all cached content.
78
+ * Useful for hot reload during development.
79
+ */
80
+ clearCache() {
81
+ this.cache.clear();
82
+ this.searchIndex.clear();
83
+ }
84
+ /**
85
+ * Invalidate a specific content item.
86
+ * @param collection - The collection name.
87
+ * @param slug - The file slug.
88
+ * @param locale - The locale. Defaults to 'en'.
89
+ */
90
+ invalidate(collection, slug, locale = "en") {
91
+ const safeSlug = this.sanitizeSegment(slug);
92
+ const safeLocale = this.sanitizeSegment(locale);
93
+ if (safeSlug && safeLocale) {
94
+ const cacheKey = `${collection}:${safeLocale}:${safeSlug}`;
95
+ this.cache.delete(cacheKey);
96
+ }
97
+ }
98
+ getCollectionConfig(name) {
99
+ return this.collections.get(name);
100
+ }
73
101
  /**
74
102
  * Register a new content collection.
75
103
  *
@@ -99,16 +127,17 @@ var ContentManager = class {
99
127
  return null;
100
128
  }
101
129
  const cacheKey = `${collectionName}:${locale}:${slug}`;
102
- if (this.cache.has(cacheKey)) {
103
- return this.cache.get(cacheKey);
130
+ const cachedItem = this.cache.get(cacheKey);
131
+ if (cachedItem) {
132
+ return cachedItem;
104
133
  }
105
- const filePath = (0, import_node_path.join)(this.rootDir, config.path, safeLocale, `${safeSlug}.md`);
134
+ const filePath = (0, import_node_path.join)(config.path, safeLocale, `${safeSlug}.md`);
106
135
  try {
107
- const exists = await (0, import_promises.stat)(filePath).then(() => true).catch(() => false);
136
+ const exists = await this.driver.exists(filePath);
108
137
  if (!exists) {
109
138
  return null;
110
139
  }
111
- const fileContent = await (0, import_promises.readFile)(filePath, "utf-8");
140
+ const fileContent = await this.driver.read(filePath);
112
141
  const { data, content, excerpt } = (0, import_gray_matter.default)(fileContent);
113
142
  const html = await import_marked.marked.parse(content, { renderer: this.renderer });
114
143
  const item = {
@@ -119,6 +148,7 @@ var ContentManager = class {
119
148
  excerpt
120
149
  };
121
150
  this.cache.set(cacheKey, item);
151
+ this.buildSearchIndex(cacheKey, item);
122
152
  return item;
123
153
  } catch (e) {
124
154
  console.error(`[Orbit-Content] Error reading file: ${filePath}`, e);
@@ -143,9 +173,9 @@ var ContentManager = class {
143
173
  if (!safeLocale) {
144
174
  return [];
145
175
  }
146
- const dirPath = (0, import_node_path.join)(this.rootDir, config.path, safeLocale);
176
+ const dirPath = (0, import_node_path.join)(config.path, safeLocale);
147
177
  try {
148
- const files = await (0, import_promises.readdir)(dirPath);
178
+ const files = await this.driver.list(dirPath);
149
179
  const items = [];
150
180
  for (const file of files) {
151
181
  if (!file.endsWith(".md")) {
@@ -162,6 +192,54 @@ var ContentManager = class {
162
192
  return [];
163
193
  }
164
194
  }
195
+ /**
196
+ * Search for content items across collections and locales.
197
+ *
198
+ * @param query - The search query.
199
+ * @param options - Optional filters for collection and locale.
200
+ * @returns An array of matching ContentItems.
201
+ */
202
+ search(query, options = {}) {
203
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
204
+ if (terms.length === 0) {
205
+ return [];
206
+ }
207
+ const matches = /* @__PURE__ */ new Set();
208
+ for (const term of terms) {
209
+ const keys = this.searchIndex.get(term);
210
+ if (keys) {
211
+ for (const key of keys) {
212
+ matches.add(key);
213
+ }
214
+ }
215
+ }
216
+ const results = [];
217
+ for (const key of matches) {
218
+ const item = this.cache.get(key);
219
+ if (!item) {
220
+ continue;
221
+ }
222
+ const [collection, locale] = key.split(":");
223
+ if (options.collection && options.collection !== collection) {
224
+ continue;
225
+ }
226
+ if (options.locale && options.locale !== locale) {
227
+ continue;
228
+ }
229
+ results.push(item);
230
+ }
231
+ return results;
232
+ }
233
+ buildSearchIndex(cacheKey, item) {
234
+ const text = `${item.slug} ${item.meta.title || ""} ${item.raw} ${item.excerpt || ""}`.toLowerCase();
235
+ const terms = text.split(/[^\w\d]+/).filter((t) => t.length > 2);
236
+ for (const term of terms) {
237
+ if (!this.searchIndex.has(term)) {
238
+ this.searchIndex.set(term, /* @__PURE__ */ new Set());
239
+ }
240
+ this.searchIndex.get(term)?.add(cacheKey);
241
+ }
242
+ }
165
243
  sanitizeSegment(value) {
166
244
  if (!value) {
167
245
  return null;
@@ -198,6 +276,128 @@ var ContentManager = class {
198
276
  }
199
277
  };
200
278
 
279
+ // src/ContentWatcher.ts
280
+ var import_node_fs = require("fs");
281
+ var import_node_path2 = require("path");
282
+ var ContentWatcher = class {
283
+ constructor(contentManager, rootDir, options = {}) {
284
+ this.contentManager = contentManager;
285
+ this.rootDir = rootDir;
286
+ this.options = options;
287
+ }
288
+ watchers = [];
289
+ debounceTimers = /* @__PURE__ */ new Map();
290
+ watch(collectionName) {
291
+ const config = this.contentManager.getCollectionConfig(collectionName);
292
+ if (!config) {
293
+ console.warn(`[ContentWatcher] Collection '${collectionName}' not found`);
294
+ return;
295
+ }
296
+ const watchPath = (0, import_node_path2.join)(this.rootDir, config.path);
297
+ this.addWatcher(watchPath, collectionName);
298
+ try {
299
+ const entries = (0, import_node_fs.readdirSync)(watchPath, { withFileTypes: true });
300
+ for (const entry of entries) {
301
+ if (entry.isDirectory()) {
302
+ this.addWatcher((0, import_node_path2.join)(watchPath, entry.name), collectionName, entry.name);
303
+ }
304
+ }
305
+ } catch (_e) {
306
+ }
307
+ }
308
+ addWatcher(watchPath, collection, localePrefix) {
309
+ try {
310
+ const watcher = (0, import_node_fs.watch)(watchPath, { recursive: true }, (_eventType, filename) => {
311
+ if (!filename) {
312
+ return;
313
+ }
314
+ const key = `${collection}:${localePrefix || ""}:${filename}`;
315
+ if (this.debounceTimers.has(key)) {
316
+ clearTimeout(this.debounceTimers.get(key));
317
+ }
318
+ this.debounceTimers.set(
319
+ key,
320
+ setTimeout(() => {
321
+ this.handleFileChange(collection, filename.toString(), localePrefix);
322
+ this.debounceTimers.delete(key);
323
+ }, this.options.debounceMs ?? 100)
324
+ );
325
+ });
326
+ this.watchers.push(watcher);
327
+ } catch (_e) {
328
+ try {
329
+ const watcher = (0, import_node_fs.watch)(watchPath, { recursive: false }, (_eventType, filename) => {
330
+ if (!filename) {
331
+ return;
332
+ }
333
+ this.handleFileChange(collection, filename.toString(), localePrefix);
334
+ });
335
+ this.watchers.push(watcher);
336
+ } catch (err) {
337
+ console.error(`[ContentWatcher] Failed to watch ${watchPath}:`, err);
338
+ }
339
+ }
340
+ }
341
+ handleFileChange(collection, filename, localePrefix) {
342
+ const parts = filename.split(/[/\\]/);
343
+ let locale;
344
+ let file;
345
+ if (parts.length >= 2) {
346
+ locale = parts[parts.length - 2];
347
+ file = parts[parts.length - 1];
348
+ } else if (localePrefix) {
349
+ locale = localePrefix;
350
+ file = parts[0];
351
+ } else {
352
+ return;
353
+ }
354
+ if (!file.endsWith(".md")) {
355
+ return;
356
+ }
357
+ const slug = file.replace(/\.md$/, "");
358
+ this.contentManager.invalidate(collection, slug, locale);
359
+ }
360
+ close() {
361
+ for (const watcher of this.watchers) {
362
+ watcher.close();
363
+ }
364
+ this.watchers = [];
365
+ for (const timer of this.debounceTimers.values()) {
366
+ clearTimeout(timer);
367
+ }
368
+ this.debounceTimers.clear();
369
+ }
370
+ };
371
+
372
+ // src/driver/LocalDriver.ts
373
+ var import_promises = require("fs/promises");
374
+ var import_node_path3 = require("path");
375
+ var LocalDriver = class {
376
+ constructor(rootDir) {
377
+ this.rootDir = rootDir;
378
+ }
379
+ async read(path) {
380
+ const fullPath = (0, import_node_path3.join)(this.rootDir, path);
381
+ return (0, import_promises.readFile)(fullPath, "utf-8");
382
+ }
383
+ async exists(path) {
384
+ try {
385
+ await (0, import_promises.stat)((0, import_node_path3.join)(this.rootDir, path));
386
+ return true;
387
+ } catch {
388
+ return false;
389
+ }
390
+ }
391
+ async list(dir) {
392
+ try {
393
+ const fullPath = (0, import_node_path3.join)(this.rootDir, dir);
394
+ return await (0, import_promises.readdir)(fullPath);
395
+ } catch {
396
+ return [];
397
+ }
398
+ }
399
+ };
400
+
201
401
  // src/index.ts
202
402
  var import_mass2 = require("@gravito/mass");
203
403
 
@@ -297,6 +497,69 @@ var Controller = class {
297
497
  }
298
498
  };
299
499
 
500
+ // src/driver/GitHubDriver.ts
501
+ var import_rest = require("@octokit/rest");
502
+ var GitHubDriver = class {
503
+ octokit;
504
+ owner;
505
+ repo;
506
+ ref;
507
+ constructor(options) {
508
+ this.octokit = new import_rest.Octokit({ auth: options.auth });
509
+ this.owner = options.owner;
510
+ this.repo = options.repo;
511
+ this.ref = options.ref;
512
+ }
513
+ async read(path) {
514
+ try {
515
+ const { data } = await this.octokit.rest.repos.getContent({
516
+ owner: this.owner,
517
+ repo: this.repo,
518
+ path,
519
+ ref: this.ref
520
+ });
521
+ if (Array.isArray(data) || !("content" in data)) {
522
+ throw new Error(`Path is not a file: ${path}`);
523
+ }
524
+ return Buffer.from(data.content, "base64").toString("utf-8");
525
+ } catch (e) {
526
+ if (e.status === 404) {
527
+ throw new Error(`File not found: ${path}`);
528
+ }
529
+ throw e;
530
+ }
531
+ }
532
+ async exists(path) {
533
+ try {
534
+ await this.octokit.rest.repos.getContent({
535
+ owner: this.owner,
536
+ repo: this.repo,
537
+ path,
538
+ ref: this.ref
539
+ });
540
+ return true;
541
+ } catch {
542
+ return false;
543
+ }
544
+ }
545
+ async list(dir) {
546
+ try {
547
+ const { data } = await this.octokit.rest.repos.getContent({
548
+ owner: this.owner,
549
+ repo: this.repo,
550
+ path: dir,
551
+ ref: this.ref
552
+ });
553
+ if (!Array.isArray(data)) {
554
+ return [];
555
+ }
556
+ return data.map((item) => item.name);
557
+ } catch {
558
+ return [];
559
+ }
560
+ }
561
+ };
562
+
300
563
  // src/FormRequest.ts
301
564
  var import_mass = require("@gravito/mass");
302
565
  var FormRequest = class {
@@ -345,7 +608,9 @@ var FormRequest = class {
345
608
  for (const issue of issues) {
346
609
  const path = Array.isArray(issue.path) ? issue.path.join(".") : issue.path || "root";
347
610
  const key = path.replace(/^\//, "").replace(/\//g, ".");
348
- if (!errors[key]) errors[key] = [];
611
+ if (!errors[key]) {
612
+ errors[key] = [];
613
+ }
349
614
  errors[key].push(issue.message || "Validation failed");
350
615
  }
351
616
  return ctx.json(
@@ -402,7 +667,16 @@ var OrbitMonolith = class {
402
667
  }
403
668
  install(core) {
404
669
  const root = this.config.root || process.cwd();
405
- const manager = new ContentManager(root);
670
+ let driver;
671
+ let isLocal = false;
672
+ if (this.config.driver) {
673
+ driver = this.config.driver;
674
+ isLocal = driver instanceof LocalDriver;
675
+ } else {
676
+ driver = new LocalDriver(root);
677
+ isLocal = true;
678
+ }
679
+ const manager = new ContentManager(driver);
406
680
  if (this.config.collections) {
407
681
  for (const [name, config] of Object.entries(this.config.collections)) {
408
682
  manager.defineCollection(name, config);
@@ -412,6 +686,15 @@ var OrbitMonolith = class {
412
686
  c.set("content", manager);
413
687
  return await next();
414
688
  });
689
+ if (process.env.NODE_ENV === "development" && isLocal) {
690
+ const watcher = new ContentWatcher(manager, root);
691
+ if (this.config.collections) {
692
+ for (const name of Object.keys(this.config.collections)) {
693
+ watcher.watch(name);
694
+ }
695
+ }
696
+ core.logger.info("Orbit Monolith Hot Reload Active \u{1F525}");
697
+ }
415
698
  core.logger.info("Orbit Monolith installed \u2B1B\uFE0F");
416
699
  }
417
700
  };
@@ -421,6 +704,8 @@ var OrbitMonolith = class {
421
704
  ContentManager,
422
705
  Controller,
423
706
  FormRequest,
707
+ GitHubDriver,
708
+ LocalDriver,
424
709
  OrbitMonolith,
425
710
  Route,
426
711
  Schema
package/dist/index.d.cts 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 };