@raketa-cloud/admin 1.3.0 → 1.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raketa-cloud/admin",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "main": "src/index.js",
5
5
  "files": [
6
6
  "src/",
@@ -12,19 +12,19 @@
12
12
  "prepare": "npx tailwindcss -i ./src/styles/application.css -o ./dist/application.css"
13
13
  },
14
14
  "devDependencies": {
15
- "@babel/core": "7.24.7",
16
- "@babel/preset-env": "7.24.7",
17
- "@babel/preset-typescript": "7.24.7",
15
+ "@babel/core": "7.26.10",
16
+ "@babel/preset-env": "7.26.9",
17
+ "@babel/preset-typescript": "7.26.0",
18
18
  "@repo/typescript-config": "*",
19
19
  "@tailwindcss/forms": "0.5.7",
20
20
  "@tailwindcss/typography": "0.5.13",
21
- "@types/node": "20.11.24",
21
+ "@types/node": "22.13.10",
22
22
  "jest": "29.7.0",
23
23
  "tailwindcss": "3.4.4",
24
- "typescript": "5.3.3"
24
+ "typescript": "5.8.2"
25
25
  },
26
26
  "dependencies": {
27
27
  "ejs": "3.1.10",
28
- "graphql-request": "5.1.0"
28
+ "graphql-request": "7.1.2"
29
29
  }
30
30
  }
@@ -1,51 +1,241 @@
1
- import Adapter from "./Adapter";
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import ejs from "ejs";
2
4
  import Logger from "./Logger";
5
+ import currentPath from "./currentPath.cjs";
6
+ import FormBuilder from "./helpers/FormBuilder";
7
+ import HTMLBuilder from "./helpers/HTMLBuilder";
8
+ import Inflector from "./helpers/Inflector";
3
9
  import RaketaContext from "./RaketaContext";
4
10
  import setupAuthentication from "./setupAuthentication";
