webspresso 0.0.74 → 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.
Files changed (60) hide show
  1. package/README.md +41 -3
  2. package/bin/commands/orm-map.js +139 -0
  3. package/bin/commands/skill.js +22 -8
  4. package/bin/utils/orm-map-html.js +689 -0
  5. package/bin/utils/orm-map-load.js +85 -0
  6. package/bin/utils/orm-map-snapshot.js +179 -0
  7. package/bin/utils/resolve-webspresso-orm.js +23 -0
  8. package/bin/webspresso.js +2 -0
  9. package/core/auth/manager.js +14 -1
  10. package/core/kernel/app.js +96 -0
  11. package/core/kernel/base-repository.js +143 -0
  12. package/core/kernel/events.js +101 -0
  13. package/core/kernel/flow.js +22 -0
  14. package/core/kernel/index.js +17 -0
  15. package/core/kernel/plugin.js +23 -0
  16. package/core/kernel/plugins/sample-seo.js +26 -0
  17. package/core/kernel/run-demo.js +58 -0
  18. package/core/kernel/view.js +167 -0
  19. package/core/openapi/build-from-api-routes.js +8 -2
  20. package/core/orm/model.js +3 -1
  21. package/core/url-path-normalize.js +30 -0
  22. package/index.d.ts +168 -1
  23. package/index.js +20 -2
  24. package/package.json +11 -1
  25. package/plugins/admin-panel/api.js +43 -15
  26. package/plugins/admin-panel/client/README.md +39 -0
  27. package/plugins/admin-panel/client/load-parts.js +74 -0
  28. package/plugins/admin-panel/client/manifest.parts.json +12 -0
  29. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +150 -0
  30. package/plugins/admin-panel/client/parts/02-filter-components.js +554 -0
  31. package/plugins/admin-panel/client/parts/03-pagination-intro.js +70 -0
  32. package/plugins/admin-panel/client/parts/04-field-renderers.js +287 -0
  33. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +335 -0
  34. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +125 -0
  35. package/plugins/admin-panel/client/parts/07-model-list.js +596 -0
  36. package/plugins/admin-panel/client/parts/08-record-list.js +536 -0
  37. package/plugins/admin-panel/client/parts/09-record-form.js +170 -0
  38. package/plugins/admin-panel/client/parts/10-export-registry.js +11 -0
  39. package/plugins/admin-panel/client/verify-spa-parts.js +32 -0
  40. package/plugins/admin-panel/client/vite.config.example.mjs +22 -0
  41. package/plugins/admin-panel/components.js +4 -2640
  42. package/plugins/admin-panel/core/api-extensions.js +100 -10
  43. package/plugins/admin-panel/index.js +3 -0
  44. package/plugins/admin-panel/lib/is-rich-text-empty.js +23 -0
  45. package/plugins/admin-panel/lib/sanitize-rich-html.js +106 -0
  46. package/plugins/admin-panel/modules/dashboard.js +3 -2
  47. package/plugins/admin-panel/modules/user-management.js +90 -20
  48. package/plugins/index.js +4 -0
  49. package/plugins/rate-limit/index.js +178 -0
  50. package/plugins/redirect/index.js +204 -0
  51. package/plugins/rest-resources/index.js +2 -1
  52. package/plugins/swagger.js +2 -1
  53. package/plugins/upload/local-file-provider.js +6 -2
  54. package/src/file-router.js +191 -50
  55. package/src/njk-frontmatter.js +156 -0
  56. package/src/plugin-manager.js +4 -2
  57. package/src/server.js +26 -9
  58. package/templates/skills/webspresso-usage/REFERENCE-framework.md +276 -0
  59. package/templates/skills/webspresso-usage/REFERENCE-kernel.md +93 -0
  60. package/templates/skills/webspresso-usage/SKILL.md +29 -278
