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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1 -1
  3. data/README.md +434 -544
  4. data/exe/{tina4 → tina4ruby} +1 -0
  5. data/lib/tina4/ai.rb +312 -0
  6. data/lib/tina4/auth.rb +44 -3
  7. data/lib/tina4/auto_crud.rb +163 -0
  8. data/lib/tina4/cli.rb +389 -97
  9. data/lib/tina4/constants.rb +46 -0
  10. data/lib/tina4/cors.rb +74 -0
  11. data/lib/tina4/database/sqlite3_adapter.rb +139 -0
  12. data/lib/tina4/database.rb +144 -7
  13. data/lib/tina4/debug.rb +4 -79
  14. data/lib/tina4/dev_admin.rb +1162 -0
  15. data/lib/tina4/dev_mailbox.rb +191 -0
  16. data/lib/tina4/dev_reload.rb +9 -9
  17. data/lib/tina4/drivers/firebird_driver.rb +19 -3
  18. data/lib/tina4/drivers/mssql_driver.rb +3 -3
  19. data/lib/tina4/drivers/mysql_driver.rb +4 -4
  20. data/lib/tina4/drivers/postgres_driver.rb +9 -2
  21. data/lib/tina4/drivers/sqlite_driver.rb +1 -1
  22. data/lib/tina4/env.rb +42 -2
  23. data/lib/tina4/error_overlay.rb +252 -0
  24. data/lib/tina4/events.rb +90 -0
  25. data/lib/tina4/field_types.rb +4 -0
  26. data/lib/tina4/frond.rb +1497 -0
  27. data/lib/tina4/gallery/auth/meta.json +1 -0
  28. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -0
  29. data/lib/tina4/gallery/database/meta.json +1 -0
  30. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -0
  31. data/lib/tina4/gallery/error-overlay/meta.json +1 -0
  32. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -0
  33. data/lib/tina4/gallery/orm/meta.json +1 -0
  34. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -0
  35. data/lib/tina4/gallery/queue/meta.json +1 -0
  36. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -0
  37. data/lib/tina4/gallery/rest-api/meta.json +1 -0
  38. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -0
  39. data/lib/tina4/gallery/templates/meta.json +1 -0
  40. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -0
  41. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -0
  42. data/lib/tina4/health.rb +39 -0
  43. data/lib/tina4/html_element.rb +148 -0
  44. data/lib/tina4/localization.rb +2 -2
  45. data/lib/tina4/log.rb +203 -0
  46. data/lib/tina4/messenger.rb +562 -0
  47. data/lib/tina4/migration.rb +132 -29
  48. data/lib/tina4/orm.rb +463 -35
  49. data/lib/tina4/public/css/tina4.css +178 -1
  50. data/lib/tina4/public/css/tina4.min.css +1 -2
  51. data/lib/tina4/public/favicon.ico +0 -0
  52. data/lib/tina4/public/images/logo.svg +5 -0
  53. data/lib/tina4/public/images/tina4-logo-icon.webp +0 -0
  54. data/lib/tina4/public/js/frond.min.js +420 -0
  55. data/lib/tina4/public/js/tina4-dev-admin.min.js +367 -0
  56. data/lib/tina4/public/js/tina4.min.js +93 -0
  57. data/lib/tina4/public/swagger/index.html +90 -0
  58. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -0
  59. data/lib/tina4/queue.rb +162 -6
  60. data/lib/tina4/queue_backends/lite_backend.rb +88 -0
  61. data/lib/tina4/rack_app.rb +331 -27
  62. data/lib/tina4/rate_limiter.rb +123 -0
  63. data/lib/tina4/request.rb +61 -15
  64. data/lib/tina4/response.rb +54 -24
  65. data/lib/tina4/response_cache.rb +551 -0
  66. data/lib/tina4/router.rb +90 -15
  67. data/lib/tina4/scss_compiler.rb +2 -2
  68. data/lib/tina4/seeder.rb +56 -61
  69. data/lib/tina4/service_runner.rb +303 -0
  70. data/lib/tina4/session.rb +85 -0
  71. data/lib/tina4/session_handlers/mongo_handler.rb +1 -1
  72. data/lib/tina4/session_handlers/valkey_handler.rb +43 -0
  73. data/lib/tina4/shutdown.rb +84 -0
  74. data/lib/tina4/sql_translation.rb +295 -0
  75. data/lib/tina4/template.rb +36 -6
  76. data/lib/tina4/templates/base.twig +2 -2
  77. data/lib/tina4/templates/errors/302.twig +14 -0
  78. data/lib/tina4/templates/errors/401.twig +9 -0
  79. data/lib/tina4/templates/errors/403.twig +22 -15
  80. data/lib/tina4/templates/errors/404.twig +22 -15
  81. data/lib/tina4/templates/errors/500.twig +31 -15
  82. data/lib/tina4/templates/errors/502.twig +9 -0
  83. data/lib/tina4/templates/errors/503.twig +12 -0
  84. data/lib/tina4/templates/errors/base.twig +37 -0
  85. data/lib/tina4/version.rb +1 -1
  86. data/lib/tina4/webserver.rb +28 -18
  87. data/lib/tina4.rb +118 -21
  88. metadata +68 -8
  89. data/lib/tina4/public/js/tina4.js +0 -134
  90. 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;">&lt;link href="/css/tina4.min.css"&gt;</code>.
250
+ </p>
251
+ </div>
252
+ </div>
253
+
254
+ </div>
255
+
256
+ </body>
257
+ </html>
@@ -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("&", "&amp;")
95
+ .gsub('"', "&quot;")
96
+ .gsub("<", "&lt;")
97
+ .gsub(">", "&gt;")
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
@@ -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::Debug.debug("Loaded locale: #{locale} from #{file}")
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::Debug.warning("YAML support requires the 'yaml' gem")
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