webspresso 0.0.73 → 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.
- package/README.md +44 -4
- package/bin/commands/orm-map.js +139 -0
- package/bin/commands/skill.js +22 -8
- package/bin/commands/upgrade.js +146 -0
- package/bin/utils/orm-map-html.js +689 -0
- package/bin/utils/orm-map-load.js +85 -0
- package/bin/utils/orm-map-snapshot.js +179 -0
- package/bin/utils/resolve-webspresso-orm.js +23 -0
- package/bin/webspresso.js +4 -0
- package/core/auth/manager.js +14 -1
- package/core/kernel/app.js +96 -0
- package/core/kernel/base-repository.js +143 -0
- package/core/kernel/events.js +101 -0
- package/core/kernel/flow.js +22 -0
- package/core/kernel/index.js +17 -0
- package/core/kernel/plugin.js +23 -0
- package/core/kernel/plugins/sample-seo.js +26 -0
- package/core/kernel/run-demo.js +58 -0
- package/core/kernel/view.js +167 -0
- package/core/openapi/build-from-api-routes.js +8 -2
- package/core/orm/model.js +3 -1
- package/core/url-path-normalize.js +30 -0
- package/index.d.ts +168 -1
- package/index.js +20 -2
- package/package.json +11 -1
- package/plugins/admin-panel/api.js +43 -15
- package/plugins/admin-panel/app.js +109 -0
- package/plugins/admin-panel/client/README.md +39 -0
- package/plugins/admin-panel/client/load-parts.js +74 -0
- package/plugins/admin-panel/client/manifest.parts.json +12 -0
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +150 -0
- package/plugins/admin-panel/client/parts/02-filter-components.js +554 -0
- package/plugins/admin-panel/client/parts/03-pagination-intro.js +70 -0
- package/plugins/admin-panel/client/parts/04-field-renderers.js +287 -0
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +335 -0
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +125 -0
- package/plugins/admin-panel/client/parts/07-model-list.js +596 -0
- package/plugins/admin-panel/client/parts/08-record-list.js +536 -0
- package/plugins/admin-panel/client/parts/09-record-form.js +170 -0
- package/plugins/admin-panel/client/parts/10-export-registry.js +11 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +32 -0
- package/plugins/admin-panel/client/vite.config.example.mjs +22 -0
- package/plugins/admin-panel/components.js +4 -2640
- package/plugins/admin-panel/core/api-extensions.js +100 -10
- package/plugins/admin-panel/index.js +3 -0
- package/plugins/admin-panel/lib/is-rich-text-empty.js +23 -0
- package/plugins/admin-panel/lib/sanitize-rich-html.js +106 -0
- package/plugins/admin-panel/modules/dashboard.js +17 -13
- package/plugins/admin-panel/modules/user-management.js +118 -27
- package/plugins/data-exchange/export-xlsx.js +3 -0
- package/plugins/data-exchange/record-selection.js +21 -5
- package/plugins/index.js +4 -0
- package/plugins/rate-limit/index.js +178 -0
- package/plugins/redirect/index.js +204 -0
- package/plugins/rest-resources/index.js +2 -1
- package/plugins/site-analytics/admin-component.js +88 -78
- package/plugins/swagger.js +2 -1
- package/plugins/upload/local-file-provider.js +6 -2
- package/src/file-router.js +270 -53
- package/src/njk-frontmatter.js +156 -0
- package/src/plugin-manager.js +4 -2
- package/src/server.js +28 -9
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +276 -0
- package/templates/skills/webspresso-usage/REFERENCE-kernel.md +93 -0
- package/templates/skills/webspresso-usage/SKILL.md +29 -275
|
@@ -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:
|
|
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.
|
|
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",
|