webspresso 0.0.74 → 0.0.75

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.
Files changed (60) hide show
  1. package/README.md +41 -3
  2. package/bin/commands/orm-map.js +139 -0
  3. package/bin/commands/skill.js +22 -8
  4. package/bin/utils/orm-map-html.js +689 -0
  5. package/bin/utils/orm-map-load.js +85 -0
  6. package/bin/utils/orm-map-snapshot.js +179 -0
  7. package/bin/utils/resolve-webspresso-orm.js +23 -0
  8. package/bin/webspresso.js +2 -0
  9. package/core/auth/manager.js +14 -1
  10. package/core/kernel/app.js +96 -0
  11. package/core/kernel/base-repository.js +143 -0
  12. package/core/kernel/events.js +101 -0
  13. package/core/kernel/flow.js +22 -0
  14. package/core/kernel/index.js +17 -0
  15. package/core/kernel/plugin.js +23 -0
  16. package/core/kernel/plugins/sample-seo.js +26 -0
  17. package/core/kernel/run-demo.js +58 -0
  18. package/core/kernel/view.js +167 -0
  19. package/core/openapi/build-from-api-routes.js +8 -2
  20. package/core/orm/model.js +3 -1
  21. package/core/url-path-normalize.js +30 -0
  22. package/index.d.ts +168 -1
  23. package/index.js +20 -2
  24. package/package.json +11 -1
  25. package/plugins/admin-panel/api.js +43 -15
  26. package/plugins/admin-panel/client/README.md +39 -0
  27. package/plugins/admin-panel/client/load-parts.js +74 -0
  28. package/plugins/admin-panel/client/manifest.parts.json +12 -0
  29. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +150 -0
  30. package/plugins/admin-panel/client/parts/02-filter-components.js +554 -0
  31. package/plugins/admin-panel/client/parts/03-pagination-intro.js +70 -0
  32. package/plugins/admin-panel/client/parts/04-field-renderers.js +287 -0
  33. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +335 -0
  34. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +125 -0
  35. package/plugins/admin-panel/client/parts/07-model-list.js +596 -0
  36. package/plugins/admin-panel/client/parts/08-record-list.js +536 -0
  37. package/plugins/admin-panel/client/parts/09-record-form.js +170 -0
  38. package/plugins/admin-panel/client/parts/10-export-registry.js +11 -0
  39. package/plugins/admin-panel/client/verify-spa-parts.js +32 -0
  40. package/plugins/admin-panel/client/vite.config.example.mjs +22 -0
  41. package/plugins/admin-panel/components.js +4 -2640
  42. package/plugins/admin-panel/core/api-extensions.js +100 -10
  43. package/plugins/admin-panel/index.js +3 -0
  44. package/plugins/admin-panel/lib/is-rich-text-empty.js +23 -0
  45. package/plugins/admin-panel/lib/sanitize-rich-html.js +106 -0
  46. package/plugins/admin-panel/modules/dashboard.js +3 -2
  47. package/plugins/admin-panel/modules/user-management.js +90 -20
  48. package/plugins/index.js +4 -0
  49. package/plugins/rate-limit/index.js +178 -0
  50. package/plugins/redirect/index.js +204 -0
  51. package/plugins/rest-resources/index.js +2 -1
  52. package/plugins/swagger.js +2 -1
  53. package/plugins/upload/local-file-provider.js +6 -2
  54. package/src/file-router.js +191 -50
  55. package/src/njk-frontmatter.js +156 -0
  56. package/src/plugin-manager.js +4 -2
  57. package/src/server.js +26 -9
  58. package/templates/skills/webspresso-usage/REFERENCE-framework.md +276 -0
  59. package/templates/skills/webspresso-usage/REFERENCE-kernel.md +93 -0
  60. package/templates/skills/webspresso-usage/SKILL.md +29 -278
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @module core/kernel/plugin
3
+ */
4
+
5
+ /**
6
+ * Plugin descriptor consumed by {@link createApp}.
7
+ *
8
+ * @param {{
9
+ * name: string,
10
+ * events?: (app: Record<string, unknown>) => void,
11
+ * views?: () => {
12
+ * namespace: string,
13
+ * layouts?: Record<string, string>,
14
+ * pages?: Record<string, string>,
15
+ * partials?: Record<string, string>,
16
+ * },
17
+ * }} definition
18
+ */
19
+ function definePlugin(definition) {
20
+ return definition;
21
+ }
22
+
23
+ module.exports = { definePlugin };
@@ -0,0 +1,26 @@
1
+ const { definePlugin } = require('../plugin');
2
+
3
+ module.exports = definePlugin({
4
+ name: 'seo-plugin',
5
+
6
+ events(app) {
7
+ app.events.on('orm.post.afterCreate', async () => {
8
+ console.log('[plugin] SEO update triggered');
9
+ });
10
+ },
11
+
12
+ views() {
13
+ return {
14
+ namespace: 'seo',
15
+ layouts: {
16
+ main: '<html><body><div class="wrap">{{ content }}</div></body></html>',
17
+ },
18
+ pages: {
19
+ home: '<h1>{{ title }}</h1><p>Welcome</p>',
20
+ },
21
+ partials: {
22
+ badge: '<span class="badge">{{ label }}</span>',
23
+ },
24
+ };
25
+ },
26
+ });
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Demo: PostRepository + plugin + flow. Run: node core/kernel/run-demo.js
4
+ */
5
+
6
+ const { createApp, defineFlow, BaseRepository } = require('./app');
7
+ const sampleSeo = require('./plugins/sample-seo');
8
+
9
+ class PostRepository extends BaseRepository {
10
+ constructor(events) {
11
+ super(events, { resource: 'post', source: 'orm' });
12
+ }
13
+ }
14
+
15
+ async function main() {
16
+ const app = createApp();
17
+
18
+ app.events.on('orm.post.beforeCreate', async () => {
19
+ console.log('[beforeCreate]');
20
+ });
21
+ app.events.on('orm.post.afterCreate', async () => {
22
+ console.log('[afterCreate]');
23
+ });
24
+
25
+ app.registerPlugin(sampleSeo);
26
+
27
+ app.registerFlow(
28
+ defineFlow({
29
+ id: 'sitemap-on-publish',
30
+ trigger: 'orm.post.afterCreate',
31
+ when: (ctx) => ctx.payload.record?.status === 'published',
32
+ actions: [
33
+ async () => {
34
+ console.log('[flow] Run sitemap update');
35
+ },
36
+ ],
37
+ }),
38
+ );
39
+
40
+ const posts = new PostRepository(app.events);
41
+
42
+ console.log('--- create post (published) ---');
43
+ await posts.create({ title: 'Hello', status: 'published' });
44
+
45
+ console.log('\n--- view render (inline plugin template) ---');
46
+ const html = app.view.renderView('seo::home', { title: 'Kernel' }, {
47
+ layout: 'seo::main',
48
+ });
49
+ console.log(html.slice(0, 120).replace(/\s+/g, ' ') + '...');
50
+
51
+ console.log('\n--- partial ---');
52
+ console.log(app.view.renderPartial('seo::badge', { label: 'new' }));
53
+ }
54
+
55
+ main().catch((err) => {
56
+ console.error(err);
57
+ process.exit(1);
58
+ });
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Namespaced view resolution and minimal {{ var }} rendering.
3
+ * @module core/kernel/view
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ /**
10
+ * @param {string} template
11
+ * @param {Record<string, any>} data
12
+ */
13
+ function renderTemplate(template, data) {
14
+ return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, keyPath) => {
15
+ const parts = keyPath.split('.');
16
+ let v = data;
17
+ for (const p of parts) {
18
+ v = v == null ? undefined : v[p];
19
+ }
20
+ if (v == null || v === false) return '';
21
+ return String(v);
22
+ });
23
+ }
24
+
25
+ /**
26
+ * Parse "namespace::name" → { namespace, name }
27
+ * @param {string} qualified
28
+ */
29
+ function parseQualified(qualified) {
30
+ const sep = '::';
31
+ const i = qualified.indexOf(sep);
32
+ if (i === -1) {
33
+ throw new Error(`View name must be namespaced ("ns::page"), got: ${qualified}`);
34
+ }
35
+ return {
36
+ namespace: qualified.slice(0, i),
37
+ name: qualified.slice(i + sep.length),
38
+ };
39
+ }
40
+
41
+ /**
42
+ * @param {{
43
+ * appViews?: string,
44
+ * themeViews?: string,
45
+ * }} paths
46
+ */
47
+ function createViewEngine(paths = {}) {
48
+ const appPluginsRoot = paths.appViews || '';
49
+ const themeRoot = paths.themeViews || '';
50
+
51
+ /** @type {Map<string, { pluginName: string, layouts: Record<string,string>, pages: Record<string,string>, partials: Record<string,string> }>} */
52
+ const registry = new Map();
53
+
54
+ /**
55
+ * @param {string} pluginName
56
+ * @param {{ namespace: string, layouts?: Record<string,string>, pages?: Record<string,string>, partials?: Record<string,string> }} bundle
57
+ */
58
+ function registerPluginViews(pluginName, bundle) {
59
+ const { namespace } = bundle;
60
+ registry.set(namespace, {
61
+ pluginName,
62
+ layouts: bundle.layouts || {},
63
+ pages: bundle.pages || {},
64
+ partials: bundle.partials || {},
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Resolve template body: app override → theme → plugin in-memory bundle.
70
+ * @param {'page'|'layout'|'partial'} kind
71
+ * @param {string} namespace
72
+ * @param {string} id
73
+ * @returns {string|null}
74
+ */
75
+ function resolveTemplate(kind, namespace, id) {
76
+ const entry = registry.get(namespace);
77
+ const pluginSlug = entry ? entry.pluginName : namespace;
78
+
79
+ const sub =
80
+ kind === 'layout'
81
+ ? 'layouts'
82
+ : kind === 'partial'
83
+ ? 'partials'
84
+ : 'pages';
85
+ const file = `${id}.html`;
86
+
87
+ if (appPluginsRoot) {
88
+ const appPath = path.join(appPluginsRoot, 'plugins', pluginSlug, sub, file);
89
+ if (fs.existsSync(appPath)) {
90
+ return fs.readFileSync(appPath, 'utf8');
91
+ }
92
+ }
93
+
94
+ if (themeRoot) {
95
+ const themePath = path.join(themeRoot, pluginSlug, sub, file);
96
+ if (fs.existsSync(themePath)) {
97
+ return fs.readFileSync(themePath, 'utf8');
98
+ }
99
+ }
100
+
101
+ if (entry) {
102
+ const dict =
103
+ kind === 'layout'
104
+ ? entry.layouts
105
+ : kind === 'partial'
106
+ ? entry.partials
107
+ : entry.pages;
108
+ const inline = dict[id];
109
+ if (inline != null) return inline;
110
+ }
111
+
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * @param {string} qualified
117
+ * @param {Record<string, any>} data
118
+ * @param {{ layout?: string }} [options]
119
+ */
120
+ function renderView(qualified, data, options = {}) {
121
+ const { namespace, name } = parseQualified(qualified);
122
+ let body = resolveTemplate('page', namespace, name);
123
+ if (body == null) {
124
+ throw new Error(`View not found: ${qualified}`);
125
+ }
126
+ body = renderTemplate(body, data);
127
+
128
+ if (options.layout) {
129
+ const lq = parseQualified(options.layout);
130
+ const layoutBody = resolveTemplate('layout', lq.namespace, lq.name);
131
+ if (layoutBody == null) {
132
+ throw new Error(`Layout not found: ${options.layout}`);
133
+ }
134
+ const merged = { ...data, content: body };
135
+ return renderTemplate(layoutBody, merged);
136
+ }
137
+
138
+ return body;
139
+ }
140
+
141
+ /**
142
+ * @param {string} qualified
143
+ * @param {Record<string, any>} data
144
+ */
145
+ function renderPartial(qualified, data) {
146
+ const { namespace, name } = parseQualified(qualified);
147
+ const raw = resolveTemplate('partial', namespace, name);
148
+ if (raw == null) {
149
+ throw new Error(`Partial not found: ${qualified}`);
150
+ }
151
+ return renderTemplate(raw, data);
152
+ }
153
+
154
+ return {
155
+ registerPluginViews,
156
+ renderView,
157
+ renderPartial,
158
+ renderTemplate,
159
+ parseQualified,
160
+ };
161
+ }
162
+
163
+ module.exports = {
164
+ createViewEngine,
165
+ renderTemplate,
166
+ parseQualified,
167
+ };
@@ -8,6 +8,8 @@ const { zodToJsonSchema } = require('zod-to-json-schema');
8
8
  const { compileSchema } = require('../compileSchema');
9
9
  const { generateOrmOpenApiSchemas } = require('./orm-components');
10
10
 
11
+ const OPENAPI_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options']);
12
+
11
13
  /**
12
14
  * Express route pattern → OpenAPI path (e.g. /users/:id → /users/{id})
13
15
  * @param {string} expressPath
@@ -115,7 +117,7 @@ function buildOpenApiDocument(opts) {
115
117
  throw new Error('buildOpenApiDocument: pagesDir is required');
116
118
  }
117
119
 
118
- const paths = {};
120
+ const paths = Object.create(null);
119
121
  const apiRoutes = routes.filter((r) => r.type === 'api');
120
122
  const absPages = path.resolve(pagesDir);
121
123
 
@@ -142,8 +144,12 @@ function buildOpenApiDocument(opts) {
142
144
  const openApiPath = expressPathToOpenApi(route.pattern);
143
145
  const method = route.method.toLowerCase();
144
146
 
147
+ if (!OPENAPI_METHODS.has(method)) {
148
+ continue;
149
+ }
150
+
145
151
  if (!paths[openApiPath]) {
146
- paths[openApiPath] = {};
152
+ paths[openApiPath] = Object.create(null);
147
153
  }
148
154
 
149
155
  paths[openApiPath][method] = buildOperation(route, compiled);
package/core/orm/model.js CHANGED
@@ -4,6 +4,7 @@
4
4
  * @module core/orm/model
5
5
  */
6
6
 
7
+ const { trimUrlPathSlashes } = require('../url-path-normalize');
7
8
  const { extractColumnsFromSchema } = require('./schema-helpers');
8
9
  const { ModelEvents, Hooks } = require('./events');
9
10
 
@@ -93,7 +94,8 @@ function defineModel(options) {
93
94
  },
94
95
  rest: {
95
96
  enabled: rest.enabled === true,
96
- path: typeof rest.path === 'string' && rest.path.length > 0 ? rest.path.replace(/^\/+|\/+$/g, '') : null,
97
+ path:
98
+ typeof rest.path === 'string' && rest.path.length > 0 ? trimUrlPathSlashes(rest.path) : null,
97
99
  allowInclude: Array.isArray(rest.allowInclude) ? rest.allowInclude.filter((x) => typeof x === 'string') : null,
98
100
  },
99
101
  hidden: Array.isArray(hidden) ? hidden : [],
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Linear-time URL path trimming (avoid polynomial regex on long slash runs).
3
+ */
4
+
5
+ const DEFAULT_MAX_CHARS = 4096;
6
+
7
+ /**
8
+ * Trim leading and optional trailing ASCII '/' from a logical URL path fragment.
9
+ * @param {unknown} raw
10
+ * @param {{ maxChars?: number }} [opts]
11
+ * @returns {string}
12
+ */
13
+ function trimUrlPathSlashes(raw, opts = {}) {
14
+ const maxChars = opts.maxChars ?? DEFAULT_MAX_CHARS;
15
+ let s = raw == null ? '' : String(raw);
16
+ if (s.length > maxChars) {
17
+ s = s.slice(0, maxChars);
18
+ }
19
+ let start = 0;
20
+ while (start < s.length && s.charCodeAt(start) === 47 /* / */) {
21
+ start++;
22
+ }
23
+ let end = s.length;
24
+ while (end > start && s.charCodeAt(end - 1) === 47) {
25
+ end--;
26
+ }
27
+ return s.slice(start, end);
28
+ }
29
+
30
+ module.exports = { trimUrlPathSlashes };
package/index.d.ts CHANGED
@@ -34,7 +34,10 @@ export interface CreateAppOptions {
34
34
  version?: string;
35
35
  manifestPath?: string;
36
36
  prefix?: string;
37
- };
37
+ }; /**
38
+ * Custom 404 / 500 / 503 handlers. File-based route errors are forwarded with `next(err)` and hit `serverError` / `timeout`.
39
+ * When `serverError` / `timeout` is a template path, it is not used for paths under `/api` (default JSON instead).
40
+ */
38
41
  errorPages?: {
39
42
  notFound?: string | ((req: Request, res: Response, ctx: ErrorPageContext) => unknown);
40
43
  serverError?:
@@ -120,6 +123,29 @@ export function detectLocale(
120
123
  defaultLocale: string
121
124
  ): string;
122
125
 
126
+ export function parseNjkFrontmatter(content: string): {
127
+ body: string;
128
+ fm: Record<string, unknown> | null;
129
+ hasDelimiter: boolean;
130
+ };
131
+
132
+ export function frontmatterToPatches(fm: unknown): {
133
+ metaPatch: Record<string, unknown>;
134
+ dataPatch: Record<string, unknown>;
135
+ };
136
+
137
+ export function loadNjkRouteTemplate(
138
+ absPath: string,
139
+ isDev: boolean
140
+ ): {
141
+ useStringRender: boolean;
142
+ templateBody: string | null;
143
+ metaPatch: Record<string, unknown>;
144
+ dataPatch: Record<string, unknown>;
145
+ };
146
+
147
+ export function clearNjkFrontmatterCaches(): void;
148
+
123
149
  // --- Helpers / assets ---
124
150
 
125
151
  export function createHelpers(context: Record<string, unknown>): Record<string, unknown>;
@@ -144,6 +170,8 @@ export interface RoutesReadyContext {
144
170
  app: Application;
145
171
  nunjucksEnv: unknown;
146
172
  options: CreateAppOptions;
173
+ /** Same object as `createApp({ middlewares })` — plugins may register named handlers before or after routes. */
174
+ middlewares: Record<string, WebspressoRegisteredMiddleware>;
147
175
  db: DatabaseInstance | null;
148
176
  routes: unknown;
149
177
  usePlugin(name: string): unknown;
@@ -157,6 +185,8 @@ export interface PluginRegisterContext {
157
185
  app: Application;
158
186
  nunjucksEnv: unknown;
159
187
  options: Record<string, unknown>;
188
+ /** Same object as `createApp({ middlewares })` for registering named route middleware. */
189
+ middlewares: Record<string, WebspressoRegisteredMiddleware>;
160
190
  db: DatabaseInstance | null;
161
191
  usePlugin(name: string): unknown;
162
192
  addHelper(name: string, fn: (...args: unknown[]) => unknown): void;
@@ -528,6 +558,43 @@ export function swaggerPlugin(options?: Record<string, unknown>): WebspressoPlug
528
558
 
529
559
  export function healthCheckPlugin(options?: Record<string, unknown>): WebspressoPlugin;
530
560
 
561
+ export interface RedirectRule {
562
+ from: string | RegExp;
563
+ to: string;
564
+ status?: number;
565
+ /** Use `'*'` to match any HTTP method; default follows `defaultMethods` on the plugin. */
566
+ methods?: string[] | '*';
567
+ }
568
+
569
+ export interface RedirectPluginOptions {
570
+ rules?: RedirectRule[];
571
+ /** Default when a rule omits `status`. Must be 301, 302, 303, 307, or 308. Default 302. */
572
+ defaultStatus?: number;
573
+ /** Append request query string to `to` when `to` has no `?`. Default true. */
574
+ preserveQuery?: boolean;
575
+ /** Allow `to` starting with http(s): or //. Default false. */
576
+ allowExternal?: boolean;
577
+ /** Normalize path before matching: strip/add trailing slash. Default false (still allows /old vs /old/ match for string rules). */
578
+ trailingSlash?: 'strip' | 'add' | false;
579
+ /** Methods matched when a rule has no `methods`. Default GET and HEAD. */
580
+ defaultMethods?: string[];
581
+ }
582
+
583
+ export function redirectPlugin(options?: RedirectPluginOptions): WebspressoPlugin;
584
+
585
+ /** Options for `rateLimitPlugin`: `express-rate-limit` fields plus plugin-only keys. */
586
+ export interface RateLimitPluginOptions {
587
+ /** Mount a global limiter on the Express app (named route middleware is always registered). */
588
+ global?: boolean;
589
+ /** Shallow merge applied only to the global limiter. */
590
+ globalOverrides?: Record<string, unknown>;
591
+ /** Extra path prefixes skipped by the global limiter (builtin skips dev routes, favicon, robots, health). */
592
+ globalSkipPaths?: string[];
593
+ [key: string]: unknown;
594
+ }
595
+
596
+ export function rateLimitPlugin(options?: RateLimitPluginOptions): WebspressoPlugin;
597
+
531
598
  export interface RestResourcePluginOptions {
532
599
  path?: string;
533
600
  middleware?: RequestHandler[];
@@ -569,3 +636,103 @@ export function createLocalFileProvider(options?: {
569
636
  destDir?: string;
570
637
  publicBasePath?: string;
571
638
  }): UploadStorageProvider;
639
+
640
+ // --- Application kernel (use `kernel.createApp`; not the SSR `createApp`) ---
641
+
642
+ export type KernelEventSource = 'orm' | 'auth' | 'route' | 'plugin' | 'system';
643
+
644
+ export interface KernelEventMeta {
645
+ requestId?: string;
646
+ userId?: string;
647
+ source: KernelEventSource;
648
+ createdAt: Date;
649
+ }
650
+
651
+ export interface KernelEventContext {
652
+ payload: unknown;
653
+ meta: KernelEventMeta;
654
+ }
655
+
656
+ export interface KernelEventBus {
657
+ dispatch(eventName: string, ctx: KernelEventContext): Promise<unknown>;
658
+ publish(eventName: string, ctx: KernelEventContext): Promise<void>;
659
+ on(eventName: string, handler: (ctx: KernelEventContext) => unknown): void;
660
+ off(eventName: string, handler: (ctx: KernelEventContext) => unknown): void;
661
+ buildContext(
662
+ payload: unknown,
663
+ meta: Partial<Omit<KernelEventMeta, 'createdAt'>> & Pick<KernelEventMeta, 'source'>
664
+ ): KernelEventContext;
665
+ }
666
+
667
+ export interface KernelViewEngine {
668
+ registerPluginViews(
669
+ pluginName: string,
670
+ bundle: {
671
+ namespace: string;
672
+ layouts?: Record<string, string>;
673
+ pages?: Record<string, string>;
674
+ partials?: Record<string, string>;
675
+ }
676
+ ): void;
677
+ renderView(
678
+ qualifiedName: string,
679
+ data: Record<string, unknown>,
680
+ options?: { layout?: string }
681
+ ): string;
682
+ renderPartial(qualifiedName: string, data: Record<string, unknown>): string;
683
+ }
684
+
685
+ export interface KernelPluginDescriptor {
686
+ name: string;
687
+ events?: (app: KernelAppShell) => void | Promise<void>;
688
+ views?: () => {
689
+ namespace: string;
690
+ layouts?: Record<string, string>;
691
+ pages?: Record<string, string>;
692
+ partials?: Record<string, string>;
693
+ };
694
+ }
695
+
696
+ export interface KernelFlowDefinition {
697
+ id?: string;
698
+ trigger: string;
699
+ when?: (ctx: KernelEventContext) => boolean;
700
+ actions?: Array<(ctx: KernelEventContext, app: KernelAppShell) => void | Promise<void>>;
701
+ }
702
+
703
+ export interface KernelAppShell {
704
+ events: KernelEventBus;
705
+ view: KernelViewEngine;
706
+ flows: Array<{ id?: string; trigger: string }>;
707
+ registerPlugin(plugin: KernelPluginDescriptor): void;
708
+ registerFlow(flow: KernelFlowDefinition): () => void;
709
+ paths: Record<string, string | undefined>;
710
+ options?: Record<string, unknown>;
711
+ }
712
+
713
+ export interface KernelBaseRepository {
714
+ events: KernelEventBus;
715
+ resource: string;
716
+ create(data: Record<string, unknown>): Promise<Record<string, unknown>>;
717
+ update(id: string, data: Partial<Record<string, unknown>>): Promise<Record<string, unknown>>;
718
+ delete(id: string): Promise<void>;
719
+ }
720
+
721
+ export interface KernelBaseRepositoryConstructor {
722
+ new (events: KernelEventBus, options: { resource: string; source?: KernelEventSource }): KernelBaseRepository;
723
+ }
724
+
725
+ export interface WebspressoKernel {
726
+ createApp(options?: { paths?: { appViews?: string; themeViews?: string } }): KernelAppShell;
727
+ definePlugin(plugin: KernelPluginDescriptor): KernelPluginDescriptor;
728
+ defineFlow(flow: KernelFlowDefinition): KernelFlowDefinition;
729
+ BaseRepository: KernelBaseRepositoryConstructor;
730
+ createEventBus(): KernelEventBus;
731
+ buildContext: KernelEventBus['buildContext'];
732
+ randomUUID(): string;
733
+ createViewEngine(paths?: { appViews?: string; themeViews?: string }): KernelViewEngine;
734
+ renderTemplate(template: string, data: Record<string, unknown>): string;
735
+ parseQualified(qualified: string): { namespace: string; name: string };
736
+ }
737
+
738
+ export const kernel: WebspressoKernel;
package/index.js CHANGED
@@ -20,7 +20,11 @@ const {
20
20
  scanDirectory,
21
21
  loadI18n,
22
22
  createTranslator,
23
- detectLocale
23
+ detectLocale,
24
+ parseNjkFrontmatter,
25
+ frontmatterToPatches,
26
+ loadNjkRouteTemplate,
27
+ clearNjkFrontmatterCaches,
24
28
  } = require('./src/file-router');
25
29
  const {
26
30
  createHelpers,
@@ -39,6 +43,9 @@ const {
39
43
  // ORM exports (lazy loaded)
40
44
  const orm = require('./core/orm');
41
45
 
46
+ /** Application kernel (event bus, plugin shell, view resolver, flows). Use `kernel.createApp`; not the framework SSR `createApp`. */
47
+ const kernel = require('./core/kernel');
48
+
42
49
  // Built-in plugins
43
50
  const {
44
51
  schemaExplorerPlugin,
@@ -53,6 +60,8 @@ const {
53
60
  uploadPlugin,
54
61
  createLocalFileProvider,
55
62
  dataExchangePlugin,
63
+ redirectPlugin,
64
+ rateLimitPlugin,
56
65
  } = require('./plugins');
57
66
 
58
67
  module.exports = {
@@ -76,6 +85,10 @@ module.exports = {
76
85
  loadI18n,
77
86
  createTranslator,
78
87
  detectLocale,
88
+ parseNjkFrontmatter,
89
+ frontmatterToPatches,
90
+ loadNjkRouteTemplate,
91
+ clearNjkFrontmatterCaches,
79
92
 
80
93
  // Template helpers
81
94
  createHelpers,
@@ -94,7 +107,10 @@ module.exports = {
94
107
 
95
108
  // ORM
96
109
  ...orm,
97
-
110
+
111
+ /** Event bus / plugin shell / view resolver / flows (`kernel.createApp` is distinct from SSR `createApp`) */
112
+ kernel,
113
+
98
114
  // Direct zdb export (for convenience)
99
115
  zdb: orm.zdb,
100
116
 
@@ -111,4 +127,6 @@ module.exports = {
111
127
  uploadPlugin,
112
128
  createLocalFileProvider,
113
129
  dataExchangePlugin,
130
+ redirectPlugin,
131
+ rateLimitPlugin,
114
132
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.74",
3
+ "version": "0.0.75",
4
4
  "description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -17,6 +17,9 @@
17
17
  "test:e2e:headed": "playwright test --headed",
18
18
  "bench": "vitest bench --run",
19
19
  "bench:compare": "vitest bench --run --compare benchmark-results.json",
20
+ "bench:baseline": "vitest bench --run --outputJson benchmarks/ci-baseline.json",
21
+ "bench:ci": "vitest bench --run --outputJson benchmarks/current-benchmark.json && node scripts/check-bench-regression.mjs benchmarks/ci-baseline.json benchmarks/current-benchmark.json",
22
+ "bench:ci:local": "npm run bench:baseline && npm run bench:ci",
20
23
  "check:types": "tsc --project tests/ts-smoke/tsconfig.json",
21
24
  "rebuild:native": "npm rebuild better-sqlite3 bcrypt sharp",
22
25
  "release": "release-it"
@@ -66,8 +69,10 @@
66
69
  "knex": "^3.1.0",
67
70
  "multer": "^2.1.1",
68
71
  "nunjucks": "^3.2.4",
72
+ "sanitize-html": "^2.17.3",
69
73
  "sharp": "^0.33.5",
70
74
  "swup": "^4.8.3",
75
+ "yaml": "^2.8.3",
71
76
  "zod": "^3.23.0",
72
77
  "zod-to-json-schema": "^3.25.2"
73
78
  },
@@ -75,10 +80,14 @@
75
80
  "@faker-js/faker": ">=8.0.0",
76
81
  "better-sqlite3": ">=9.0.0",
77
82
  "dotenv": ">=16.0.0",
83
+ "express-rate-limit": ">=8.0.0",
78
84
  "mysql2": ">=3.0.0",
79
85
  "pg": ">=8.0.0"
80
86
  },
81
87
  "peerDependenciesMeta": {
88
+ "express-rate-limit": {
89
+ "optional": true
90
+ },
82
91
  "dotenv": {
83
92
  "optional": true
84
93
  },
@@ -104,6 +113,7 @@
104
113
  "better-sqlite3": "^11.10.0",
105
114
  "chokidar": "^3.5.3",
106
115
  "dotenv": "^16.3.1",
116
+ "express-rate-limit": "^8.4.1",
107
117
  "release-it": "^19.0.0",
108
118
  "supertest": "^6.3.4",
109
119
  "typescript": "~5.6.3",