millas 0.2.11 → 0.2.12-beta-1
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 +6 -5
- package/src/auth/Auth.js +13 -8
- package/src/auth/AuthController.js +45 -134
- package/src/auth/AuthMiddleware.js +12 -23
- package/src/auth/AuthUser.js +98 -0
- package/src/auth/RoleMiddleware.js +7 -17
- package/src/cli.js +1 -1
- package/src/commands/migrate.js +46 -31
- package/src/commands/serve.js +238 -38
- package/src/container/AppInitializer.js +158 -0
- package/src/container/Application.js +288 -183
- package/src/container/HttpServer.js +156 -0
- package/src/container/MillasApp.js +23 -280
- package/src/container/MillasConfig.js +163 -0
- package/src/controller/Controller.js +79 -300
- package/src/core/auth.js +9 -0
- package/src/core/db.js +8 -0
- package/src/core/foundation.js +67 -0
- package/src/core/http.js +11 -0
- package/src/core/mail.js +6 -0
- package/src/core/queue.js +7 -0
- package/src/core/validation.js +29 -0
- package/src/errors/ErrorRenderer.js +640 -0
- package/src/facades/Admin.js +49 -0
- package/src/facades/Auth.js +29 -0
- package/src/facades/Cache.js +28 -0
- package/src/facades/Database.js +43 -0
- package/src/facades/Events.js +25 -0
- package/src/facades/Facade.js +197 -0
- package/src/facades/Http.js +51 -0
- package/src/facades/Log.js +32 -0
- package/src/facades/Mail.js +35 -0
- package/src/facades/Queue.js +30 -0
- package/src/facades/Storage.js +25 -0
- package/src/facades/Url.js +53 -0
- package/src/http/HttpClient.js +673 -0
- package/src/http/MillasRequest.js +253 -0
- package/src/http/MillasResponse.js +196 -0
- package/src/http/RequestContext.js +176 -0
- package/src/http/ResponseDispatcher.js +51 -0
- package/src/http/UrlGenerator.js +375 -0
- package/src/http/WelcomePage.js +273 -0
- package/src/http/adapters/ExpressAdapter.js +315 -0
- package/src/http/adapters/HttpAdapter.js +168 -0
- package/src/http/adapters/index.js +9 -0
- package/src/http/helpers.js +164 -0
- package/src/http/index.js +13 -0
- package/src/index.js +5 -91
- package/src/logger/formatters/PrettyFormatter.js +15 -5
- package/src/logger/internal.js +76 -0
- package/src/logger/patchConsole.js +145 -0
- package/src/middleware/CorsMiddleware.js +22 -30
- package/src/middleware/LogMiddleware.js +27 -59
- package/src/middleware/Middleware.js +24 -15
- package/src/middleware/MiddlewarePipeline.js +30 -67
- package/src/middleware/MiddlewareRegistry.js +106 -0
- package/src/middleware/ThrottleMiddleware.js +22 -26
- package/src/orm/fields/index.js +124 -56
- package/src/orm/migration/ModelInspector.js +339 -336
- package/src/orm/model/Model.js +96 -6
- package/src/orm/query/QueryBuilder.js +141 -3
- package/src/providers/AuthServiceProvider.js +9 -5
- package/src/providers/CacheStorageServiceProvider.js +3 -1
- package/src/providers/EventServiceProvider.js +2 -1
- package/src/providers/LogServiceProvider.js +88 -17
- package/src/providers/MailServiceProvider.js +3 -2
- package/src/providers/ProviderRegistry.js +14 -1
- package/src/providers/QueueServiceProvider.js +3 -2
- package/src/providers/ServiceProvider.js +40 -8
- package/src/router/Router.js +121 -222
- package/src/scaffold/maker.js +24 -59
- package/src/scaffold/templates.js +21 -19
- package/src/validation/BaseValidator.js +193 -0
- package/src/validation/Validator.js +680 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* UrlGenerator
|
|
5
|
+
*
|
|
6
|
+
* Laravel-like URL generation service.
|
|
7
|
+
* Registered in the container as 'url' by the framework after boot.
|
|
8
|
+
*
|
|
9
|
+
* Handles:
|
|
10
|
+
* - Absolute and relative URL generation
|
|
11
|
+
* - Named route URLs with parameter substitution
|
|
12
|
+
* - Asset URLs
|
|
13
|
+
* - Secure (HTTPS) URL forcing
|
|
14
|
+
* - Query string appending
|
|
15
|
+
* - Current / previous URL tracking (set by middleware)
|
|
16
|
+
* - Signed URLs with expiry
|
|
17
|
+
*/
|
|
18
|
+
class UrlGenerator {
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} options
|
|
21
|
+
* @param {string} options.baseUrl — e.g. 'http://localhost:3000'
|
|
22
|
+
* @param {RouteRegistry} options.routeRegistry — registered named routes
|
|
23
|
+
*/
|
|
24
|
+
constructor(options = {}) {
|
|
25
|
+
this._baseUrl = (options.baseUrl || '').replace(/\/$/, '');
|
|
26
|
+
this._routeRegistry = options.routeRegistry || null;
|
|
27
|
+
this._forcedScheme = null; // 'https' when forceHttps() is called
|
|
28
|
+
this._assetUrl = null; // separate CDN / asset origin
|
|
29
|
+
this._appKey = options.appKey || process.env.APP_KEY || '';
|
|
30
|
+
|
|
31
|
+
// Set by RequestUrlMiddleware on each request
|
|
32
|
+
this._currentUrl = null;
|
|
33
|
+
this._previousUrl = null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── Base URL ───────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The configured base URL of the application.
|
|
40
|
+
* URL.base() → 'https://myapp.com'
|
|
41
|
+
*/
|
|
42
|
+
base() {
|
|
43
|
+
return this._resolveBase();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── URL generation ─────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Generate an absolute URL for a path.
|
|
50
|
+
*
|
|
51
|
+
* URL.to('/users') → 'https://myapp.com/users'
|
|
52
|
+
* URL.to('/users', { id: 1 })→ 'https://myapp.com/users?id=1'
|
|
53
|
+
* URL.to('https://other.com')→ 'https://other.com' (already absolute)
|
|
54
|
+
*/
|
|
55
|
+
to(path, query = {}) {
|
|
56
|
+
if (this._isAbsolute(path)) return this._appendQuery(path, query);
|
|
57
|
+
const base = this._resolveBase();
|
|
58
|
+
const url = base + '/' + path.replace(/^\//, '');
|
|
59
|
+
return this._appendQuery(url, query);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Generate a secure (HTTPS) URL for a path.
|
|
64
|
+
*
|
|
65
|
+
* URL.secure('/login') → 'https://myapp.com/login'
|
|
66
|
+
*/
|
|
67
|
+
secure(path, query = {}) {
|
|
68
|
+
const url = this.to(path, query);
|
|
69
|
+
return url.replace(/^http:\/\//, 'https://');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Generate a relative URL (path only, no origin).
|
|
74
|
+
*
|
|
75
|
+
* URL.relative('/users') → '/users'
|
|
76
|
+
* URL.relative('/users', { page: 2 }) → '/users?page=2'
|
|
77
|
+
*/
|
|
78
|
+
relative(path, query = {}) {
|
|
79
|
+
const normalized = '/' + path.replace(/^\//, '');
|
|
80
|
+
return this._appendQuery(normalized, query);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Named routes ───────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Generate a URL for a named route, substituting parameters.
|
|
87
|
+
*
|
|
88
|
+
* // Route: Route.get('/users/:id/posts/:postId', ...).name('users.posts.show')
|
|
89
|
+
* URL.route('users.posts.show', { id: 1, postId: 42 })
|
|
90
|
+
* → 'https://myapp.com/users/1/posts/42'
|
|
91
|
+
*
|
|
92
|
+
* // Extra params become query string
|
|
93
|
+
* URL.route('users.index', { page: 2, search: 'alice' })
|
|
94
|
+
* → 'https://myapp.com/users?page=2&search=alice'
|
|
95
|
+
*
|
|
96
|
+
* // Relative
|
|
97
|
+
* URL.route('users.show', { id: 5 }, { absolute: false })
|
|
98
|
+
* → '/users/5'
|
|
99
|
+
*/
|
|
100
|
+
route(name, params = {}, options = {}) {
|
|
101
|
+
if (!this._routeRegistry) {
|
|
102
|
+
throw new Error('[URL] Route registry not available. Make sure the app is booted.');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const entry = this._routeRegistry.findByName(name);
|
|
106
|
+
if (!entry) {
|
|
107
|
+
throw new Error(`[URL] No route named "${name}".`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const { path, query } = this._substituteParams(entry.path, params);
|
|
111
|
+
const absolute = options.absolute !== false;
|
|
112
|
+
|
|
113
|
+
if (!absolute) return this._appendQuery(path, query);
|
|
114
|
+
return this._appendQuery(this._resolveBase() + path, query);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Assets ─────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Generate an asset URL (uses asset origin if configured, else base URL).
|
|
121
|
+
*
|
|
122
|
+
* URL.asset('css/app.css') → 'https://myapp.com/css/app.css'
|
|
123
|
+
* URL.asset('images/logo.png') → 'https://cdn.myapp.com/images/logo.png'
|
|
124
|
+
*/
|
|
125
|
+
asset(path) {
|
|
126
|
+
const origin = this._assetUrl || this._resolveBase();
|
|
127
|
+
return origin.replace(/\/$/, '') + '/' + path.replace(/^\//, '');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Generate a secure asset URL.
|
|
132
|
+
*/
|
|
133
|
+
secureAsset(path) {
|
|
134
|
+
return this.asset(path).replace(/^http:\/\//, 'https://');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Current / previous ────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The full URL of the current request.
|
|
141
|
+
* Populated by the request context — null outside of a request.
|
|
142
|
+
*/
|
|
143
|
+
current() {
|
|
144
|
+
return this._currentUrl;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The full URL of the previous request (from Referer header).
|
|
149
|
+
* Returns fallback if unavailable.
|
|
150
|
+
*
|
|
151
|
+
* URL.previous() → 'https://myapp.com/dashboard'
|
|
152
|
+
* URL.previous('/') → '/' if no previous URL
|
|
153
|
+
*/
|
|
154
|
+
previous(fallback = '/') {
|
|
155
|
+
return this._previousUrl || this.to(fallback);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The path portion of the current URL (no origin).
|
|
160
|
+
*/
|
|
161
|
+
currentPath() {
|
|
162
|
+
if (!this._currentUrl) return null;
|
|
163
|
+
try {
|
|
164
|
+
// If it's a full URL, extract pathname; otherwise treat as path directly
|
|
165
|
+
if (this._isAbsolute(this._currentUrl)) {
|
|
166
|
+
return new globalThis.URL(this._currentUrl).pathname;
|
|
167
|
+
}
|
|
168
|
+
return this._currentUrl.split('?')[0];
|
|
169
|
+
} catch {
|
|
170
|
+
return this._currentUrl;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Signed URLs ────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Generate a signed URL that cannot be tampered with.
|
|
178
|
+
* Optionally expires after a given number of seconds.
|
|
179
|
+
*
|
|
180
|
+
* URL.signedRoute('password.reset', { token }, 3600)
|
|
181
|
+
* → 'https://myapp.com/password/reset?token=...&expires=...&signature=...'
|
|
182
|
+
*/
|
|
183
|
+
signedRoute(name, params = {}, expiresIn = null) {
|
|
184
|
+
const base = this.route(name, params);
|
|
185
|
+
return this._sign(base, expiresIn);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Generate a signed URL for an arbitrary path.
|
|
190
|
+
*
|
|
191
|
+
* URL.signedUrl('/download/file.pdf', 300)
|
|
192
|
+
*/
|
|
193
|
+
signedUrl(path, expiresIn = null) {
|
|
194
|
+
const url = this.to(path);
|
|
195
|
+
return this._sign(url, expiresIn);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Verify that a signed URL is valid and has not expired.
|
|
200
|
+
* Returns true if valid, false otherwise.
|
|
201
|
+
*
|
|
202
|
+
* URL.hasValidSignature(req)
|
|
203
|
+
*/
|
|
204
|
+
hasValidSignature(req) {
|
|
205
|
+
const rawUrl = this.to(req.path || req.url || '', req.query || {});
|
|
206
|
+
return this._verifySignature(rawUrl);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Scheme control ────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Force all generated URLs to use HTTPS.
|
|
213
|
+
* URL.forceHttps()
|
|
214
|
+
*/
|
|
215
|
+
forceHttps(force = true) {
|
|
216
|
+
this._forcedScheme = force ? 'https' : null;
|
|
217
|
+
return this;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Force all generated URLs to use a specific scheme.
|
|
222
|
+
* URL.forceScheme('https')
|
|
223
|
+
*/
|
|
224
|
+
forceScheme(scheme) {
|
|
225
|
+
this._forcedScheme = scheme || null;
|
|
226
|
+
return this;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Set a separate origin for asset URLs (CDN, S3, etc.).
|
|
231
|
+
* URL.useAssetOrigin('https://cdn.myapp.com')
|
|
232
|
+
*/
|
|
233
|
+
useAssetOrigin(origin) {
|
|
234
|
+
this._assetUrl = origin ? origin.replace(/\/$/, '') : null;
|
|
235
|
+
return this;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Introspection ──────────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Check whether a string is a valid absolute URL.
|
|
242
|
+
* URL.isValid('https://example.com') → true
|
|
243
|
+
* URL.isValid('/relative/path') → false
|
|
244
|
+
*/
|
|
245
|
+
isValid(url) {
|
|
246
|
+
try { new URL(url); return true; } catch { return false; }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Check whether the current request URL matches a pattern.
|
|
251
|
+
* Supports * wildcards.
|
|
252
|
+
*
|
|
253
|
+
* URL.is('/users/*') → true on /users/1, /users/edit
|
|
254
|
+
* URL.is('/users') → true only on /users
|
|
255
|
+
*/
|
|
256
|
+
is(...patterns) {
|
|
257
|
+
const path = this.currentPath() || '';
|
|
258
|
+
return patterns.some(pattern => {
|
|
259
|
+
const regex = new RegExp(
|
|
260
|
+
'^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$'
|
|
261
|
+
);
|
|
262
|
+
return regex.test(path);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── Internal: called by framework ─────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
/** Set by AppInitialiser / request middleware. @internal */
|
|
269
|
+
_setCurrentUrl(url) { this._currentUrl = url; }
|
|
270
|
+
_setPreviousUrl(url) { this._previousUrl = url; }
|
|
271
|
+
|
|
272
|
+
// ── Private ────────────────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
_resolveBase() {
|
|
275
|
+
let base = process.env.APP_URL || this._baseUrl;
|
|
276
|
+
|
|
277
|
+
if (!base) {
|
|
278
|
+
const host = process.env.MILLAS_HOST || 'localhost';
|
|
279
|
+
const port = process.env.APP_PORT || process.env.MILLAS_PORT || '3000';
|
|
280
|
+
const scheme = this._forcedScheme || 'http';
|
|
281
|
+
base = `${scheme}://${host}:${port}`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (this._forcedScheme) {
|
|
285
|
+
base = base.replace(/^https?:\/\//, `${this._forcedScheme}://`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return base.replace(/\/$/, '');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
_isAbsolute(url) {
|
|
292
|
+
return /^https?:\/\//.test(url);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
_appendQuery(url, query = {}) {
|
|
296
|
+
const pairs = Object.entries(query).filter(([, v]) => v !== undefined && v !== null);
|
|
297
|
+
if (!pairs.length) return url;
|
|
298
|
+
const qs = new URLSearchParams(pairs).toString();
|
|
299
|
+
return url + (url.includes('?') ? '&' : '?') + qs;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Replace :param and {param} placeholders in a route path.
|
|
304
|
+
* Returns { path, query } where query holds leftover params.
|
|
305
|
+
*/
|
|
306
|
+
_substituteParams(routePath, params) {
|
|
307
|
+
const remaining = { ...params };
|
|
308
|
+
let path = routePath;
|
|
309
|
+
|
|
310
|
+
// Replace :param and {param} style placeholders
|
|
311
|
+
path = path.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)\??|\{([a-zA-Z_][a-zA-Z0-9_]*)\??}/g, (_, p1, p2) => {
|
|
312
|
+
const key = p1 || p2;
|
|
313
|
+
if (key in remaining) {
|
|
314
|
+
const val = remaining[key];
|
|
315
|
+
delete remaining[key];
|
|
316
|
+
return encodeURIComponent(val);
|
|
317
|
+
}
|
|
318
|
+
return '';
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// Remove any trailing slash left by optional segments
|
|
322
|
+
path = path.replace(/\/+$/, '') || '/';
|
|
323
|
+
|
|
324
|
+
return { path, query: remaining };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
_sign(url, expiresIn) {
|
|
328
|
+
const crypto = require('crypto');
|
|
329
|
+
let target = url;
|
|
330
|
+
|
|
331
|
+
if (expiresIn) {
|
|
332
|
+
const expires = Math.floor(Date.now() / 1000) + expiresIn;
|
|
333
|
+
target = this._appendQuery(url, { expires });
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const signature = crypto
|
|
337
|
+
.createHmac('sha256', this._appKey)
|
|
338
|
+
.update(target)
|
|
339
|
+
.digest('hex')
|
|
340
|
+
.slice(0, 40);
|
|
341
|
+
|
|
342
|
+
return this._appendQuery(target, { signature });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
_verifySignature(url) {
|
|
346
|
+
const crypto = require('crypto');
|
|
347
|
+
try {
|
|
348
|
+
const parsed = new URL(url);
|
|
349
|
+
const signature = parsed.searchParams.get('signature');
|
|
350
|
+
const expires = parsed.searchParams.get('expires');
|
|
351
|
+
|
|
352
|
+
if (!signature) return false;
|
|
353
|
+
|
|
354
|
+
if (expires && Math.floor(Date.now() / 1000) > Number(expires)) {
|
|
355
|
+
return false; // expired
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Reconstruct the URL without the signature param to re-sign
|
|
359
|
+
parsed.searchParams.delete('signature');
|
|
360
|
+
const unsigned = parsed.toString();
|
|
361
|
+
|
|
362
|
+
const expected = require('crypto')
|
|
363
|
+
.createHmac('sha256', this._appKey)
|
|
364
|
+
.update(unsigned)
|
|
365
|
+
.digest('hex')
|
|
366
|
+
.slice(0, 40);
|
|
367
|
+
|
|
368
|
+
return signature === expected;
|
|
369
|
+
} catch {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
module.exports = UrlGenerator;
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WelcomePage
|
|
5
|
+
*
|
|
6
|
+
* Renders a branded welcome page when no GET / route is defined.
|
|
7
|
+
* Automatically removed once the developer registers their own / route.
|
|
8
|
+
*
|
|
9
|
+
* Only shown to browser requests (Accept: text/html).
|
|
10
|
+
* API clients always receive JSON.
|
|
11
|
+
*/
|
|
12
|
+
class WelcomePage {
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Returns an Express middleware that serves the welcome page for GET /
|
|
16
|
+
* only when no user-defined route has been registered for that path.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} version — package version shown in the page
|
|
19
|
+
*/
|
|
20
|
+
static handler(version = '') {
|
|
21
|
+
return function millaWelcome(req, res) {
|
|
22
|
+
const accept = req.headers?.accept || '';
|
|
23
|
+
const wantsHtml = accept.includes('text/html') && !accept.startsWith('application/json');
|
|
24
|
+
|
|
25
|
+
if (!wantsHtml) {
|
|
26
|
+
return res.json({
|
|
27
|
+
framework: 'Millas',
|
|
28
|
+
version,
|
|
29
|
+
message: 'Welcome to your Millas app. Define your routes in routes/api.js.',
|
|
30
|
+
docs: 'https://millas.dev/docs',
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
35
|
+
res.send(WelcomePage._render(version));
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
static _render(version) {
|
|
40
|
+
const ver = version ? `v${version}` : '';
|
|
41
|
+
return `<!DOCTYPE html>
|
|
42
|
+
<html lang="en">
|
|
43
|
+
<head>
|
|
44
|
+
<meta charset="UTF-8">
|
|
45
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
46
|
+
<title>Welcome — Millas</title>
|
|
47
|
+
<style>
|
|
48
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
49
|
+
|
|
50
|
+
:root {
|
|
51
|
+
--orange: #f97316;
|
|
52
|
+
--orange-dark: #ea580c;
|
|
53
|
+
--orange-dim: #fff7ed;
|
|
54
|
+
--orange-mid: #fed7aa;
|
|
55
|
+
--bg: #fafafa;
|
|
56
|
+
--surface: #ffffff;
|
|
57
|
+
--border: #e5e7eb;
|
|
58
|
+
--text: #111827;
|
|
59
|
+
--muted: #6b7280;
|
|
60
|
+
--code-bg: #fff7ed;
|
|
61
|
+
--code-fg: #c2410c;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@media (prefers-color-scheme: dark) {
|
|
65
|
+
:root {
|
|
66
|
+
--orange: #fb923c;
|
|
67
|
+
--orange-dark: #f97316;
|
|
68
|
+
--orange-dim: #1c0f00;
|
|
69
|
+
--orange-mid: #7c2d12;
|
|
70
|
+
--bg: #0c0c0c;
|
|
71
|
+
--surface: #141414;
|
|
72
|
+
--border: #262626;
|
|
73
|
+
--text: #f5f5f5;
|
|
74
|
+
--muted: #737373;
|
|
75
|
+
--code-bg: #1a0e00;
|
|
76
|
+
--code-fg: #fb923c;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
body {
|
|
81
|
+
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
|
82
|
+
background: var(--bg);
|
|
83
|
+
color: var(--text);
|
|
84
|
+
min-height: 100vh;
|
|
85
|
+
display: flex;
|
|
86
|
+
flex-direction: column;
|
|
87
|
+
align-items: center;
|
|
88
|
+
justify-content: center;
|
|
89
|
+
padding: 40px 20px;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* ── Logo mark ── */
|
|
93
|
+
.logo {
|
|
94
|
+
width: 64px;
|
|
95
|
+
height: 64px;
|
|
96
|
+
background: linear-gradient(135deg, var(--orange), var(--orange-dark));
|
|
97
|
+
border-radius: 18px;
|
|
98
|
+
display: flex;
|
|
99
|
+
align-items: center;
|
|
100
|
+
justify-content: center;
|
|
101
|
+
margin-bottom: 28px;
|
|
102
|
+
box-shadow: 0 8px 32px color-mix(in srgb, var(--orange) 35%, transparent);
|
|
103
|
+
}
|
|
104
|
+
.logo svg { width: 34px; height: 34px; }
|
|
105
|
+
|
|
106
|
+
/* ── Card ── */
|
|
107
|
+
.card {
|
|
108
|
+
padding: 48px 52px;
|
|
109
|
+
max-width: 560px;
|
|
110
|
+
width: 100%;
|
|
111
|
+
text-align: center;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
h1 {
|
|
115
|
+
font-size: 28px;
|
|
116
|
+
font-weight: 800;
|
|
117
|
+
letter-spacing: -.5px;
|
|
118
|
+
color: var(--text);
|
|
119
|
+
line-height: 1.2;
|
|
120
|
+
}
|
|
121
|
+
h1 span { color: var(--orange); }
|
|
122
|
+
|
|
123
|
+
.version {
|
|
124
|
+
display: inline-block;
|
|
125
|
+
margin-top: 10px;
|
|
126
|
+
background: var(--orange-dim);
|
|
127
|
+
color: var(--orange-dark);
|
|
128
|
+
border: 1px solid var(--orange-mid);
|
|
129
|
+
border-radius: 99px;
|
|
130
|
+
font-size: 12px;
|
|
131
|
+
font-weight: 600;
|
|
132
|
+
padding: 3px 12px;
|
|
133
|
+
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.tagline {
|
|
137
|
+
margin-top: 16px;
|
|
138
|
+
font-size: 15px;
|
|
139
|
+
color: var(--muted);
|
|
140
|
+
line-height: 1.6;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* ── Divider ── */
|
|
144
|
+
.divider {
|
|
145
|
+
height: 1px;
|
|
146
|
+
background: var(--border);
|
|
147
|
+
margin: 32px 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* ── Quickstart block ── */
|
|
151
|
+
.qs-label {
|
|
152
|
+
font-size: 11px;
|
|
153
|
+
font-weight: 700;
|
|
154
|
+
text-transform: uppercase;
|
|
155
|
+
letter-spacing: .8px;
|
|
156
|
+
color: var(--muted);
|
|
157
|
+
margin-bottom: 12px;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.code-block {
|
|
161
|
+
background: var(--code-bg);
|
|
162
|
+
border: 1px solid var(--orange-mid);
|
|
163
|
+
border-radius: 12px;
|
|
164
|
+
padding: 18px 20px;
|
|
165
|
+
text-align: left;
|
|
166
|
+
font-family: 'Cascadia Code', 'Fira Code', 'SF Mono', monospace;
|
|
167
|
+
font-size: 13px;
|
|
168
|
+
line-height: 1.9;
|
|
169
|
+
color: var(--code-fg);
|
|
170
|
+
}
|
|
171
|
+
.code-block .comment { color: var(--muted); font-style: italic; }
|
|
172
|
+
.code-block .kw { color: var(--orange-dark); font-weight: 700; }
|
|
173
|
+
.code-block .str { color: #16a34a; }
|
|
174
|
+
|
|
175
|
+
/* ── Links row ── */
|
|
176
|
+
.links {
|
|
177
|
+
margin-top: 28px;
|
|
178
|
+
display: flex;
|
|
179
|
+
justify-content: center;
|
|
180
|
+
gap: 12px;
|
|
181
|
+
flex-wrap: wrap;
|
|
182
|
+
}
|
|
183
|
+
.link {
|
|
184
|
+
display: inline-flex;
|
|
185
|
+
align-items: center;
|
|
186
|
+
gap: 6px;
|
|
187
|
+
padding: 8px 18px;
|
|
188
|
+
border-radius: 99px;
|
|
189
|
+
font-size: 13px;
|
|
190
|
+
font-weight: 600;
|
|
191
|
+
text-decoration: none;
|
|
192
|
+
transition: opacity .15s;
|
|
193
|
+
}
|
|
194
|
+
.link:hover { opacity: .8; }
|
|
195
|
+
.link-primary {
|
|
196
|
+
background: linear-gradient(135deg, var(--orange), var(--orange-dark));
|
|
197
|
+
color: #fff;
|
|
198
|
+
}
|
|
199
|
+
.link-ghost {
|
|
200
|
+
background: var(--surface);
|
|
201
|
+
color: var(--text);
|
|
202
|
+
border: 1px solid var(--border);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/* ── Footer note ── */
|
|
206
|
+
.note {
|
|
207
|
+
margin-top: 36px;
|
|
208
|
+
font-size: 12px;
|
|
209
|
+
color: var(--muted);
|
|
210
|
+
line-height: 1.6;
|
|
211
|
+
}
|
|
212
|
+
.note strong { color: var(--orange); font-family: monospace; font-weight: 600; }
|
|
213
|
+
</style>
|
|
214
|
+
</head>
|
|
215
|
+
<body>
|
|
216
|
+
|
|
217
|
+
<div class="logo">
|
|
218
|
+
<!-- M lettermark -->
|
|
219
|
+
<svg viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
220
|
+
<path d="M4 27V7l13 13L30 7v20" stroke="#fff" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
221
|
+
</svg>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<div class="card">
|
|
225
|
+
<h1>Welcome to <span>Millas</span></h1>
|
|
226
|
+
${ver ? `<div class="version">${_esc(ver)}</div>` : ''}
|
|
227
|
+
|
|
228
|
+
<p class="tagline">
|
|
229
|
+
Your app is running. Define your first route and this page will disappear.
|
|
230
|
+
</p>
|
|
231
|
+
|
|
232
|
+
<div class="divider"></div>
|
|
233
|
+
|
|
234
|
+
<div class="qs-label">Get started</div>
|
|
235
|
+
<div class="code-block">
|
|
236
|
+
<span class="comment">// routes/api.js</span>
|
|
237
|
+
<span class="kw">module</span>.exports = <span class="kw">function</span> (Route) {
|
|
238
|
+
Route.get(<span class="str">'/'</span>, () => ({
|
|
239
|
+
message: <span class="str">'Hello from Millas!'</span>
|
|
240
|
+
}));
|
|
241
|
+
};
|
|
242
|
+
</div>
|
|
243
|
+
|
|
244
|
+
<div class="links">
|
|
245
|
+
<a class="link link-primary" href="https://id-preview--ae4ed87b-a9d5-434d-8559-1e8c30972a28.lovable.app" target="_blank">
|
|
246
|
+
📖 Documentation
|
|
247
|
+
</a>
|
|
248
|
+
<a class="link link-ghost" href="https://github.com/millas-framework/millas" target="_blank">
|
|
249
|
+
GitHub
|
|
250
|
+
</a>
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
<p class="note">
|
|
255
|
+
This page is shown because no route is registered for <strong>GET /</strong>.<br>
|
|
256
|
+
It will never appear in production to end users.
|
|
257
|
+
</p>
|
|
258
|
+
|
|
259
|
+
</body>
|
|
260
|
+
</html>`;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function _esc(str) {
|
|
265
|
+
return String(str ?? '')
|
|
266
|
+
.replace(/&/g, '&')
|
|
267
|
+
.replace(/</g, '<')
|
|
268
|
+
.replace(/>/g, '>')
|
|
269
|
+
.replace(/"/g, '"')
|
|
270
|
+
.replace(/'/g, ''');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
module.exports = WelcomePage;
|