tina4ruby 0.5.2 → 3.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1 -1
- data/README.md +434 -544
- data/exe/{tina4 → tina4ruby} +1 -0
- data/lib/tina4/ai.rb +312 -0
- data/lib/tina4/auth.rb +44 -3
- data/lib/tina4/auto_crud.rb +163 -0
- data/lib/tina4/cli.rb +389 -97
- data/lib/tina4/constants.rb +46 -0
- data/lib/tina4/cors.rb +74 -0
- data/lib/tina4/database/sqlite3_adapter.rb +139 -0
- data/lib/tina4/database.rb +144 -7
- data/lib/tina4/debug.rb +4 -79
- data/lib/tina4/dev_admin.rb +1162 -0
- data/lib/tina4/dev_mailbox.rb +191 -0
- data/lib/tina4/dev_reload.rb +9 -9
- data/lib/tina4/drivers/firebird_driver.rb +19 -3
- data/lib/tina4/drivers/mssql_driver.rb +3 -3
- data/lib/tina4/drivers/mysql_driver.rb +4 -4
- data/lib/tina4/drivers/postgres_driver.rb +9 -2
- data/lib/tina4/drivers/sqlite_driver.rb +1 -1
- data/lib/tina4/env.rb +42 -2
- data/lib/tina4/error_overlay.rb +252 -0
- data/lib/tina4/events.rb +90 -0
- data/lib/tina4/field_types.rb +4 -0
- data/lib/tina4/frond.rb +1497 -0
- data/lib/tina4/gallery/auth/meta.json +1 -0
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -0
- data/lib/tina4/gallery/database/meta.json +1 -0
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -0
- data/lib/tina4/gallery/error-overlay/meta.json +1 -0
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -0
- data/lib/tina4/gallery/orm/meta.json +1 -0
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -0
- data/lib/tina4/gallery/queue/meta.json +1 -0
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -0
- data/lib/tina4/gallery/rest-api/meta.json +1 -0
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -0
- data/lib/tina4/gallery/templates/meta.json +1 -0
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -0
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -0
- data/lib/tina4/health.rb +39 -0
- data/lib/tina4/html_element.rb +148 -0
- data/lib/tina4/localization.rb +2 -2
- data/lib/tina4/log.rb +203 -0
- data/lib/tina4/messenger.rb +562 -0
- data/lib/tina4/migration.rb +132 -29
- data/lib/tina4/orm.rb +463 -35
- data/lib/tina4/public/css/tina4.css +178 -1
- data/lib/tina4/public/css/tina4.min.css +1 -2
- data/lib/tina4/public/favicon.ico +0 -0
- data/lib/tina4/public/images/logo.svg +5 -0
- data/lib/tina4/public/images/tina4-logo-icon.webp +0 -0
- data/lib/tina4/public/js/frond.min.js +420 -0
- data/lib/tina4/public/js/tina4-dev-admin.min.js +367 -0
- data/lib/tina4/public/js/tina4.min.js +93 -0
- data/lib/tina4/public/swagger/index.html +90 -0
- data/lib/tina4/public/swagger/oauth2-redirect.html +63 -0
- data/lib/tina4/queue.rb +162 -6
- data/lib/tina4/queue_backends/lite_backend.rb +88 -0
- data/lib/tina4/rack_app.rb +331 -27
- data/lib/tina4/rate_limiter.rb +123 -0
- data/lib/tina4/request.rb +61 -15
- data/lib/tina4/response.rb +54 -24
- data/lib/tina4/response_cache.rb +551 -0
- data/lib/tina4/router.rb +90 -15
- data/lib/tina4/scss_compiler.rb +2 -2
- data/lib/tina4/seeder.rb +56 -61
- data/lib/tina4/service_runner.rb +303 -0
- data/lib/tina4/session.rb +85 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +1 -1
- data/lib/tina4/session_handlers/valkey_handler.rb +43 -0
- data/lib/tina4/shutdown.rb +84 -0
- data/lib/tina4/sql_translation.rb +295 -0
- data/lib/tina4/template.rb +36 -6
- data/lib/tina4/templates/base.twig +2 -2
- data/lib/tina4/templates/errors/302.twig +14 -0
- data/lib/tina4/templates/errors/401.twig +9 -0
- data/lib/tina4/templates/errors/403.twig +22 -15
- data/lib/tina4/templates/errors/404.twig +22 -15
- data/lib/tina4/templates/errors/500.twig +31 -15
- data/lib/tina4/templates/errors/502.twig +9 -0
- data/lib/tina4/templates/errors/503.twig +12 -0
- data/lib/tina4/templates/errors/base.twig +37 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -18
- data/lib/tina4.rb +118 -21
- metadata +68 -8
- data/lib/tina4/public/js/tina4.js +0 -134
- data/lib/tina4/public/js/tina4helper.js +0 -387
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>{{ title }}</title>
|
|
7
|
+
<link rel="stylesheet" href="/css/tina4.min.css">
|
|
8
|
+
<style>
|
|
9
|
+
.component-section { margin-bottom: 2.5rem; }
|
|
10
|
+
.component-section h3 { border-bottom: 2px solid var(--bs-primary, #0d6efd); padding-bottom: 0.5rem; margin-bottom: 1rem; }
|
|
11
|
+
.code-preview { background: #1e293b; color: #4ade80; padding: 1rem; border-radius: 0.5rem; font-family: monospace; font-size: 0.85rem; overflow-x: auto; margin-top: 0.75rem; }
|
|
12
|
+
</style>
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
|
|
16
|
+
<!-- Navbar -->
|
|
17
|
+
<nav class="navbar navbar-dark bg-dark navbar-expand-lg">
|
|
18
|
+
<div class="container">
|
|
19
|
+
<a class="navbar-brand" href="/">Tina4 CSS Showcase</a>
|
|
20
|
+
<div class="navbar-nav ms-auto">
|
|
21
|
+
<a class="nav-link active" href="#">Components</a>
|
|
22
|
+
<a class="nav-link" href="/__dev">Dashboard</a>
|
|
23
|
+
<a class="nav-link" href="/swagger">API Docs</a>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
</nav>
|
|
27
|
+
|
|
28
|
+
<div class="container mt-4">
|
|
29
|
+
|
|
30
|
+
<div class="alert alert-info alert-dismissible mb-4">
|
|
31
|
+
<strong>tina4css</strong> — Zero-dependency CSS framework (~24KB). Bootstrap-compatible class names, dark mode ready. No CDN needed — ships with every Tina4 project.
|
|
32
|
+
<button type="button" class="btn-close" onclick="this.parentElement.remove()"></button>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<!-- Buttons -->
|
|
36
|
+
<div class="component-section">
|
|
37
|
+
<h3>Buttons</h3>
|
|
38
|
+
<div class="d-flex flex-wrap gap-2 mb-3">
|
|
39
|
+
<button class="btn btn-primary">Primary</button>
|
|
40
|
+
<button class="btn btn-secondary">Secondary</button>
|
|
41
|
+
<button class="btn btn-success">Success</button>
|
|
42
|
+
<button class="btn btn-danger">Danger</button>
|
|
43
|
+
<button class="btn btn-warning">Warning</button>
|
|
44
|
+
<button class="btn btn-info">Info</button>
|
|
45
|
+
<button class="btn btn-dark">Dark</button>
|
|
46
|
+
<button class="btn btn-light">Light</button>
|
|
47
|
+
</div>
|
|
48
|
+
<div class="d-flex flex-wrap gap-2 mb-3">
|
|
49
|
+
<button class="btn btn-outline-primary">Outline</button>
|
|
50
|
+
<button class="btn btn-outline-success">Outline</button>
|
|
51
|
+
<button class="btn btn-outline-danger">Outline</button>
|
|
52
|
+
<button class="btn btn-outline-warning">Outline</button>
|
|
53
|
+
</div>
|
|
54
|
+
<div class="d-flex flex-wrap gap-2">
|
|
55
|
+
<button class="btn btn-primary btn-lg">Large</button>
|
|
56
|
+
<button class="btn btn-primary">Default</button>
|
|
57
|
+
<button class="btn btn-primary btn-sm">Small</button>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<!-- Alerts -->
|
|
62
|
+
<div class="component-section">
|
|
63
|
+
<h3>Alerts</h3>
|
|
64
|
+
<div class="alert alert-success">Success — Record saved successfully!</div>
|
|
65
|
+
<div class="alert alert-danger">Error — Something went wrong.</div>
|
|
66
|
+
<div class="alert alert-warning">Warning — Please check your input.</div>
|
|
67
|
+
<div class="alert alert-info">Info — New version available.</div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<!-- Cards -->
|
|
71
|
+
<div class="component-section">
|
|
72
|
+
<h3>Cards</h3>
|
|
73
|
+
<div class="row">
|
|
74
|
+
{% for item in items %}
|
|
75
|
+
<div class="col-md-4 mb-3">
|
|
76
|
+
<div class="card h-100">
|
|
77
|
+
<div class="card-header bg-primary text-white">{{ item.name }}</div>
|
|
78
|
+
<div class="card-body">
|
|
79
|
+
<p class="card-text">{{ item.description }}</p>
|
|
80
|
+
<span class="badge bg-success">{{ item.badge }}</span>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="card-footer text-muted">
|
|
83
|
+
<small>Built with tina4css</small>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
{% endfor %}
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<!-- Badges -->
|
|
92
|
+
<div class="component-section">
|
|
93
|
+
<h3>Badges</h3>
|
|
94
|
+
<span class="badge bg-primary me-1">Primary</span>
|
|
95
|
+
<span class="badge bg-secondary me-1">Secondary</span>
|
|
96
|
+
<span class="badge bg-success me-1">Success</span>
|
|
97
|
+
<span class="badge bg-danger me-1">Danger</span>
|
|
98
|
+
<span class="badge bg-warning me-1">Warning</span>
|
|
99
|
+
<span class="badge bg-info me-1">Info</span>
|
|
100
|
+
<span class="badge bg-dark me-1">Dark</span>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<!-- Forms -->
|
|
104
|
+
<div class="component-section">
|
|
105
|
+
<h3>Forms</h3>
|
|
106
|
+
<div class="card">
|
|
107
|
+
<div class="card-body">
|
|
108
|
+
<form>
|
|
109
|
+
<div class="row mb-3">
|
|
110
|
+
<div class="col-md-6">
|
|
111
|
+
<div class="form-group">
|
|
112
|
+
<label class="form-label">Full Name</label>
|
|
113
|
+
<input type="text" class="form-control" placeholder="Andre van Zuydam">
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
<div class="col-md-6">
|
|
117
|
+
<div class="form-group">
|
|
118
|
+
<label class="form-label">Email</label>
|
|
119
|
+
<input type="email" class="form-control" placeholder="you@example.com">
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
<div class="row mb-3">
|
|
124
|
+
<div class="col-md-6">
|
|
125
|
+
<div class="form-group">
|
|
126
|
+
<label class="form-label">Framework</label>
|
|
127
|
+
<select class="form-select">
|
|
128
|
+
<option>Python</option>
|
|
129
|
+
<option>PHP</option>
|
|
130
|
+
<option selected>Ruby</option>
|
|
131
|
+
<option>Node.js</option>
|
|
132
|
+
</select>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
<div class="col-md-6">
|
|
136
|
+
<div class="form-group">
|
|
137
|
+
<label class="form-label">Database</label>
|
|
138
|
+
<select class="form-select">
|
|
139
|
+
<option>SQLite</option>
|
|
140
|
+
<option>PostgreSQL</option>
|
|
141
|
+
<option>MySQL</option>
|
|
142
|
+
<option>Firebird</option>
|
|
143
|
+
<option>MSSQL</option>
|
|
144
|
+
</select>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
<div class="form-group mb-3">
|
|
149
|
+
<label class="form-label">Message</label>
|
|
150
|
+
<textarea class="form-control" rows="3" placeholder="Tell us about your project..."></textarea>
|
|
151
|
+
</div>
|
|
152
|
+
<div class="form-check mb-3">
|
|
153
|
+
<input class="form-check-input" type="checkbox" checked>
|
|
154
|
+
<label class="form-check-label">I agree to the terms</label>
|
|
155
|
+
</div>
|
|
156
|
+
<button type="button" class="btn btn-primary">Submit</button>
|
|
157
|
+
<button type="button" class="btn btn-outline-secondary ms-2">Cancel</button>
|
|
158
|
+
</form>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<!-- Table -->
|
|
164
|
+
<div class="component-section">
|
|
165
|
+
<h3>Tables</h3>
|
|
166
|
+
<div class="table-responsive">
|
|
167
|
+
<table class="table table-striped table-hover">
|
|
168
|
+
<thead>
|
|
169
|
+
<tr>
|
|
170
|
+
<th>#</th>
|
|
171
|
+
<th>Framework</th>
|
|
172
|
+
<th>Port</th>
|
|
173
|
+
<th>Language</th>
|
|
174
|
+
<th>Status</th>
|
|
175
|
+
</tr>
|
|
176
|
+
</thead>
|
|
177
|
+
<tbody>
|
|
178
|
+
<tr><td>1</td><td>tina4-python</td><td>7145</td><td>Python 3.12+</td><td><span class="badge bg-success">Stable</span></td></tr>
|
|
179
|
+
<tr><td>2</td><td>tina4-php</td><td>7146</td><td>PHP 8.2+</td><td><span class="badge bg-success">Stable</span></td></tr>
|
|
180
|
+
<tr><td>3</td><td>tina4-ruby</td><td>7147</td><td>Ruby 3.1+</td><td><span class="badge bg-success">Stable</span></td></tr>
|
|
181
|
+
<tr><td>4</td><td>tina4-nodejs</td><td>7148</td><td>Node 20+</td><td><span class="badge bg-success">Stable</span></td></tr>
|
|
182
|
+
</tbody>
|
|
183
|
+
</table>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<!-- Breadcrumbs -->
|
|
188
|
+
<div class="component-section">
|
|
189
|
+
<h3>Breadcrumbs</h3>
|
|
190
|
+
<nav>
|
|
191
|
+
<ol class="breadcrumb">
|
|
192
|
+
<li class="breadcrumb-item"><a href="/">Home</a></li>
|
|
193
|
+
<li class="breadcrumb-item"><a href="#">Gallery</a></li>
|
|
194
|
+
<li class="breadcrumb-item active">Components</li>
|
|
195
|
+
</ol>
|
|
196
|
+
</nav>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<!-- Input Groups -->
|
|
200
|
+
<div class="component-section">
|
|
201
|
+
<h3>Input Groups</h3>
|
|
202
|
+
<div class="input-group mb-3">
|
|
203
|
+
<span class="input-group-text">@</span>
|
|
204
|
+
<input type="text" class="form-control" placeholder="Username">
|
|
205
|
+
</div>
|
|
206
|
+
<div class="input-group mb-3">
|
|
207
|
+
<input type="text" class="form-control" placeholder="Search routes...">
|
|
208
|
+
<button class="btn btn-primary">Search</button>
|
|
209
|
+
</div>
|
|
210
|
+
<div class="input-group">
|
|
211
|
+
<span class="input-group-text">https://</span>
|
|
212
|
+
<input type="text" class="form-control" placeholder="tina4.com">
|
|
213
|
+
<span class="input-group-text">/api</span>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<!-- Progress -->
|
|
218
|
+
<div class="component-section">
|
|
219
|
+
<h3>Progress Bars</h3>
|
|
220
|
+
<div class="progress mb-2"><div class="progress-bar bg-primary" style="width: 25%">25%</div></div>
|
|
221
|
+
<div class="progress mb-2"><div class="progress-bar bg-success" style="width: 50%">50%</div></div>
|
|
222
|
+
<div class="progress mb-2"><div class="progress-bar bg-warning" style="width: 75%">75%</div></div>
|
|
223
|
+
<div class="progress"><div class="progress-bar bg-danger" style="width: 100%">100%</div></div>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
<!-- List Group -->
|
|
227
|
+
<div class="component-section">
|
|
228
|
+
<h3>List Group</h3>
|
|
229
|
+
<div class="list-group">
|
|
230
|
+
<a href="#" class="list-group-item list-group-item-action active">Routes — 12 registered</a>
|
|
231
|
+
<a href="#" class="list-group-item list-group-item-action">Queue — 3 pending jobs</a>
|
|
232
|
+
<a href="#" class="list-group-item list-group-item-action">Database — SQLite connected</a>
|
|
233
|
+
<a href="#" class="list-group-item list-group-item-action disabled">Cache — Not configured</a>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
<!-- How this page was built -->
|
|
238
|
+
<div class="card mt-4 mb-5" style="background:#0f172a;border:1px solid #334155;">
|
|
239
|
+
<div class="card-body">
|
|
240
|
+
<h5 style="color:#e2e8f0;">How this page was built</h5>
|
|
241
|
+
<pre style="background:#1e293b;border:1px solid #334155;border-radius:0.5rem;padding:1.25rem;margin-top:0.75rem;overflow-x:auto;font-family:'SF Mono',SFMono-Regular,Consolas,monospace;font-size:0.9rem;line-height:1.7;"><code><span style="color:#c084fc;">Tina4::Router.get</span> <span style="color:#4ade80;">"/gallery/page"</span>, <span style="color:#38bdf8;">template:</span> <span style="color:#4ade80;">"gallery_page.twig"</span> <span style="color:#c084fc;">do</span> |request, response|
|
|
242
|
+
response.call({
|
|
243
|
+
<span style="color:#fbbf24;">title:</span> <span style="color:#4ade80;">"tina4css Component Showcase"</span>,
|
|
244
|
+
<span style="color:#fbbf24;">items:</span> [...]
|
|
245
|
+
}, Tina4::HTTP_OK)
|
|
246
|
+
<span style="color:#c084fc;">end</span></code></pre>
|
|
247
|
+
<p style="color:#94a3b8;margin-top:0.75rem;margin-bottom:0;">
|
|
248
|
+
Rendered via <code style="color:#c084fc;">template:</code> keyword. Styled with <strong style="color:#e2e8f0;">tina4css</strong> — zero external CDN.
|
|
249
|
+
All components above use only <code style="color:#4ade80;"><link href="/css/tina4.min.css"></code>.
|
|
250
|
+
</p>
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
</body>
|
|
257
|
+
</html>
|
data/lib/tina4/health.rb
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Tina4
|
|
6
|
+
module Health
|
|
7
|
+
START_TIME = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def register!
|
|
11
|
+
Tina4::Router.add_route("GET", "/health", method(:handle))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def handle(_request, response)
|
|
15
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
16
|
+
uptime = (now - START_TIME).round(2)
|
|
17
|
+
|
|
18
|
+
payload = {
|
|
19
|
+
status: "ok",
|
|
20
|
+
version: Tina4::VERSION,
|
|
21
|
+
uptime: uptime,
|
|
22
|
+
framework: "tina4-ruby"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
response.json(payload)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def status
|
|
29
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
30
|
+
{
|
|
31
|
+
status: "ok",
|
|
32
|
+
version: Tina4::VERSION,
|
|
33
|
+
uptime: (now - START_TIME).round(2),
|
|
34
|
+
framework: "tina4-ruby"
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tina4
|
|
4
|
+
# Programmatic HTML builder — avoids string concatenation.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# el = Tina4::HtmlElement.new("div", { class: "card" }, ["Hello"])
|
|
8
|
+
# el.to_s # => '<div class="card">Hello</div>'
|
|
9
|
+
#
|
|
10
|
+
# # Builder pattern (via call)
|
|
11
|
+
# el = Tina4::HtmlElement.new("div").call(Tina4::HtmlElement.new("p").call("Text"))
|
|
12
|
+
#
|
|
13
|
+
# # Helper functions
|
|
14
|
+
# include Tina4::HtmlHelpers
|
|
15
|
+
# html = _div({ class: "card" }, _p("Hello"))
|
|
16
|
+
#
|
|
17
|
+
class HtmlElement
|
|
18
|
+
VOID_TAGS = %w[
|
|
19
|
+
area base br col embed hr img input
|
|
20
|
+
link meta param source track wbr
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
HTML_TAGS = %w[
|
|
24
|
+
a abbr address area article aside audio
|
|
25
|
+
b base bdi bdo blockquote body br button
|
|
26
|
+
canvas caption cite code col colgroup
|
|
27
|
+
data datalist dd del details dfn dialog div dl dt
|
|
28
|
+
em embed
|
|
29
|
+
fieldset figcaption figure footer form
|
|
30
|
+
h1 h2 h3 h4 h5 h6 head header hgroup hr html
|
|
31
|
+
i iframe img input ins
|
|
32
|
+
kbd
|
|
33
|
+
label legend li link
|
|
34
|
+
main map mark menu meta meter
|
|
35
|
+
nav noscript
|
|
36
|
+
object ol optgroup option output
|
|
37
|
+
p param picture pre progress
|
|
38
|
+
q
|
|
39
|
+
rp rt ruby
|
|
40
|
+
s samp script section select slot small source span
|
|
41
|
+
strong style sub summary sup
|
|
42
|
+
table tbody td template textarea tfoot th thead time
|
|
43
|
+
title tr track
|
|
44
|
+
u ul
|
|
45
|
+
var video
|
|
46
|
+
wbr
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
attr_reader :tag, :attrs, :children
|
|
50
|
+
|
|
51
|
+
# @param tag [String] HTML tag name
|
|
52
|
+
# @param attrs [Hash] attribute => value
|
|
53
|
+
# @param children [Array] child elements (strings or HtmlElement instances)
|
|
54
|
+
def initialize(tag, attrs = {}, children = [])
|
|
55
|
+
@tag = tag.to_s.downcase
|
|
56
|
+
@attrs = attrs
|
|
57
|
+
@children = children
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Builder pattern — appends children and/or merges attributes.
|
|
61
|
+
#
|
|
62
|
+
# @param args [Array] Strings, HtmlElements, Hashes (treated as attrs), or Arrays
|
|
63
|
+
# @return [HtmlElement] a new HtmlElement with the appended children
|
|
64
|
+
def call(*args)
|
|
65
|
+
new_attrs = @attrs.dup
|
|
66
|
+
new_children = @children.dup
|
|
67
|
+
|
|
68
|
+
args.each do |arg|
|
|
69
|
+
case arg
|
|
70
|
+
when Hash
|
|
71
|
+
new_attrs = new_attrs.merge(arg)
|
|
72
|
+
when Array
|
|
73
|
+
new_children.concat(arg)
|
|
74
|
+
else
|
|
75
|
+
new_children << arg
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
self.class.new(@tag, new_attrs, new_children)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Render to HTML string.
|
|
83
|
+
def to_s
|
|
84
|
+
html = "<#{@tag}"
|
|
85
|
+
|
|
86
|
+
@attrs.each do |key, value|
|
|
87
|
+
case value
|
|
88
|
+
when true
|
|
89
|
+
html << " #{key}"
|
|
90
|
+
when false, nil
|
|
91
|
+
next
|
|
92
|
+
else
|
|
93
|
+
escaped = value.to_s
|
|
94
|
+
.gsub("&", "&")
|
|
95
|
+
.gsub('"', """)
|
|
96
|
+
.gsub("<", "<")
|
|
97
|
+
.gsub(">", ">")
|
|
98
|
+
html << " #{key}=\"#{escaped}\""
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
if VOID_TAGS.include?(@tag)
|
|
103
|
+
html << ">"
|
|
104
|
+
return html
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
html << ">"
|
|
108
|
+
|
|
109
|
+
@children.each do |child|
|
|
110
|
+
html << child.to_s
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
html << "</#{@tag}>"
|
|
114
|
+
html
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Module providing _div, _p, _span, etc. helper methods.
|
|
119
|
+
# Include in any class or use extend on a module.
|
|
120
|
+
module HtmlHelpers
|
|
121
|
+
HtmlElement::HTML_TAGS.each do |tag|
|
|
122
|
+
define_method("_#{tag}") do |*args|
|
|
123
|
+
attrs = {}
|
|
124
|
+
children = []
|
|
125
|
+
|
|
126
|
+
args.each do |arg|
|
|
127
|
+
case arg
|
|
128
|
+
when Hash
|
|
129
|
+
attrs = attrs.merge(arg)
|
|
130
|
+
when Array
|
|
131
|
+
children.concat(arg)
|
|
132
|
+
when HtmlElement
|
|
133
|
+
children << arg
|
|
134
|
+
else
|
|
135
|
+
children << arg
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
HtmlElement.new(tag, attrs, children)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Module-level convenience: Tina4.html_helpers returns a module you can include.
|
|
145
|
+
def self.html_helpers
|
|
146
|
+
HtmlHelpers
|
|
147
|
+
end
|
|
148
|
+
end
|
data/lib/tina4/localization.rb
CHANGED
|
@@ -28,7 +28,7 @@ module Tina4
|
|
|
28
28
|
data = JSON.parse(File.read(file))
|
|
29
29
|
translations[locale] ||= {}
|
|
30
30
|
translations[locale].merge!(data)
|
|
31
|
-
Tina4::
|
|
31
|
+
Tina4::Log.debug("Loaded locale: #{locale} from #{file}")
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
# Also support YAML
|
|
@@ -40,7 +40,7 @@ module Tina4
|
|
|
40
40
|
translations[locale] ||= {}
|
|
41
41
|
translations[locale].merge!(data) if data.is_a?(Hash)
|
|
42
42
|
rescue LoadError
|
|
43
|
-
Tina4::
|
|
43
|
+
Tina4::Log.warning("YAML support requires the 'yaml' gem")
|
|
44
44
|
end
|
|
45
45
|
end
|
|
46
46
|
end
|
data/lib/tina4/log.rb
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Tina4
|
|
7
|
+
module Log
|
|
8
|
+
LEVELS = {
|
|
9
|
+
"[TINA4_LOG_ALL]" => 0,
|
|
10
|
+
"[TINA4_LOG_DEBUG]" => 0,
|
|
11
|
+
"[TINA4_LOG_INFO]" => 1,
|
|
12
|
+
"[TINA4_LOG_WARNING]" => 2,
|
|
13
|
+
"[TINA4_LOG_ERROR]" => 3,
|
|
14
|
+
"[TINA4_LOG_NONE]" => 4
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
SEVERITY_MAP = {
|
|
18
|
+
debug: 0, info: 1, warn: 2, error: 3
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
COLORS = {
|
|
22
|
+
reset: "\e[0m", red: "\e[31m", green: "\e[32m",
|
|
23
|
+
yellow: "\e[33m", blue: "\e[34m", magenta: "\e[35m",
|
|
24
|
+
cyan: "\e[36m", gray: "\e[90m"
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# ANSI escape code regex for stripping from file output
|
|
28
|
+
ANSI_RE = /\033\[[0-9;]*m/
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
attr_reader :log_dir
|
|
32
|
+
|
|
33
|
+
def setup(root_dir = Dir.pwd)
|
|
34
|
+
@log_dir = File.join(root_dir, "logs")
|
|
35
|
+
FileUtils.mkdir_p(@log_dir)
|
|
36
|
+
|
|
37
|
+
@max_size_mb = (ENV["TINA4_LOG_MAX_SIZE"] || "10").to_i
|
|
38
|
+
@max_size = @max_size_mb * 1024 * 1024
|
|
39
|
+
@keep = (ENV["TINA4_LOG_KEEP"] || "5").to_i
|
|
40
|
+
@json_mode = production?
|
|
41
|
+
@log_file = File.join(@log_dir, "tina4.log")
|
|
42
|
+
|
|
43
|
+
@console_level = resolve_level
|
|
44
|
+
@request_id = nil
|
|
45
|
+
@current_context = {}
|
|
46
|
+
@mutex = Mutex.new
|
|
47
|
+
@initialized = true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def set_request_id(id)
|
|
51
|
+
@mutex.synchronize { @request_id = id }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def clear_request_id
|
|
55
|
+
@mutex.synchronize { @request_id = nil }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def request_id
|
|
59
|
+
@mutex.synchronize { @request_id }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def json_mode?
|
|
63
|
+
@json_mode
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def info(message, context = {})
|
|
67
|
+
log(:info, message, context)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def debug(message, context = {})
|
|
71
|
+
log(:debug, message, context)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def warning(message, context = {})
|
|
75
|
+
log(:warn, message, context)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def error(message, context = {})
|
|
79
|
+
log(:error, message, context)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def production?
|
|
85
|
+
env = ENV["TINA4_ENV"] || ENV["RACK_ENV"] || ENV["RUBY_ENV"] || "development"
|
|
86
|
+
env.downcase == "production"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def log(level, message, context = {})
|
|
90
|
+
setup unless @initialized
|
|
91
|
+
@current_context = context.is_a?(Hash) ? context : {}
|
|
92
|
+
|
|
93
|
+
formatted = format_line(level, message)
|
|
94
|
+
|
|
95
|
+
# Console output respects TINA4_LOG_LEVEL
|
|
96
|
+
severity = SEVERITY_MAP[level] || 0
|
|
97
|
+
if severity >= @console_level
|
|
98
|
+
if @json_mode
|
|
99
|
+
$stdout.puts json_line(level, message)
|
|
100
|
+
else
|
|
101
|
+
$stdout.puts colorize(level, formatted)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# File always gets ALL levels, plain text (no ANSI)
|
|
106
|
+
write_to_file(strip_ansi(formatted))
|
|
107
|
+
|
|
108
|
+
@current_context = {}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def resolve_level
|
|
112
|
+
env_level = ENV["TINA4_LOG_LEVEL"] || "[TINA4_LOG_ALL]"
|
|
113
|
+
LEVELS[env_level] || 0
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def severity_to_level(level)
|
|
117
|
+
case level
|
|
118
|
+
when :debug then "DEBUG"
|
|
119
|
+
when :info then "INFO"
|
|
120
|
+
when :warn then "WARNING"
|
|
121
|
+
when :error then "ERROR"
|
|
122
|
+
else level.to_s.upcase
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def utc_timestamp
|
|
127
|
+
now = Time.now.utc
|
|
128
|
+
now.strftime("%Y-%m-%dT%H:%M:%S.") + format("%03d", now.usec / 1000) + "Z"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def strip_ansi(text)
|
|
132
|
+
text.gsub(ANSI_RE, "")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def format_line(level, message)
|
|
136
|
+
level_str = severity_to_level(level)
|
|
137
|
+
ts = utc_timestamp
|
|
138
|
+
rid = request_id
|
|
139
|
+
rid_str = rid ? " [#{rid}]" : ""
|
|
140
|
+
ctx = @current_context && !@current_context.empty? ? " #{JSON.generate(@current_context)}" : ""
|
|
141
|
+
"#{ts} [#{level_str.ljust(7)}]#{rid_str} #{message}#{ctx}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def json_line(level, message)
|
|
145
|
+
level_str = severity_to_level(level)
|
|
146
|
+
entry = {
|
|
147
|
+
timestamp: utc_timestamp,
|
|
148
|
+
level: level_str,
|
|
149
|
+
message: message
|
|
150
|
+
}
|
|
151
|
+
rid = request_id
|
|
152
|
+
entry[:request_id] = rid if rid
|
|
153
|
+
entry[:context] = @current_context if @current_context && !@current_context.empty?
|
|
154
|
+
JSON.generate(entry)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def colorize(level, line)
|
|
158
|
+
color = case level
|
|
159
|
+
when :debug then COLORS[:cyan]
|
|
160
|
+
when :info then COLORS[:green]
|
|
161
|
+
when :warn then COLORS[:yellow]
|
|
162
|
+
when :error then COLORS[:red]
|
|
163
|
+
else COLORS[:reset]
|
|
164
|
+
end
|
|
165
|
+
"#{color}#{line}#{COLORS[:reset]}"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def write_to_file(line)
|
|
169
|
+
rotate_if_needed
|
|
170
|
+
begin
|
|
171
|
+
File.open(@log_file, "a") { |f| f.puts(line) }
|
|
172
|
+
rescue IOError, SystemCallError
|
|
173
|
+
# Don't crash on log write failure
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Numbered rotation: tina4.log → tina4.log.1 → tina4.log.2 → ... → tina4.log.{keep}
|
|
178
|
+
def rotate_if_needed
|
|
179
|
+
return unless File.exist?(@log_file)
|
|
180
|
+
|
|
181
|
+
begin
|
|
182
|
+
return if File.size(@log_file) < @max_size
|
|
183
|
+
rescue SystemCallError
|
|
184
|
+
return
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Delete the oldest rotated file if it exists
|
|
188
|
+
oldest = "#{@log_file}.#{@keep}"
|
|
189
|
+
File.delete(oldest) if File.exist?(oldest)
|
|
190
|
+
|
|
191
|
+
# Shift existing rotated files: .{n} → .{n+1}
|
|
192
|
+
(@keep - 1).downto(1) do |n|
|
|
193
|
+
src = "#{@log_file}.#{n}"
|
|
194
|
+
dst = "#{@log_file}.#{n + 1}"
|
|
195
|
+
File.rename(src, dst) if File.exist?(src)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Rename current log to .1
|
|
199
|
+
File.rename(@log_file, "#{@log_file}.1") rescue nil
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|