@@ -0,0 +1,689 @@
1
+ /**
2
+ * Self-contained HTML page for ORM map (snapshot + Mermaid).
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+
8
+ /**
9
+ * @param {object} snapshot - { generatedAt, models }
10
+ * @param {string} mermaidSource
11
+ * @param {{ title?: string, packageName?: string }} meta
12
+ * @returns {string}
13
+ */
14
+ function buildOrmMapHtml(snapshot, mermaidSource, meta = {}) {
15
+ const title = meta.title || 'ORM model map';
16
+ const pkgName = meta.packageName || '';
17
+ const dataJson = JSON.stringify(snapshot);
18
+ const mermaidEscaped = JSON.stringify(mermaidSource);
19
+
20
+ return `<!DOCTYPE html>
21
+ <html lang="en">
22
+ <head>
23
+ <meta charset="UTF-8">
24
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
25
+ <title>${escapeHtml(title)}</title>
26
+ <link rel="preconnect" href="https://fonts.googleapis.com">
27
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
28
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
29
+ <style>
30
+ :root {
31
+ --bg: #0c0f14;
32
+ --bg-elevated: #121722;
33
+ --surface: #171d28;
34
+ --surface-hover: #1e2635;
35
+ --border: #2a3548;
36
+ --border-subtle: #222a3a;
37
+ --text: #e8edf4;
38
+ --text-secondary: #9aa8bc;
39
+ --accent: #4e9fd4;
40
+ --accent-dim: rgba(78, 159, 212, 0.15);
41
+ --accent-glow: rgba(78, 159, 212, 0.35);
42
+ --success: #6bcf7f;
43
+ --warning: #e6b05c;
44
+ --radius: 10px;
45
+ --radius-sm: 7px;
46
+ --font: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
47
+ --mono: "IBM Plex Mono", ui-monospace, monospace;
48
+ --shadow: 0 4px 24px rgba(0, 0, 0, 0.35);
49
+ --header-h: 60px;
50
+ }
51
+ * { box-sizing: border-box; }
52
+ body {
53
+ margin: 0;
54
+ font-family: var(--font);
55
+ background: var(--bg);
56
+ color: var(--text);
57
+ min-height: 100vh;
58
+ display: flex;
59
+ flex-direction: column;
60
+ font-size: 15px;
61
+ line-height: 1.5;
62
+ -webkit-font-smoothing: antialiased;
63
+ }
64
+ .sr-only {
65
+ position: absolute;
66
+ width: 1px;
67
+ height: 1px;
68
+ padding: 0;
69
+ margin: -1px;
70
+ overflow: hidden;
71
+ clip: rect(0,0,0,0);
72
+ border: 0;
73
+ }
74
+ header.app-header {
75
+ position: sticky;
76
+ top: 0;
77
+ z-index: 40;
78
+ height: var(--header-h);
79
+ padding: 0 1.25rem 0 1.5rem;
80
+ background: linear-gradient(180deg, var(--bg-elevated) 0%, rgba(18, 23, 34, 0.92) 100%);
81
+ backdrop-filter: blur(10px);
82
+ border-bottom: 1px solid var(--border-subtle);
83
+ display: flex;
84
+ align-items: center;
85
+ gap: 1rem;
86
+ flex-wrap: wrap;
87
+ }
88
+ .brand {
89
+ display: flex;
90
+ flex-direction: column;
91
+ gap: 0.1rem;
92
+ min-width: 0;
93
+ }
94
+ .brand h1 {
95
+ margin: 0;
96
+ font-size: 1.05rem;
97
+ font-weight: 600;
98
+ letter-spacing: -0.02em;
99
+ }
100
+ .brand .subtitle {
101
+ font-size: 0.75rem;
102
+ color: var(--text-secondary);
103
+ display: flex;
104
+ flex-wrap: wrap;
105
+ gap: 0.35rem 0.75rem;
106
+ align-items: center;
107
+ }
108
+ .dot-sep { opacity: 0.45; }
109
+ time { color: var(--text-secondary); }
110
+ .badge {
111
+ display: inline-flex;
112
+ align-items: center;
113
+ padding: 0.15rem 0.5rem;
114
+ border-radius: 999px;
115
+ font-size: 0.7rem;
116
+ font-weight: 600;
117
+ text-transform: uppercase;
118
+ letter-spacing: 0.04em;
119
+ background: var(--accent-dim);
120
+ color: var(--accent);
121
+ border: 1px solid rgba(78, 159, 212, 0.35);
122
+ }
123
+ .header-actions {
124
+ margin-left: auto;
125
+ display: flex;
126
+ align-items: center;
127
+ gap: 0.5rem;
128
+ flex-wrap: wrap;
129
+ }
130
+ .btn {
131
+ font-family: var(--font);
132
+ background: var(--surface);
133
+ color: var(--text);
134
+ border: 1px solid var(--border);
135
+ padding: 0.45rem 0.85rem;
136
+ border-radius: var(--radius-sm);
137
+ font-size: 0.8rem;
138
+ font-weight: 500;
139
+ cursor: pointer;
140
+ transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
141
+ }
142
+ .btn:hover {
143
+ background: var(--surface-hover);
144
+ border-color: #3d4d66;
145
+ }
146
+ .btn:focus-visible {
147
+ outline: none;
148
+ box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent-glow);
149
+ }
150
+ .btn-primary {
151
+ background: linear-gradient(180deg, #5aaee3 0%, var(--accent) 100%);
152
+ border-color: rgba(255,255,255,0.12);
153
+ color: #081018;
154
+ }
155
+ .btn-primary:hover {
156
+ filter: brightness(1.06);
157
+ border-color: rgba(255,255,255,0.2);
158
+ }
159
+ .layout {
160
+ flex: 1;
161
+ display: grid;
162
+ grid-template-columns: minmax(220px, 280px) 1fr;
163
+ min-height: 0;
164
+ max-width: 1600px;
165
+ margin: 0 auto;
166
+ width: 100%;
167
+ }
168
+ @media (max-width: 900px) {
169
+ .layout {
170
+ grid-template-columns: 1fr;
171
+ }
172
+ aside.sidebar {
173
+ border-right: none;
174
+ border-bottom: 1px solid var(--border-subtle);
175
+ max-height: min(42vh, 320px);
176
+ }
177
+ }
178
+ aside.sidebar {
179
+ background: var(--surface);
180
+ border-right: 1px solid var(--border-subtle);
181
+ display: flex;
182
+ flex-direction: column;
183
+ min-height: 0;
184
+ }
185
+ .sidebar-top {
186
+ padding: 1rem;
187
+ border-bottom: 1px solid var(--border-subtle);
188
+ flex-shrink: 0;
189
+ }
190
+ .sidebar-top label {
191
+ display: block;
192
+ font-size: 0.65rem;
193
+ font-weight: 600;
194
+ text-transform: uppercase;
195
+ letter-spacing: 0.07em;
196
+ color: var(--text-secondary);
197
+ margin-bottom: 0.45rem;
198
+ }
199
+ .filter-input {
200
+ width: 100%;
201
+ font-family: var(--font);
202
+ font-size: 0.875rem;
203
+ padding: 0.5rem 0.65rem 0.5rem 2rem;
204
+ border-radius: var(--radius-sm);
205
+ border: 1px solid var(--border);
206
+ background: var(--bg) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%239aa8bc' viewBox='0 0 16 16'%3E%3Cpath d='M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z'/%3E%3C/svg%3E") 0.55rem center no-repeat;
207
+ color: var(--text);
208
+ }
209
+ .filter-input::placeholder { color: #6b7a90; }
210
+ .filter-input:focus {
211
+ outline: none;
212
+ border-color: var(--accent);
213
+ box-shadow: 0 0 0 3px var(--accent-dim);
214
+ }
215
+ nav#nav {
216
+ overflow-y: auto;
217
+ padding: 0.5rem;
218
+ flex: 1;
219
+ display: flex;
220
+ flex-direction: column;
221
+ gap: 2px;
222
+ }
223
+ nav#nav::-webkit-scrollbar { width: 8px; }
224
+ nav#nav::-webkit-scrollbar-thumb {
225
+ background: var(--border);
226
+ border-radius: 4px;
227
+ }
228
+ .nav-item {
229
+ display: flex;
230
+ flex-direction: column;
231
+ align-items: flex-start;
232
+ gap: 0.1rem;
233
+ width: 100%;
234
+ text-align: left;
235
+ border: none;
236
+ border-radius: var(--radius-sm);
237
+ background: transparent;
238
+ color: var(--text);
239
+ padding: 0.55rem 0.65rem 0.55rem 0.75rem;
240
+ cursor: pointer;
241
+ font-family: var(--font);
242
+ transition: background 0.12s;
243
+ border-left: 3px solid transparent;
244
+ }
245
+ .nav-item:hover { background: var(--surface-hover); }
246
+ .nav-item:focus-visible {
247
+ outline: none;
248
+ box-shadow: inset 0 0 0 2px var(--accent-glow);
249
+ }
250
+ .nav-item.active {
251
+ background: var(--accent-dim);
252
+ border-left-color: var(--accent);
253
+ }
254
+ .nav-item .name { font-weight: 600; font-size: 0.9rem; letter-spacing: -0.01em; }
255
+ .nav-item .table-name {
256
+ font-family: var(--mono);
257
+ font-size: 0.72rem;
258
+ color: var(--text-secondary);
259
+ }
260
+ .nav-item.hidden { display: none; }
261
+ .content {
262
+ overflow-y: auto;
263
+ padding: 1.25rem 1.5rem 2rem;
264
+ min-height: 0;
265
+ }
266
+ .card {
267
+ background: var(--surface);
268
+ border: 1px solid var(--border-subtle);
269
+ border-radius: var(--radius);
270
+ box-shadow: var(--shadow);
271
+ margin-bottom: 1.25rem;
272
+ overflow: hidden;
273
+ }
274
+ .card-header {
275
+ display: flex;
276
+ flex-wrap: wrap;
277
+ align-items: baseline;
278
+ justify-content: space-between;
279
+ gap: 0.5rem 1rem;
280
+ padding: 0.85rem 1.1rem;
281
+ background: linear-gradient(180deg, rgba(255,255,255,0.03) 0%, transparent 100%);
282
+ border-bottom: 1px solid var(--border-subtle);
283
+ }
284
+ .card-header h2 {
285
+ margin: 0;
286
+ font-size: 0.95rem;
287
+ font-weight: 600;
288
+ }
289
+ .card-header .hint {
290
+ font-size: 0.75rem;
291
+ color: var(--text-secondary);
292
+ }
293
+ .diagram-body {
294
+ padding: 1rem 1rem 1.25rem;
295
+ overflow: auto;
296
+ background: radial-gradient(ellipse at top, rgba(78, 159, 212, 0.06) 0%, transparent 55%);
297
+ }
298
+ .diagram-body .mermaid {
299
+ display: flex;
300
+ justify-content: center;
301
+ min-height: 120px;
302
+ }
303
+ .detail-stack { padding: 0 0 1rem; }
304
+ .model-hero {
305
+ margin-bottom: 1.25rem;
306
+ }
307
+ .model-hero h2 {
308
+ margin: 0 0 0.35rem;
309
+ font-size: 1.5rem;
310
+ font-weight: 600;
311
+ letter-spacing: -0.03em;
312
+ }
313
+ .table-pill {
314
+ display: inline-flex;
315
+ font-family: var(--mono);
316
+ font-size: 0.8rem;
317
+ padding: 0.2rem 0.55rem;
318
+ border-radius: var(--radius-sm);
319
+ background: var(--bg);
320
+ border: 1px solid var(--border);
321
+ color: var(--accent);
322
+ }
323
+ .chip-row {
324
+ display: flex;
325
+ flex-wrap: wrap;
326
+ gap: 0.4rem;
327
+ margin-top: 0.75rem;
328
+ }
329
+ .chip {
330
+ font-size: 0.72rem;
331
+ padding: 0.2rem 0.5rem;
332
+ border-radius: 6px;
333
+ background: var(--bg);
334
+ border: 1px solid var(--border);
335
+ color: var(--text-secondary);
336
+ }
337
+ .chip strong { color: var(--success); font-weight: 500; }
338
+ h3.section-title {
339
+ margin: 1.25rem 0 0.6rem;
340
+ font-size: 0.72rem;
341
+ font-weight: 600;
342
+ text-transform: uppercase;
343
+ letter-spacing: 0.08em;
344
+ color: var(--text-secondary);
345
+ }
346
+ .table-wrap {
347
+ border-radius: var(--radius-sm);
348
+ border: 1px solid var(--border);
349
+ overflow: auto;
350
+ margin-bottom: 0.25rem;
351
+ }
352
+ table.data-table {
353
+ width: 100%;
354
+ border-collapse: collapse;
355
+ font-size: 0.8125rem;
356
+ }
357
+ table.data-table thead {
358
+ position: sticky;
359
+ top: 0;
360
+ z-index: 1;
361
+ }
362
+ table.data-table th {
363
+ text-align: left;
364
+ padding: 0.55rem 0.75rem;
365
+ background: var(--bg-elevated);
366
+ color: var(--text-secondary);
367
+ font-weight: 600;
368
+ font-size: 0.7rem;
369
+ text-transform: uppercase;
370
+ letter-spacing: 0.05em;
371
+ border-bottom: 1px solid var(--border);
372
+ }
373
+ table.data-table td {
374
+ padding: 0.45rem 0.75rem;
375
+ border-bottom: 1px solid var(--border-subtle);
376
+ vertical-align: top;
377
+ }
378
+ table.data-table tbody tr:nth-child(even) td {
379
+ background: rgba(0,0,0,0.12);
380
+ }
381
+ table.data-table tbody tr:last-child td { border-bottom: none; }
382
+ table.data-table code {
383
+ font-family: var(--mono);
384
+ font-size: 0.85em;
385
+ background: transparent;
386
+ padding: 0;
387
+ color: var(--text);
388
+ }
389
+ .pill {
390
+ display: inline-block;
391
+ padding: 0.12rem 0.38rem;
392
+ border-radius: 4px;
393
+ font-size: 0.68rem;
394
+ font-weight: 500;
395
+ background: var(--bg);
396
+ border: 1px solid var(--border);
397
+ color: var(--text-secondary);
398
+ margin: 0.1rem 0.1rem 0 0;
399
+ }
400
+ .pill-fk { border-color: rgba(230, 176, 92, 0.45); color: var(--warning); }
401
+ .rel-type { color: var(--success); font-weight: 500; }
402
+ .link-model {
403
+ font-family: var(--font);
404
+ background: none;
405
+ border: none;
406
+ color: var(--accent);
407
+ cursor: pointer;
408
+ font-weight: 500;
409
+ padding: 0;
410
+ text-decoration: underline;
411
+ text-underline-offset: 2px;
412
+ }
413
+ .link-model:hover { color: #7ebbe8; }
414
+ .empty-state {
415
+ padding: 2rem;
416
+ text-align: center;
417
+ color: var(--text-secondary);
418
+ font-size: 0.9rem;
419
+ }
420
+ .footer-meta {
421
+ font-size: 0.75rem;
422
+ color: var(--text-secondary);
423
+ margin-top: 1rem;
424
+ padding-top: 1rem;
425
+ border-top: 1px solid var(--border-subtle);
426
+ }
427
+ @media print {
428
+ header.app-header .header-actions { display: none; }
429
+ .filter-input { display: none; }
430
+ aside.sidebar { max-height: none !important; }
431
+ body { background: #fff; color: #111; }
432
+ .card, aside.sidebar, .nav-item.active { box-shadow: none; }
433
+ }
434
+ </style>
435
+ </head>
436
+ <body>
437
+ <header class="app-header">
438
+ <div class="brand">
439
+ <h1>${escapeHtml(title)}</h1>
440
+ <div class="subtitle">
441
+ ${pkgName ? '<span>' + escapeHtml(pkgName) + '</span><span class="dot-sep">·</span>' : ''}
442
+ <time id="gen-time" datetime="${escapeHtml(snapshot.generatedAt)}"></time>
443
+ </div>
444
+ </div>
445
+ <span class="badge" id="model-count-badge">${snapshot.models.length} models</span>
446
+ <div class="header-actions">
447
+ <button type="button" class="btn btn-primary" id="btn-svg">Download SVG</button>
448
+ <button type="button" class="btn" id="btn-print">Print / PDF</button>
449
+ </div>
450
+ </header>
451
+ <div class="layout">
452
+ <aside class="sidebar" aria-label="Model list">
453
+ <div class="sidebar-top">
454
+ <label for="filter-models">Filter models</label>
455
+ <input class="filter-input" type="search" id="filter-models" placeholder="Search by name or table…" autocomplete="off">
456
+ </div>
457
+ <nav id="nav" role="navigation"></nav>
458
+ </aside>
459
+ <div class="content">
460
+ <section class="card" aria-labelledby="diagram-heading">
461
+ <div class="card-header">
462
+ <h2 id="diagram-heading">Entity relationship</h2>
463
+ <span class="hint">Generated diagram · scroll to pan wide graphs</span>
464
+ </div>
465
+ <div class="diagram-body">
466
+ <div class="mermaid" id="mermaid-diagram"></div>
467
+ </div>
468
+ </section>
469
+ <div class="detail-stack" id="detail"></div>
470
+ </div>
471
+ </div>
472
+ <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
473
+ <script>
474
+ (function() {
475
+ var SNAPSHOT = ${dataJson};
476
+ var MERMAID_SRC = ${mermaidEscaped};
477
+
478
+ function $(id) { return document.getElementById(id); }
479
+
480
+ var nav = $('nav');
481
+ var navButtons = [];
482
+ var selected = (SNAPSHOT.models && SNAPSHOT.models[0]) ? SNAPSHOT.models[0].name : null;
483
+
484
+ function esc(s) {
485
+ if (s == null) return '';
486
+ return String(s)
487
+ .replace(/&/g, '&amp;')
488
+ .replace(/</g, '&lt;')
489
+ .replace(/>/g, '&gt;')
490
+ .replace(/"/g, '&quot;');
491
+ }
492
+
493
+ function selectModel(name) {
494
+ selected = name;
495
+ navButtons.forEach(function(item) {
496
+ var on = item.modelName === name;
497
+ item.el.classList.toggle('active', on);
498
+ item.el.setAttribute('aria-selected', on ? 'true' : 'false');
499
+ if (on) item.el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
500
+ });
501
+ renderDetail(SNAPSHOT.models.find(function(x) { return x.name === name; }));
502
+ }
503
+
504
+ function renderDetail(m) {
505
+ var d = $('detail');
506
+ if (!m) {
507
+ d.innerHTML = '<div class="empty-state">Select a model from the list.</div>';
508
+ return;
509
+ }
510
+
511
+ var chips = [];
512
+ chips.push('<span class="chip">PK <strong>' + esc(m.primaryKey) + '</strong></span>');
513
+ if (m.scopes && m.scopes.timestamps) chips.push('<span class="chip">Timestamps</span>');
514
+ if (m.scopes && m.scopes.softDelete) chips.push('<span class="chip">Soft delete</span>');
515
+ if (m.scopes && m.scopes.tenant) chips.push('<span class="chip">Tenant: <strong>' + esc(m.scopes.tenant) + '</strong></span>');
516
+ if (m.cache !== undefined && m.cache !== null) chips.push('<span class="chip">Cache: ' + esc(String(m.cache)) + '</span>');
517
+
518
+ var rows = m.columns.map(function(c) {
519
+ var flags = [];
520
+ if (c.primary) flags.push('PK');
521
+ if (c.unique) flags.push('UQ');
522
+ if (c.index) flags.push('IX');
523
+ var fk = c.references ? 'FK→' + c.references : '';
524
+ var flagHtml = flags.map(function(f) {
525
+ return '<span class="pill">' + esc(f) + '</span>';
526
+ }).join(' ');
527
+ if (fk) flagHtml += '<span class="pill pill-fk">' + esc(fk) + '</span>';
528
+ return '<tr><td><code>' + esc(c.name) + '</code></td><td>' + esc(c.type) + '</td><td>' + (c.nullable ? '<span class="pill">NULL</span>' : '<span class="pill">NOT NULL</span>') + '</td><td>' + (flagHtml || '—') + '</td></tr>';
529
+ }).join('');
530
+
531
+ var modelNames = new Set(SNAPSHOT.models.map(function(x) { return x.name; }));
532
+ var rels = (m.relations || []).map(function(r) {
533
+ var targetCell = '—';
534
+ if (r.targetModel) {
535
+ if (modelNames.has(r.targetModel)) {
536
+ targetCell = '<button type="button" class="link-model" data-jump="' + esc(r.targetModel) + '">' + esc(r.targetModel) + '</button>';
537
+ } else {
538
+ targetCell = esc(r.targetModel);
539
+ }
540
+ }
541
+ return '<tr><td><code>' + esc(r.name) + '</code></td><td class="rel-type">' + esc(r.type) + '</td><td>' + targetCell + '</td><td><code>' + esc(r.foreignKey) + '</code></td></tr>';
542
+ }).join('');
543
+
544
+ d.innerHTML =
545
+ '<div class="model-hero">' +
546
+ '<h2>' + esc(m.name) + '</h2>' +
547
+ '<div><span class="table-pill">' + esc(m.table) + '</span></div>' +
548
+ '<div class="chip-row">' + chips.join('') + '</div>' +
549
+ '</div>' +
550
+ '<h3 class="section-title">Columns (' + m.columns.length + ')</h3>' +
551
+ '<div class="table-wrap"><table class="data-table"><thead><tr><th>Column</th><th>Type</th><th>Null</th><th>Flags</th></tr></thead><tbody>' +
552
+ (rows || '<tr><td colspan="4" class="empty-state">No columns</td></tr>') +
553
+ '</tbody></table></div>' +
554
+ '<h3 class="section-title">Relations (' + (m.relations || []).length + ')</h3>' +
555
+ '<div class="table-wrap"><table class="data-table"><thead><tr><th>Name</th><th>Type</th><th>Target</th><th>FK</th></tr></thead><tbody>' +
556
+ (rels || '<tr><td colspan="4" class="empty-state">No relations</td></tr>') +
557
+ '</tbody></table></div>' +
558
+ (m.rest && m.rest.enabled ? '<div class="footer-meta">REST <code>/' + esc(m.rest.path || '') + '</code>' + (m.rest.allowInclude && m.rest.allowInclude.length ? ' · include: ' + m.rest.allowInclude.map(esc).join(', ') : '') + '</div>' : '') +
559
+ (m.hidden && m.hidden.length ? '<div class="footer-meta">Hidden columns: ' + m.hidden.map(function(h) { return '<code>' + esc(h) + '</code>'; }).join(' ') + '</div>' : '') +
560
+ (m.admin && m.admin.enabled ? '<div class="footer-meta">Admin · ' + esc(m.admin.label || m.name) + (m.admin.icon ? ' ' + esc(m.admin.icon) : '') + '</div>' : '');
561
+
562
+ d.querySelectorAll('.link-model').forEach(function(btn) {
563
+ btn.addEventListener('click', function() {
564
+ var n = btn.getAttribute('data-jump');
565
+ if (n) selectModel(n);
566
+ });
567
+ });
568
+ }
569
+
570
+ SNAPSHOT.models.forEach(function(m) {
571
+ var b = document.createElement('button');
572
+ b.type = 'button';
573
+ b.className = 'nav-item' + (m.name === selected ? ' active' : '');
574
+ b.setAttribute('role', 'option');
575
+ b.setAttribute('aria-selected', m.name === selected ? 'true' : 'false');
576
+ var nameEl = document.createElement('span');
577
+ nameEl.className = 'name';
578
+ nameEl.textContent = m.name;
579
+ var tableEl = document.createElement('span');
580
+ tableEl.className = 'table-name';
581
+ tableEl.textContent = m.table;
582
+ b.appendChild(nameEl);
583
+ b.appendChild(tableEl);
584
+ b.addEventListener('click', function() { selectModel(m.name); });
585
+ navButtons.push({ modelName: m.name, el: b });
586
+ nav.appendChild(b);
587
+ });
588
+
589
+ var tEl = $('gen-time');
590
+ if (tEl && SNAPSHOT.generatedAt) {
591
+ try {
592
+ tEl.textContent = new Date(SNAPSHOT.generatedAt).toLocaleString(undefined, {
593
+ dateStyle: 'medium',
594
+ timeStyle: 'short'
595
+ });
596
+ } catch (e) {
597
+ tEl.textContent = SNAPSHOT.generatedAt;
598
+ }
599
+ }
600
+
601
+ $('filter-models').addEventListener('input', function() {
602
+ var q = (this.value || '').trim().toLowerCase();
603
+ navButtons.forEach(function(item) {
604
+ var model = SNAPSHOT.models.find(function(x) { return x.name === item.modelName; });
605
+ var hay = (model.name + ' ' + model.table).toLowerCase();
606
+ var show = !q || hay.indexOf(q) !== -1;
607
+ item.el.classList.toggle('hidden', !show);
608
+ });
609
+ });
610
+
611
+ selectModel(selected);
612
+
613
+ mermaid.initialize({
614
+ startOnLoad: false,
615
+ securityLevel: 'strict',
616
+ theme: 'base',
617
+ themeVariables: {
618
+ darkMode: true,
619
+ background: '#171d28',
620
+ mainBkg: '#1e2635',
621
+ secondBkg: '#121722',
622
+ lineColor: '#4e9fd4',
623
+ border1: '#2a3548',
624
+ border2: '#2a3548',
625
+ primaryTextColor: '#e8edf4',
626
+ secondaryTextColor: '#9aa8bc',
627
+ tertiaryTextColor: '#9aa8bc',
628
+ noteBkgColor: '#1a1f2e',
629
+ noteTextColor: '#e8edf4',
630
+ noteBorderColor: '#2a3548'
631
+ }
632
+ });
633
+ var diagramEl = $('mermaid-diagram');
634
+ diagramEl.textContent = MERMAID_SRC;
635
+ mermaid.run({ nodes: [diagramEl] }).catch(function(e) {
636
+ diagramEl.innerHTML = '<p class="empty-state">Could not render diagram: ' + esc(e && e.message ? e.message : String(e)) + '</p>';
637
+ });
638
+
639
+ $('btn-print').onclick = function() { window.print(); };
640
+
641
+ $('btn-svg').onclick = function() {
642
+ var svg = document.querySelector('#mermaid-diagram svg');
643
+ if (!svg) {
644
+ alert('Diagram is still loading — try again in a second.');
645
+ return;
646
+ }
647
+ var clone = svg.cloneNode(true);
648
+ var xml = new XMLSerializer().serializeToString(clone);
649
+ var blob = new Blob([xml], { type: 'image/svg+xml;charset=utf-8' });
650
+ var url = URL.createObjectURL(blob);
651
+ var a = document.createElement('a');
652
+ a.href = url;
653
+ a.download = 'orm-map-diagram.svg';
654
+ a.click();
655
+ URL.revokeObjectURL(url);
656
+ };
657
+ })();
658
+ </script>
659
+ </body>
660
+ </html>
661
+ `;
662
+ }
663
+
664
+ function escapeHtml(s) {
665
+ if (s == null) return '';
666
+ return String(s)
667
+ .replace(/&/g, '&amp;')
668
+ .replace(/</g, '&lt;')
669
+ .replace(/>/g, '&gt;')
670
+ .replace(/"/g, '&quot;');
671
+ }
672
+
673
+ /**
674
+ * Optional: read package name from cwd/package.json
675
+ * @param {string} cwd
676
+ * @returns {string}
677
+ */
678
+ function readPackageName(cwd) {
679
+ try {
680
+ const p = path.join(cwd, 'package.json');
681
+ if (!fs.existsSync(p)) return '';
682
+ const pkg = JSON.parse(fs.readFileSync(p, 'utf8'));
683
+ return typeof pkg.name === 'string' ? pkg.name : '';
684
+ } catch {
685
+ return '';
686
+ }
687
+ }
688
+
689
+ module.exports = { buildOrmMapHtml, readPackageName, escapeHtml };