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.
Files changed (74) hide show
  1. package/package.json +6 -5
  2. package/src/auth/Auth.js +13 -8
  3. package/src/auth/AuthController.js +45 -134
  4. package/src/auth/AuthMiddleware.js +12 -23
  5. package/src/auth/AuthUser.js +98 -0
  6. package/src/auth/RoleMiddleware.js +7 -17
  7. package/src/cli.js +1 -1
  8. package/src/commands/migrate.js +46 -31
  9. package/src/commands/serve.js +238 -38
  10. package/src/container/AppInitializer.js +158 -0
  11. package/src/container/Application.js +288 -183
  12. package/src/container/HttpServer.js +156 -0
  13. package/src/container/MillasApp.js +23 -280
  14. package/src/container/MillasConfig.js +163 -0
  15. package/src/controller/Controller.js +79 -300
  16. package/src/core/auth.js +9 -0
  17. package/src/core/db.js +8 -0
  18. package/src/core/foundation.js +67 -0
  19. package/src/core/http.js +11 -0
  20. package/src/core/mail.js +6 -0
  21. package/src/core/queue.js +7 -0
  22. package/src/core/validation.js +29 -0
  23. package/src/errors/ErrorRenderer.js +640 -0
  24. package/src/facades/Admin.js +49 -0
  25. package/src/facades/Auth.js +29 -0
  26. package/src/facades/Cache.js +28 -0
  27. package/src/facades/Database.js +43 -0
  28. package/src/facades/Events.js +25 -0
  29. package/src/facades/Facade.js +197 -0
  30. package/src/facades/Http.js +51 -0
  31. package/src/facades/Log.js +32 -0
  32. package/src/facades/Mail.js +35 -0
  33. package/src/facades/Queue.js +30 -0
  34. package/src/facades/Storage.js +25 -0
  35. package/src/facades/Url.js +53 -0
  36. package/src/http/HttpClient.js +673 -0
  37. package/src/http/MillasRequest.js +253 -0
  38. package/src/http/MillasResponse.js +196 -0
  39. package/src/http/RequestContext.js +176 -0
  40. package/src/http/ResponseDispatcher.js +51 -0
  41. package/src/http/UrlGenerator.js +375 -0
  42. package/src/http/WelcomePage.js +273 -0
  43. package/src/http/adapters/ExpressAdapter.js +315 -0
  44. package/src/http/adapters/HttpAdapter.js +168 -0
  45. package/src/http/adapters/index.js +9 -0
  46. package/src/http/helpers.js +164 -0
  47. package/src/http/index.js +13 -0
  48. package/src/index.js +5 -91
  49. package/src/logger/formatters/PrettyFormatter.js +15 -5
  50. package/src/logger/internal.js +76 -0
  51. package/src/logger/patchConsole.js +145 -0
  52. package/src/middleware/CorsMiddleware.js +22 -30
  53. package/src/middleware/LogMiddleware.js +27 -59
  54. package/src/middleware/Middleware.js +24 -15
  55. package/src/middleware/MiddlewarePipeline.js +30 -67
  56. package/src/middleware/MiddlewareRegistry.js +106 -0
  57. package/src/middleware/ThrottleMiddleware.js +22 -26
  58. package/src/orm/fields/index.js +124 -56
  59. package/src/orm/migration/ModelInspector.js +339 -336
  60. package/src/orm/model/Model.js +96 -6
  61. package/src/orm/query/QueryBuilder.js +141 -3
  62. package/src/providers/AuthServiceProvider.js +9 -5
  63. package/src/providers/CacheStorageServiceProvider.js +3 -1
  64. package/src/providers/EventServiceProvider.js +2 -1
  65. package/src/providers/LogServiceProvider.js +88 -17
  66. package/src/providers/MailServiceProvider.js +3 -2
  67. package/src/providers/ProviderRegistry.js +14 -1
  68. package/src/providers/QueueServiceProvider.js +3 -2
  69. package/src/providers/ServiceProvider.js +40 -8
  70. package/src/router/Router.js +121 -222
  71. package/src/scaffold/maker.js +24 -59
  72. package/src/scaffold/templates.js +21 -19
  73. package/src/validation/BaseValidator.js +193 -0
  74. 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, '&amp;')
267
+ .replace(/</g, '&lt;')
268
+ .replace(/>/g, '&gt;')
269
+ .replace(/"/g, '&quot;')
270
+ .replace(/'/g, '&#39;');
271
+ }
272
+
273
+ module.exports = WelcomePage;