11
+ import form_for from "./helpers/form_for";
12
+ import history from "./helpers/historyUtils";
13
+ import pagination from "./helpers/pagination_helper";
14
+ import sort_button from "./helpers/sort_button";
15
+ import utils from "./helpers/utils";
16
+
17
+ function escapeHtml(string) {
18
+ const escape = {
19
+ '"': '"',
20
+ '&': '&',
21
+ "'": ''',
22
+ '<': '&lt;',
23
+ '>': '&gt;'
24
+ };
25
+
26
+ return String(string).replace(/["&'<>]/g, char => escape[char]);
27
+ }
28
+
29
+ const renderError = (message, stack) => {
30
+ const errorCSS = `
31
+ * { box-sizing: border-box; margin: 0; padding: 0; }
32
+ html { font-family: sans-serif; font-size: 16px; padding: 2rem; background-color: pink; }
33
+ h1 { margin-bottom: 1rem; color: FireBrick; }
34
+ .fail-squid { font-size: 7rem; }
35
+ pre { padding: 1.5rem 2rem; border-radius: 0.5rem; background-color: black; color: white; line-height: 1.5; font-size: 1.25rem; }
36
+ `;
37
+ return `<style>${errorCSS}</style><div class="fail-squid">🦑</div><h1>${message}</h1><pre>${escapeHtml(stack)}</pre>`;
38
+ };
39
+
40
+ // Convert to array if needed
41
+ const toArray = value => Array.isArray(value) ? value : [value].filter(Boolean);
42
+
43
+ // Handle nested form values
44
+ function putIn(result, key, value) {
45
+ const segments = key.split(/[\[\]]+/).filter(Boolean);
46
+ let current = result;
47
+
48
+ segments.forEach((segment, idx) => {
49
+ const isLastSegment = idx === segments.length - 1;
50
+
51
+ if (isLastSegment) {
52
+ // Set value at final segment
53
+ current[segment] = key.endsWith("[]") || Array.isArray(value)
54
+ ? toArray(value)
55
+ : value;
56
+ } else {
57
+ // Create path to next segment
58
+ current[segment] ??= {};
59
+ current = current[segment];
60
+ }
61
+ });
62
+ }
63
+
64
+ // Normalize paths for consistent handling
65
+ const sanitizePath = filePath => path.normalize(path.join("/", ...filePath.split("/")));
66
+
67
+ // Find template path with fallbacks
68
+ function getPartialPath(partialPath, views, viewsFallback) {
69
+ const paths = [
70
+ sanitizePath(`${process.cwd()}/src/views/${views}/${partialPath}`),
71
+ sanitizePath(`${currentPath}/views/${views}/${partialPath}`),
72
+ viewsFallback && sanitizePath(`${currentPath}/views/${viewsFallback}/${partialPath}`)
73
+ ].filter(Boolean);
74
+
75
+ // Return first existing path
76
+ const existingPath = paths.find(path => existsSync(path));
77
+
78
+ if (existingPath) return existingPath;
79
+
80
+ throw new Error(`Template not found: "${partialPath}"\nSearched in:\n${paths.join('\n')}`);
81
+ }
5
82
 
83
+ // Parse request body based on content type
6
84
  async function parseBody(req) {
7
- switch (req.header("Content-type")) {
8
- case "multipart/form-data":
9
- case "application/x-www-form-urlencoded":
10
- return req.parseBody();
11
- case "application/json":
12
- return req.json();
13
- case "text/plain":
14
- return req.text();
15
- default:
16
- // That is an ok default for now
17
- return req.parseBody();
18
- }
85
+ const contentType = req.header("Content-type") || "";
86
+
87
+ if (contentType.includes("json")) return req.json();
88
+ if (contentType === "text/plain") return req.text();
89
+
90
+ // Default for form data and other content types
91
+ return req.parseBody();
19
92
  }
20
93
 
94
+ // Convert body to request parameters
21
95
  function bodyToParams(req, parsedBody, adapter) {
22
- switch (req.header("Content-type")) {
23
- case "multipart/form-data":
24
- case "application/x-www-form-urlencoded":
25
- return adapter.parseFormBody(parsedBody);
26
- default:
27
- return parsedBody;
96
+ const contentType = req.header("Content-type") || "";
97
+
98
+ if (contentType.includes("form-data") || contentType.includes("urlencoded")) {
99
+ return adapter.parseFormBody(parsedBody);
28
100
  }
101
+
102
+ return parsedBody;
29
103
  }
30
104
 
31
- export default class HonoAdapter extends Adapter {
105
+ export default class HonoAdapter {
32
106
  constructor(router, config) {
33
- super(router);
34
107
  this.globalCtx = (ctx) => ctx;
35
108
  this.router = router;
36
109
  this.globalConfig = config;
110
+ this.globalBeforeFilters = [];
111
+ this.globalAfterFilters = [];
37
112
 
38
113
  // TODO: Pass in a path to redirect to after login?
39
- setupAuthentication(router, config);
40
-
41
- router.get("/", (c) => {
42
- return c.redirect("/en");
43
- });
114
+ if (config) {
115
+ setupAuthentication(router, config);
116
+
117
+ router.get("/", (c) => {
118
+ return c.redirect("/en");
119
+ });
120
+ }
44
121
  }
45
122
 
46
123
  setGlobalContext(globalCtx) {
47
124
  this.globalCtx = globalCtx;
48
125
  }
126
+
127
+ setBeforeFilter(pattern, filter) {
128
+ this.globalBeforeFilters.push({ pattern, filter });
129
+ }
130
+
131
+ setAfterFilter(pattern, filter) {
132
+ this.globalAfterFilters.push({ pattern, filter });
133
+ }
134
+
135
+ getActionPath(views, viewsFallback, action) {
136
+ const packagePath = viewsFallback
137
+ ? `${currentPath}/views/${viewsFallback}/${action}.ejs`
138
+ : `${currentPath}/views/${views}/${action}.ejs`;
139
+
140
+ const appPath = `${process.cwd()}/src/views/${views}/${action}.ejs`;
141
+
142
+ return existsSync(appPath) ? appPath : packagePath;
143
+ }
144
+
145
+ getHelpers(viewHelpersConfig) {
146
+ const t = new HTMLBuilder();
147
+ const f = new FormBuilder();
148
+
149
+ return {
150
+ t,
151
+ f,
152
+ form_for: form_for(viewHelpersConfig),
153
+ history,
154
+ utils,
155
+ sort_button,
156
+ pagination,
157
+ Inflector,
158
+ };
159
+ }
160
+
161
+ async renderLayout(assigns, body) {
162
+ const layoutTemplate = `${currentPath}/views/layouts/application.ejs`;
163
+
164
+ return await ejs.renderFile(layoutTemplate, {
165
+ ...assigns,
166
+ body,
167
+ });
168
+ }
169
+
170
+ renderPartial(assigns, views, viewsFallback) {
171
+ return function render(relativePath, localAssigns) {
172
+ const partialPath = getPartialPath(relativePath, views, viewsFallback);
173
+
174
+ try {
175
+ // Synchronously read the partial file as ejs does not support async/await
176
+ const partialTemplate = readFileSync(partialPath, "utf8");
177
+ const rendered = ejs.render(
178
+ partialTemplate,
179
+ {
180
+ ...assigns,
181
+ ...localAssigns,
182
+ },
183
+ { filename: partialPath }
184
+ );
185
+
186
+ return rendered;
187
+ } catch (e) {
188
+ const error = `Error rendering:\n ${partialPath}`;
189
+ Logger.error(`${error}\n\n${e.stack}`);
190
+
191
+ return renderError(error, e.stack);
192
+ }
193
+ };
194
+ }
195
+
196
+ async renderAction(
197
+ { views, viewsFallback, action, assigns },
198
+ viewHelpersConfig = {}
199
+ ) {
200
+ // Pick the parent here in the views so we can reuse stuff...
201
+ const fullTemplatePath = this.getActionPath(views, viewsFallback, action);
202
+ const renderAssigns = { ...this.getHelpers(viewHelpersConfig), ...assigns };
203
+ const renderPartialHelper = this.renderPartial(
204
+ renderAssigns,
205
+ views,
206
+ viewsFallback
207
+ );
208
+
209
+ try {
210
+ // Synchronously read the full template file
211
+ const template = readFileSync(fullTemplatePath, "utf8");
212
+
213
+ // Render the full template with synchronous partial rendering
214
+ const rendered = ejs.render(
215
+ template,
216
+ {
217
+ ...renderAssigns,
218
+ render_partial: renderPartialHelper,
219
+ },
220
+ { filename: fullTemplatePath }
221
+ );
222
+
223
+ const fullPage = this.renderLayout(renderAssigns, rendered);
224
+
225
+ return fullPage;
226
+ } catch (e) {
227
+ const error = `Error rendering:\n ${fullTemplatePath}`;
228
+ Logger.error(`${error}\n\n${e.stack}`);
229
+
230
+ return renderError(error, e.stack);
231
+ }
232
+ }
233
+
234
+ parseFormBody(body) {
235
+ const result = {};
236
+ Object.entries(body).forEach(([key, value]) => putIn(result, key, value));
237
+ return result;
238
+ }
49
239
 
50
240
  async createContext(router, next, verb, resource, action) {
51
241
  const body = await parseBody(router.req);
@@ -71,198 +261,144 @@ export default class HonoAdapter extends Adapter {
71
261
  });
72
262
  }
73
263
 
74
- async createAction(
75
- [verb, path, resource, views, action, handler, before_filter, after_filter],
76
- resourceConfig
77
- ) {
264
+ async createAction([verb, path, resource, views, action, handler], resourceConfig) {
78
265
  const method = this.getMethod(verb);
79
-
80
- Object.keys(this.globalConfig.i18n.locales).forEach((currentLocale) => {
266
+ const { i18n } = this.globalConfig;
267
+
268
+ Object.keys(i18n.locales).forEach((currentLocale) => {
81
269
  const completePath = `/${currentLocale}${path}`;
82
-
83
- // A terrible, terrible hack...
84
- // So how do we override for the entire handler.. do we need to completely override it, or just partially? Fun stuff?
85
- this.router.router.routes = this.router.router.routes.filter((route) => {
86
- return route[0] !== verb || route[1] !== completePath;
87
- });
270
+
271
+ // Clean up existing routes with same path/verb
272
+ this.router.router.routes = this.router.router.routes.filter(route =>
273
+ route[0] !== verb || route[1] !== completePath
274
+ );
88
275
 
89
276
  method(completePath, async (routerCtx, next) => {
90
- const ctx = await this.createContext(
91
- routerCtx,
92
- next,
93
- verb,
94
- resource,
95
- action
96
- );
97
-
98
- ctx.resourceConfig = resourceConfig;
99
- ctx.locales = this.globalConfig.i18n.locales;
100
- ctx.defaultLocale = this.globalConfig.i18n.defaultLocale;
101
- ctx.maxOpenRevisions = this.globalConfig?.maxOpenRevisions || 3;
102
- ctx.locale = currentLocale;
103
- ctx.remoteConfig = { ...this.globalConfig.remoteConfig };
104
- ctx.logger = Logger;
277
+ // Create context with request data
278
+ const ctx = await this.createContext(routerCtx, next, verb, resource, action);
279
+
280
+ // Set context properties
281
+ Object.assign(ctx, {
282
+ resourceConfig,
283
+ locales: i18n.locales,
284
+ defaultLocale: i18n.defaultLocale,
285
+ maxOpenRevisions: this.globalConfig?.maxOpenRevisions || 3,
286
+ locale: currentLocale,
287
+ remoteConfig: { ...this.globalConfig.remoteConfig },
288
+ logger: Logger
289
+ });
105
290
 
291
+ // Build context object
106
292
  const _context = {
107
- timezones: this.globalConfig.i18n.timezones,
293
+ timezones: i18n.timezones,
108
294
  locales: ctx.locales,
109
295
  locale: ctx.locale,
110
- defaultLocale: ctx.defaultLocale,
296
+ defaultLocale: ctx.defaultLocale,
111
297
  maxOpenRevisions: ctx.maxOpenRevisions,
112
298
  path: ctx.req.path,
113
299
  query: ctx.query || {},
114
- layout: {
115
- ...this.globalConfig.layout,
116
- },
117
- resourceConfig: resourceConfig,
118
- adminJwt: ctx.authentication.token,
300
+ layout: { ...this.globalConfig.layout },
301
+ resourceConfig,
302
+ adminJwt: ctx.authentication?.token,
119
303
  remoteConfig: { ...this.globalConfig.remoteConfig },
120
- globalConfig: this.globalConfig,
304
+ globalConfig: this.globalConfig
121
305
  };
122
306
 
123
- ctx.logger.info(
124
- `${ctx.method} "${ctx.rawPath}" -> ${resource}/${action} | ${JSON.stringify(ctx.params)}`
125
- );
126
-
307
+ ctx.logger.info(`${ctx.method} "${ctx.rawPath}" -> ${resource}/${action} | ${JSON.stringify(ctx.params)}`);
308
+
127
309
  const assigns = {
128
310
  beforeFilterAssigns: {},
129
311
  actionAssigns: {},
130
- afterFilterAssigns: {},
312
+ afterFilterAssigns: {}
131
313
  };
132
314
 
133
315
  // Initialize global context
134
316
  ctx.initGlobalContext();
135
317
 
136
- // Process before filters
137
- if (before_filter) {
138
- const {
139
- redirect,
140
- json,
141
- assigns: beforeFilterAssigns = {},
142
- } = before_filter(ctx) || {};
143
-
144
- if (redirect) return routerCtx.redirect(redirect);
145
-
146
- if (json) {
147
- return routerCtx.json(json);
318
+ // Process global before filters
319
+ for (const { pattern, filter } of this.globalBeforeFilters) {
320
+ if (pattern.test(ctx.path)) {
321
+ const result = filter(ctx) || {};
322
+
323
+ if (result.redirect) return routerCtx.redirect(result.redirect);
324
+ if (result.json) return routerCtx.json(result.json);
325
+
326
+ Object.assign(assigns.beforeFilterAssigns, result.assigns || {});
148
327
  }
149
-
150
- assigns.beforeFilterAssigns = beforeFilterAssigns;
151
328
  }
152
329
 
153
- // Process action
154
- const {
155
- headers,
156
- redirect,
157
- json,
158
- assigns: actionAssigns = {},
159
- } = (await handler(ctx, resourceConfig)) || {};
160
- assigns.actionAssigns = actionAssigns;
161
-
162
- this.applyHeaders(headers, routerCtx);
163
-
164
- if (redirect) return routerCtx.redirect(redirect);
165
-
166
- // Process after filters
167
- if (after_filter) {
168
- const {
169
- redirect,
170
- json,
171
- assigns: afterFilterAssigns = {},
172
- } = after_filter(ctx) || {};
173
-
174
- if (redirect) return routerCtx.redirect(redirect);
175
-
176
- if (json) {
177
- return routerCtx.json(json);
330
+ // Process action handler
331
+ const result = await handler(ctx, resourceConfig) || {};
332
+ assigns.actionAssigns = result.assigns || {};
333
+
334
+ if (result.headers) this.applyHeaders(result.headers, routerCtx);
335
+ if (result.redirect) return routerCtx.redirect(result.redirect);
336
+ if (result.json) return routerCtx.json(result.json);
337
+
338
+ // Process global after filters
339
+ for (const { pattern, filter } of this.globalAfterFilters) {
340
+ if (pattern.test(ctx.path)) {
341
+ const afterResult = filter(ctx) || {};
342
+
343
+ if (afterResult.redirect) return routerCtx.redirect(afterResult.redirect);
344
+ if (afterResult.json) return routerCtx.json(afterResult.json);
345
+
346
+ Object.assign(assigns.afterFilterAssigns, afterResult.assigns || {});
178
347
  }
179
-
180
- assigns.afterFilterAssigns = afterFilterAssigns;
181
348
  }
182
349
 
350
+ // Merge all assigns
183
351
  const fullAssigns = {
184
352
  ...ctx.assigns,
185
353
  ...assigns.beforeFilterAssigns,
186
354
  ...assigns.actionAssigns,
187
355
  ...assigns.afterFilterAssigns,
188
- _context: _context,
356
+ _context
189
357
  };
190
358
 
191
- if (json) {
192
- return routerCtx.json(json);
193
- }
194
-
195
- const rendered = this.renderAction(
196
- {
197
- views: views,
198
- viewsFallback: resourceConfig.viewsFallback,
199
- action,
200
- assigns: fullAssigns,
201
- },
202
- this.globalConfig?.helpers || {}
203
- );
359
+ // Render the view
360
+ const rendered = this.renderAction({
361
+ views,
362
+ viewsFallback: resourceConfig.viewsFallback,
363
+ action,
364
+ assigns: fullAssigns
365
+ }, this.globalConfig?.helpers || {});
204
366
 
205
367
  return routerCtx.html(rendered);
206
368
  });
207
369
  });
208
370
  }
209
371
 
372
+ // Single method to handle all route types
210
373
  routeCollection(mapping, resourceConfig) {
211
- mapping.forEach((handlerMap) =>
212
- this.createAction(handlerMap, resourceConfig)
213
- );
374
+ mapping.forEach(handlerMap => this.createAction(handlerMap, resourceConfig));
214
375
  }
215
376
 
216
- routeNestedCollection(mapping, resourceConfig) {
217
- mapping.forEach((handlerMap) =>
218
- this.createAction(handlerMap, resourceConfig)
219
- );
220
- }
221
-
222
- routeMember(mapping, resourceConfig) {
223
- mapping.forEach((handlerMap) =>
224
- this.createAction(handlerMap, resourceConfig)
225
- );
226
- }
227
-
228
- routeNestedMember(mapping, resourceConfig) {
229
- mapping.forEach((handlerMap) =>
230
- this.createAction(handlerMap, resourceConfig)
231
- );
232
- }
377
+ // Use the same implementation for all route types
378
+ routeNestedCollection = this.routeCollection;
379
+ routeMember = this.routeCollection;
380
+ routeNestedMember = this.routeCollection;
233
381
 
234
382
  getMethod(verb) {
235
- return (
236
- {
237
- GET: this.router.get,
238
- POST: this.router.post,
239
- PUT: this.router.put,
240
- DELETE: this.router.delete,
241
- }[verb] || this.router.get
242
- );
383
+ const methods = {
384
+ GET: this.router.get,
385
+ POST: this.router.post,
386
+ PUT: this.router.put,
387
+ DELETE: this.router.delete,
388
+ };
389
+ return methods[verb] || this.router.get;
243
390
  }
244
391
 
245
392
  applyHeaders(headers, router) {
246
- if (!headers) {
247
- return;
248
- }
249
-
250
- for (const [key, value] of Object.entries(headers)) {
251
- router.header(key, value);
252
- }
393
+ if (!headers) return;
394
+ Object.entries(headers).forEach(([key, value]) => router.header(key, value));
253
395
  }
254
396
 
255
397
  formatQuery(queries) {
256
398
  const result = {};
257
-
258
- for (const [key, value] of Object.entries(queries)) {
259
- if (value.length === 1) {
260
- result[key] = value[0];
261
- } else {
262
- result[key] = value;
263
- }
264
- }
265
-
266
- return { ...result };
399
+ Object.entries(queries).forEach(([key, value]) => {
400
+ result[key] = value.length === 1 ? value[0] : value;
401
+ });
402
+ return result;
267
403
  }
268
- }
404
+ }
package/src/filters.js ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Sets a global filter to be applied to routes matching the pattern
3
+ * @param {HonoAdapter} adapter - The adapter instance
4
+ * @param {RegExp} pattern - Route pattern to match
5
+ * @param {Function} filter - The filter function
6
+ */
7
+ export const setBeforeFilter = (adapter, pattern, filter) =>
8
+ adapter.setBeforeFilter(pattern, filter);
9
+
10
+ /**
11
+ * Sets a global after filter to be applied to routes matching the pattern
12
+ * @param {HonoAdapter} adapter - The adapter instance
13
+ * @param {RegExp} pattern - Route pattern to match
14
+ * @param {Function} filter - The filter function
15
+ */
16
+ export const setAfterFilter = (adapter, pattern, filter) =>
17
+ adapter.setAfterFilter(pattern, filter);
package/src/index.js CHANGED
@@ -12,6 +12,7 @@ export { default as setupCloud } from "./setupCloud";
12
12
  export { default as routeStandard } from "./routeStandard";
13
13
  export { default as routeCategoriesModule } from "./routeCategoriesModule";
14
14
  export { default as buildResourcePath } from "./buildResourcePath";
15
+ export { setBeforeFilter, setAfterFilter } from "./filters";
15
16
 
16
17
  export { default as RaketaClient } from "./cloud/RaketaClient";
17
18
  export { default as Redirects } from "./cloud/Redirects";