webspresso 0.0.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/README.md +373 -0
- package/bin/webspresso.js +790 -0
- package/index.js +34 -0
- package/package.json +59 -0
- package/src/file-router.js +520 -0
- package/src/helpers.js +275 -0
- package/src/server.js +180 -0
package/src/helpers.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso Template Helpers
|
|
3
|
+
* Laravel-ish utilities for Nunjucks templates
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const querystring = require('querystring');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create the fsy helper object bound to the current request context
|
|
10
|
+
* @param {Object} ctx - Request context { req, res, baseUrl, locale }
|
|
11
|
+
* @returns {Object} fsy helper object
|
|
12
|
+
*/
|
|
13
|
+
function createHelpers(ctx) {
|
|
14
|
+
const { req, res, baseUrl = process.env.BASE_URL || 'http://localhost:3000' } = ctx;
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
/**
|
|
18
|
+
* Build a URL path with optional query parameters
|
|
19
|
+
* @param {string} path - URL path
|
|
20
|
+
* @param {Object} query - Query parameters
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
url(path, query = null) {
|
|
24
|
+
let url = path.startsWith('/') ? path : `/${path}`;
|
|
25
|
+
if (query && Object.keys(query).length > 0) {
|
|
26
|
+
url += '?' + querystring.stringify(query);
|
|
27
|
+
}
|
|
28
|
+
return url;
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build a route path by replacing pattern params
|
|
33
|
+
* @param {string} pattern - Route pattern like /tools/:slug
|
|
34
|
+
* @param {Object} params - Parameters to replace
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
route(pattern, params = {}) {
|
|
38
|
+
let result = pattern;
|
|
39
|
+
for (const [key, value] of Object.entries(params)) {
|
|
40
|
+
result = result.replace(`:${key}`, encodeURIComponent(value));
|
|
41
|
+
result = result.replace(`[${key}]`, encodeURIComponent(value));
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build a full URL including the base URL
|
|
48
|
+
* @param {string} path - URL path
|
|
49
|
+
* @param {Object} query - Query parameters
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
fullUrl(path, query = null) {
|
|
53
|
+
const base = baseUrl.replace(/\/$/, '');
|
|
54
|
+
return base + this.url(path, query);
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get a query parameter from the request
|
|
59
|
+
* @param {string} name - Parameter name
|
|
60
|
+
* @param {*} def - Default value
|
|
61
|
+
* @returns {*}
|
|
62
|
+
*/
|
|
63
|
+
q(name, def = null) {
|
|
64
|
+
return req.query[name] !== undefined ? req.query[name] : def;
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get a route parameter from the request
|
|
69
|
+
* @param {string} name - Parameter name
|
|
70
|
+
* @param {*} def - Default value
|
|
71
|
+
* @returns {*}
|
|
72
|
+
*/
|
|
73
|
+
param(name, def = null) {
|
|
74
|
+
return req.params[name] !== undefined ? req.params[name] : def;
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get a header from the request
|
|
79
|
+
* @param {string} name - Header name
|
|
80
|
+
* @param {*} def - Default value
|
|
81
|
+
* @returns {*}
|
|
82
|
+
*/
|
|
83
|
+
hdr(name, def = null) {
|
|
84
|
+
return req.get(name) || def;
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Check if running in development mode
|
|
89
|
+
* @returns {boolean}
|
|
90
|
+
*/
|
|
91
|
+
isDev() {
|
|
92
|
+
return process.env.NODE_ENV !== 'production';
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Check if running in production mode
|
|
97
|
+
* @returns {boolean}
|
|
98
|
+
*/
|
|
99
|
+
isProd() {
|
|
100
|
+
return process.env.NODE_ENV === 'production';
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Convert a string to URL-friendly slug
|
|
105
|
+
* @param {string} s - Input string
|
|
106
|
+
* @returns {string}
|
|
107
|
+
*/
|
|
108
|
+
slugify(s) {
|
|
109
|
+
if (!s) return '';
|
|
110
|
+
return s
|
|
111
|
+
.toString()
|
|
112
|
+
.toLowerCase()
|
|
113
|
+
.trim()
|
|
114
|
+
.replace(/[\s_]+/g, '-')
|
|
115
|
+
.replace(/[^\w\-]+/g, '')
|
|
116
|
+
.replace(/\-\-+/g, '-')
|
|
117
|
+
.replace(/^-+/, '')
|
|
118
|
+
.replace(/-+$/, '');
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Truncate a string to n characters
|
|
123
|
+
* @param {string} s - Input string
|
|
124
|
+
* @param {number} n - Max length
|
|
125
|
+
* @param {string} suffix - Suffix to append if truncated
|
|
126
|
+
* @returns {string}
|
|
127
|
+
*/
|
|
128
|
+
truncate(s, n, suffix = '...') {
|
|
129
|
+
if (!s) return '';
|
|
130
|
+
if (s.length <= n) return s;
|
|
131
|
+
return s.substring(0, n - suffix.length) + suffix;
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Format bytes to human-readable string
|
|
136
|
+
* @param {number} bytes - Number of bytes
|
|
137
|
+
* @returns {string}
|
|
138
|
+
*/
|
|
139
|
+
prettyBytes(bytes) {
|
|
140
|
+
if (bytes === 0) return '0 B';
|
|
141
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
|
142
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
143
|
+
const value = bytes / Math.pow(1024, i);
|
|
144
|
+
return `${value.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Format milliseconds to human-readable string
|
|
149
|
+
* @param {number} ms - Milliseconds
|
|
150
|
+
* @returns {string}
|
|
151
|
+
*/
|
|
152
|
+
prettyMs(ms) {
|
|
153
|
+
if (ms < 1000) return `${ms}ms`;
|
|
154
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
155
|
+
if (ms < 3600000) return `${(ms / 60000).toFixed(1)}m`;
|
|
156
|
+
return `${(ms / 3600000).toFixed(1)}h`;
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Get the canonical URL for the current request
|
|
161
|
+
* @returns {string}
|
|
162
|
+
*/
|
|
163
|
+
canonical() {
|
|
164
|
+
const base = baseUrl.replace(/\/$/, '');
|
|
165
|
+
return base + req.originalUrl.split('?')[0];
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Generate a JSON-LD script tag (returns safe HTML)
|
|
170
|
+
* @param {Object} obj - JSON-LD object
|
|
171
|
+
* @returns {string}
|
|
172
|
+
*/
|
|
173
|
+
jsonld(obj) {
|
|
174
|
+
const json = JSON.stringify(obj, null, 0)
|
|
175
|
+
.replace(/</g, '\\u003c')
|
|
176
|
+
.replace(/>/g, '\\u003e')
|
|
177
|
+
.replace(/&/g, '\\u0026');
|
|
178
|
+
return `<script type="application/ld+json">${json}</script>`;
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get the path to a static asset
|
|
183
|
+
* @param {string} path - Asset path
|
|
184
|
+
* @returns {string}
|
|
185
|
+
*/
|
|
186
|
+
asset(path) {
|
|
187
|
+
const assetPath = path.startsWith('/') ? path : `/${path}`;
|
|
188
|
+
return assetPath;
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Get the current locale
|
|
193
|
+
* @returns {string}
|
|
194
|
+
*/
|
|
195
|
+
locale() {
|
|
196
|
+
return ctx.locale || process.env.DEFAULT_LOCALE || 'en';
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Get the current path
|
|
201
|
+
* @returns {string}
|
|
202
|
+
*/
|
|
203
|
+
path() {
|
|
204
|
+
return req.path;
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Check if the current path matches a pattern
|
|
209
|
+
* @param {string} pattern - Path pattern (supports * wildcard)
|
|
210
|
+
* @returns {boolean}
|
|
211
|
+
*/
|
|
212
|
+
isPath(pattern) {
|
|
213
|
+
const currentPath = req.path;
|
|
214
|
+
if (pattern === currentPath) return true;
|
|
215
|
+
if (pattern.includes('*')) {
|
|
216
|
+
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
|
|
217
|
+
return regex.test(currentPath);
|
|
218
|
+
}
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Pure utility functions (can be used without request context)
|
|
226
|
+
*/
|
|
227
|
+
const utils = {
|
|
228
|
+
slugify(s) {
|
|
229
|
+
if (!s) return '';
|
|
230
|
+
return s
|
|
231
|
+
.toString()
|
|
232
|
+
.toLowerCase()
|
|
233
|
+
.trim()
|
|
234
|
+
.replace(/[\s_]+/g, '-')
|
|
235
|
+
.replace(/[^\w\-]+/g, '')
|
|
236
|
+
.replace(/\-\-+/g, '-')
|
|
237
|
+
.replace(/^-+/, '')
|
|
238
|
+
.replace(/-+$/, '');
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
truncate(s, n, suffix = '...') {
|
|
242
|
+
if (!s) return '';
|
|
243
|
+
if (s.length <= n) return s;
|
|
244
|
+
return s.substring(0, n - suffix.length) + suffix;
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
prettyBytes(bytes) {
|
|
248
|
+
if (bytes === 0) return '0 B';
|
|
249
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
|
250
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
251
|
+
const value = bytes / Math.pow(1024, i);
|
|
252
|
+
return `${value.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
prettyMs(ms) {
|
|
256
|
+
if (ms < 1000) return `${ms}ms`;
|
|
257
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
258
|
+
if (ms < 3600000) return `${(ms / 60000).toFixed(1)}m`;
|
|
259
|
+
return `${(ms / 3600000).toFixed(1)}h`;
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
isDev() {
|
|
263
|
+
return process.env.NODE_ENV !== 'production';
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
isProd() {
|
|
267
|
+
return process.env.NODE_ENV === 'production';
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
module.exports = {
|
|
272
|
+
createHelpers,
|
|
273
|
+
utils
|
|
274
|
+
};
|
|
275
|
+
|
package/src/server.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso Server
|
|
3
|
+
* Express + Nunjucks SSR server with file-based routing
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const express = require('express');
|
|
7
|
+
const nunjucks = require('nunjucks');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { mountPages } = require('./file-router');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create and configure the Express app
|
|
13
|
+
* @param {Object} options - Configuration options
|
|
14
|
+
* @param {string} options.pagesDir - Path to pages directory
|
|
15
|
+
* @param {string} options.viewsDir - Path to views directory
|
|
16
|
+
* @param {string} options.publicDir - Path to public/static directory
|
|
17
|
+
* @param {boolean} options.logging - Enable request logging (default: isDev)
|
|
18
|
+
* @returns {Object} { app, nunjucksEnv }
|
|
19
|
+
*/
|
|
20
|
+
function createApp(options = {}) {
|
|
21
|
+
const NODE_ENV = process.env.NODE_ENV || 'development';
|
|
22
|
+
const isDev = NODE_ENV !== 'production';
|
|
23
|
+
const isTest = NODE_ENV === 'test';
|
|
24
|
+
|
|
25
|
+
const {
|
|
26
|
+
pagesDir,
|
|
27
|
+
viewsDir,
|
|
28
|
+
publicDir,
|
|
29
|
+
logging = isDev && !isTest
|
|
30
|
+
} = options;
|
|
31
|
+
|
|
32
|
+
if (!pagesDir) {
|
|
33
|
+
throw new Error('pagesDir is required');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const app = express();
|
|
37
|
+
|
|
38
|
+
// Trust proxy (for correct req.ip, req.protocol behind reverse proxy)
|
|
39
|
+
app.set('trust proxy', 1);
|
|
40
|
+
|
|
41
|
+
// JSON body parser for API routes
|
|
42
|
+
app.use(express.json());
|
|
43
|
+
app.use(express.urlencoded({ extended: true }));
|
|
44
|
+
|
|
45
|
+
// Static files (if publicDir provided)
|
|
46
|
+
if (publicDir) {
|
|
47
|
+
app.use(express.static(publicDir, {
|
|
48
|
+
maxAge: isDev ? 0 : '1d',
|
|
49
|
+
etag: true
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Configure Nunjucks
|
|
54
|
+
const templateDirs = viewsDir ? [pagesDir, viewsDir] : [pagesDir];
|
|
55
|
+
|
|
56
|
+
const nunjucksEnv = nunjucks.configure(templateDirs, {
|
|
57
|
+
autoescape: true,
|
|
58
|
+
express: app,
|
|
59
|
+
watch: isDev && !isTest,
|
|
60
|
+
noCache: isDev || isTest
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Add custom Nunjucks filters
|
|
64
|
+
nunjucksEnv.addFilter('json', (obj) => {
|
|
65
|
+
return JSON.stringify(obj, null, 2);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
nunjucksEnv.addFilter('date', (date, format = 'short') => {
|
|
69
|
+
const d = new Date(date);
|
|
70
|
+
if (format === 'short') {
|
|
71
|
+
return d.toLocaleDateString();
|
|
72
|
+
}
|
|
73
|
+
if (format === 'long') {
|
|
74
|
+
return d.toLocaleDateString(undefined, {
|
|
75
|
+
weekday: 'long',
|
|
76
|
+
year: 'numeric',
|
|
77
|
+
month: 'long',
|
|
78
|
+
day: 'numeric'
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
if (format === 'iso') {
|
|
82
|
+
return d.toISOString();
|
|
83
|
+
}
|
|
84
|
+
return d.toString();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Request logging middleware
|
|
88
|
+
if (logging) {
|
|
89
|
+
app.use((req, res, next) => {
|
|
90
|
+
const start = Date.now();
|
|
91
|
+
res.on('finish', () => {
|
|
92
|
+
const duration = Date.now() - start;
|
|
93
|
+
console.log(`${req.method} ${req.path} ${res.statusCode} ${duration}ms`);
|
|
94
|
+
});
|
|
95
|
+
next();
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Mount file-based routes
|
|
100
|
+
if (!isTest) {
|
|
101
|
+
console.log('\nMounting routes:');
|
|
102
|
+
}
|
|
103
|
+
mountPages(app, {
|
|
104
|
+
pagesDir,
|
|
105
|
+
nunjucks: nunjucksEnv,
|
|
106
|
+
silent: isTest
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// 404 handler
|
|
110
|
+
app.use((req, res) => {
|
|
111
|
+
res.status(404);
|
|
112
|
+
if (req.accepts('html')) {
|
|
113
|
+
res.send(`
|
|
114
|
+
<!DOCTYPE html>
|
|
115
|
+
<html>
|
|
116
|
+
<head>
|
|
117
|
+
<title>404 - Not Found</title>
|
|
118
|
+
<style>
|
|
119
|
+
body { font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
|
120
|
+
.container { text-align: center; }
|
|
121
|
+
h1 { font-size: 4rem; margin: 0; color: #333; }
|
|
122
|
+
p { color: #666; margin: 1rem 0; }
|
|
123
|
+
a { color: #0066cc; text-decoration: none; }
|
|
124
|
+
a:hover { text-decoration: underline; }
|
|
125
|
+
</style>
|
|
126
|
+
</head>
|
|
127
|
+
<body>
|
|
128
|
+
<div class="container">
|
|
129
|
+
<h1>404</h1>
|
|
130
|
+
<p>Page not found</p>
|
|
131
|
+
<a href="/">← Back to Home</a>
|
|
132
|
+
</div>
|
|
133
|
+
</body>
|
|
134
|
+
</html>
|
|
135
|
+
`);
|
|
136
|
+
} else {
|
|
137
|
+
res.json({ error: 'Not Found' });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Error handler
|
|
142
|
+
app.use((err, req, res, next) => {
|
|
143
|
+
console.error('Server error:', err);
|
|
144
|
+
res.status(500);
|
|
145
|
+
if (req.accepts('html')) {
|
|
146
|
+
res.send(`
|
|
147
|
+
<!DOCTYPE html>
|
|
148
|
+
<html>
|
|
149
|
+
<head>
|
|
150
|
+
<title>500 - Server Error</title>
|
|
151
|
+
<style>
|
|
152
|
+
body { font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
|
153
|
+
.container { text-align: center; }
|
|
154
|
+
h1 { font-size: 4rem; margin: 0; color: #333; }
|
|
155
|
+
p { color: #666; margin: 1rem 0; }
|
|
156
|
+
pre { background: #fff; padding: 1rem; border-radius: 4px; text-align: left; overflow: auto; max-width: 600px; }
|
|
157
|
+
a { color: #0066cc; text-decoration: none; }
|
|
158
|
+
a:hover { text-decoration: underline; }
|
|
159
|
+
</style>
|
|
160
|
+
</head>
|
|
161
|
+
<body>
|
|
162
|
+
<div class="container">
|
|
163
|
+
<h1>500</h1>
|
|
164
|
+
<p>Internal Server Error</p>
|
|
165
|
+
${isDev ? `<pre>${err.stack || err.message}</pre>` : ''}
|
|
166
|
+
<a href="/">← Back to Home</a>
|
|
167
|
+
</div>
|
|
168
|
+
</body>
|
|
169
|
+
</html>
|
|
170
|
+
`);
|
|
171
|
+
} else {
|
|
172
|
+
res.json({ error: 'Internal Server Error' });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
return { app, nunjucksEnv };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Export for use as library
|
|
180
|
+
module.exports = { createApp };
|