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/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 };