tina4ruby 0.5.2 → 3.0.0

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 +360 -559
  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 +242 -77
  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 +43 -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 +1336 -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 +27 -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 +484 -0
  47. data/lib/tina4/migration.rb +132 -29
  48. data/lib/tina4/orm.rb +337 -31
  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 +40 -4
  60. data/lib/tina4/queue_backends/lite_backend.rb +88 -0
  61. data/lib/tina4/rack_app.rb +314 -23
  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 +134 -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 +57 -21
  88. metadata +51 -19
  89. data/lib/tina4/public/js/tina4.js +0 -134
  90. data/lib/tina4/public/js/tina4helper.js +0 -387
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "time"
6
+ require "fileutils"
7
+ require "base64"
8
+
9
+ module Tina4
10
+ class DevMailbox
11
+ attr_reader :mailbox_dir
12
+
13
+ def initialize(mailbox_dir: nil)
14
+ @mailbox_dir = mailbox_dir || ENV["TINA4_MAILBOX_DIR"] || "data/mailbox"
15
+ ensure_dirs
16
+ end
17
+
18
+ # Capture an outgoing email to the local filesystem instead of sending
19
+ def capture(to:, subject:, body:, html: false, cc: [], bcc: [],
20
+ reply_to: nil, from_address: nil, from_name: nil, attachments: [])
21
+ msg_id = SecureRandom.uuid
22
+ timestamp = Time.now
23
+
24
+ message = {
25
+ id: msg_id,
26
+ from: { name: from_name, email: from_address },
27
+ to: normalize_recipients(to),
28
+ cc: normalize_recipients(cc),
29
+ bcc: normalize_recipients(bcc),
30
+ reply_to: reply_to,
31
+ subject: subject,
32
+ body: body,
33
+ html: html,
34
+ attachments: store_attachments(msg_id, attachments),
35
+ read: false,
36
+ folder: "outbox",
37
+ created_at: timestamp.iso8601,
38
+ updated_at: timestamp.iso8601
39
+ }
40
+
41
+ write_message(msg_id, message)
42
+
43
+ Tina4::Log.debug("DevMailbox captured email: #{subject} -> #{Array(to).join(', ')}")
44
+ { success: true, message: "Email captured to dev mailbox", id: msg_id }
45
+ end
46
+
47
+ # List messages in the mailbox
48
+ def inbox(limit: 50, offset: 0, folder: nil)
49
+ messages = load_all_messages
50
+ messages = messages.select { |m| m[:folder] == folder } if folder
51
+ messages.sort_by { |m| m[:created_at] || "" }.reverse[offset, limit] || []
52
+ end
53
+
54
+ # Read a single message by ID
55
+ def read(msg_id)
56
+ path = message_path(msg_id)
57
+ return nil unless File.exist?(path)
58
+
59
+ message = JSON.parse(File.read(path), symbolize_names: true)
60
+ unless message[:read]
61
+ message[:read] = true
62
+ message[:updated_at] = Time.now.iso8601
63
+ File.write(path, JSON.pretty_generate(message))
64
+ end
65
+ message
66
+ end
67
+
68
+ # Count unread messages
69
+ def unread_count
70
+ load_all_messages.count { |m| m[:read] == false }
71
+ end
72
+
73
+ # Delete a message by ID
74
+ def delete(msg_id)
75
+ path = message_path(msg_id)
76
+ return false unless File.exist?(path)
77
+
78
+ File.delete(path)
79
+ # Clean up attachments directory
80
+ att_dir = File.join(@mailbox_dir, "attachments", msg_id)
81
+ FileUtils.rm_rf(att_dir) if Dir.exist?(att_dir)
82
+ true
83
+ end
84
+
85
+ # Clear all messages, optionally by folder
86
+ def clear(folder: nil)
87
+ if folder
88
+ load_all_messages.each do |msg|
89
+ delete(msg[:id]) if msg[:folder] == folder
90
+ end
91
+ else
92
+ messages_dir = File.join(@mailbox_dir, "messages")
93
+ FileUtils.rm_rf(messages_dir)
94
+ FileUtils.rm_rf(File.join(@mailbox_dir, "attachments"))
95
+ ensure_dirs
96
+ end
97
+ end
98
+
99
+ # Seed the mailbox with sample messages for development
100
+ def seed(count: 5)
101
+ fake = Tina4::FakeData.new
102
+ count.times do |i|
103
+ name = fake.name
104
+ email = fake.email(from_name: name)
105
+ capture(
106
+ to: "dev@localhost",
107
+ subject: fake.sentence(words: 4 + rand(4)),
108
+ body: Array.new(2 + rand(3)) { fake.sentence(words: 8 + rand(8)) }.join("\n\n"),
109
+ html: i.even?,
110
+ from_address: email,
111
+ from_name: name
112
+ )
113
+ end
114
+ Tina4::Log.info("DevMailbox seeded with #{count} messages")
115
+ end
116
+
117
+ # Count messages by folder
118
+ # Returns { inbox: N, outbox: N, total: N }
119
+ def count(folder: nil)
120
+ messages = load_all_messages
121
+ if folder
122
+ n = messages.count { |m| m[:folder] == folder }
123
+ { folder.to_sym => n, total: n }
124
+ else
125
+ inbox_count = messages.count { |m| m[:folder] == "inbox" }
126
+ outbox_count = messages.count { |m| m[:folder] == "outbox" }
127
+ { inbox: inbox_count, outbox: outbox_count, total: messages.length }
128
+ end
129
+ end
130
+
131
+ private
132
+
133
+ def ensure_dirs
134
+ FileUtils.mkdir_p(File.join(@mailbox_dir, "messages"))
135
+ FileUtils.mkdir_p(File.join(@mailbox_dir, "attachments"))
136
+ end
137
+
138
+ def message_path(msg_id)
139
+ File.join(@mailbox_dir, "messages", "#{msg_id}.json")
140
+ end
141
+
142
+ def write_message(msg_id, message)
143
+ File.write(message_path(msg_id), JSON.pretty_generate(message))
144
+ end
145
+
146
+ def load_all_messages
147
+ pattern = File.join(@mailbox_dir, "messages", "*.json")
148
+ Dir.glob(pattern).filter_map do |path|
149
+ JSON.parse(File.read(path), symbolize_names: true)
150
+ rescue JSON::ParserError => e
151
+ Tina4::Log.error("DevMailbox: corrupt message file #{path}: #{e.message}")
152
+ nil
153
+ end
154
+ end
155
+
156
+ def normalize_recipients(value)
157
+ case value
158
+ when nil then []
159
+ when String then [value]
160
+ when Array then value.flatten.compact
161
+ else [value.to_s]
162
+ end
163
+ end
164
+
165
+ def store_attachments(msg_id, attachments)
166
+ return [] if attachments.nil? || attachments.empty?
167
+
168
+ att_dir = File.join(@mailbox_dir, "attachments", msg_id)
169
+ FileUtils.mkdir_p(att_dir)
170
+
171
+ attachments.map do |attachment|
172
+ if attachment.is_a?(Hash)
173
+ filename = attachment[:filename] || attachment[:name] || "attachment"
174
+ content = attachment[:content] || ""
175
+ mime = attachment[:mime_type] || attachment[:content_type] || "application/octet-stream"
176
+ elsif attachment.is_a?(String) && File.exist?(attachment)
177
+ filename = File.basename(attachment)
178
+ content = File.binread(attachment)
179
+ mime = "application/octet-stream"
180
+ else
181
+ next nil
182
+ end
183
+
184
+ file_path = File.join(att_dir, filename)
185
+ File.binwrite(file_path, content)
186
+
187
+ { filename: filename, mime_type: mime, size: content.bytesize, path: file_path }
188
+ end.compact
189
+ end
190
+ end
191
+ end
@@ -17,25 +17,25 @@ module Tina4
17
17
  # Also watch root for .rb files
18
18
  dirs << root_dir
19
19
 
20
- Tina4::Debug.info("Dev reload watching: #{dirs.join(', ')}")
20
+ Tina4::Log.info("Dev reload watching: #{dirs.join(', ')}")
21
21
 
22
22
  @listener = Listen.to(*dirs, only: /\.(#{WATCH_EXTENSIONS.map { |e| e.delete('.') }.join('|')})$/, ignore: build_ignore_regex) do |modified, added, removed|
23
23
  changes = { modified: modified, added: added, removed: removed }
24
24
  all_files = modified + added + removed
25
25
  next if all_files.empty?
26
26
 
27
- Tina4::Debug.info("File changes detected:")
28
- modified.each { |f| Tina4::Debug.debug(" Modified: #{f}") }
29
- added.each { |f| Tina4::Debug.debug(" Added: #{f}") }
30
- removed.each { |f| Tina4::Debug.debug(" Removed: #{f}") }
27
+ Tina4::Log.info("File changes detected:")
28
+ modified.each { |f| Tina4::Log.debug(" Modified: #{f}") }
29
+ added.each { |f| Tina4::Log.debug(" Added: #{f}") }
30
+ removed.each { |f| Tina4::Log.debug(" Removed: #{f}") }
31
31
 
32
32
  # Reload Ruby files
33
33
  modified.select { |f| f.end_with?(".rb") }.each do |file|
34
34
  begin
35
35
  load file
36
- Tina4::Debug.info("Reloaded: #{file}")
36
+ Tina4::Log.info("Reloaded: #{file}")
37
37
  rescue => e
38
- Tina4::Debug.error("Reload failed: #{file} - #{e.message}")
38
+ Tina4::Log.error("Reload failed: #{file} - #{e.message}")
39
39
  end
40
40
  end
41
41
 
@@ -49,12 +49,12 @@ module Tina4
49
49
  end
50
50
 
51
51
  @listener.start
52
- Tina4::Debug.info("Dev reload started")
52
+ Tina4::Log.info("Dev reload started")
53
53
  end
54
54
 
55
55
  def stop
56
56
  @listener&.stop
57
- Tina4::Debug.info("Dev reload stopped")
57
+ Tina4::Log.info("Dev reload stopped")
58
58
  end
59
59
 
60
60
  private
@@ -5,10 +5,26 @@ module Tina4
5
5
  class FirebirdDriver
6
6
  attr_reader :connection
7
7
 
8
- def connect(connection_string)
8
+ def connect(connection_string, username: nil, password: nil)
9
9
  require "fb"
10
- db_path = connection_string.sub(/^firebird:\/\//, "")
11
- @connection = Fb::Database.new(database: db_path).connect
10
+ require "uri"
11
+ uri = URI.parse(connection_string)
12
+ host = uri.host
13
+ port = uri.port || 3050
14
+ db_path = uri.path&.sub(/^\//, "")
15
+ db_user = username || uri.user
16
+ db_pass = password || uri.password
17
+
18
+ database = if host
19
+ "#{host}/#{port}:#{db_path}"
20
+ else
21
+ db_path || connection_string.sub(/^firebird:\/\//, "")
22
+ end
23
+
24
+ opts = { database: database }
25
+ opts[:username] = db_user if db_user
26
+ opts[:password] = db_pass if db_pass
27
+ @connection = Fb::Database.new(**opts).connect
12
28
  rescue LoadError
13
29
  raise "Firebird driver requires the 'fb' gem. Install it with: gem install fb"
14
30
  end
@@ -5,14 +5,14 @@ module Tina4
5
5
  class MssqlDriver
6
6
  attr_reader :connection
7
7
 
8
- def connect(connection_string)
8
+ def connect(connection_string, username: nil, password: nil)
9
9
  require "tiny_tds"
10
10
  uri = parse_connection(connection_string)
11
11
  @connection = TinyTds::Client.new(
12
12
  host: uri[:host],
13
13
  port: uri[:port] || 1433,
14
- username: uri[:username],
15
- password: uri[:password],
14
+ username: username || uri[:username],
15
+ password: password || uri[:password],
16
16
  database: uri[:database]
17
17
  )
18
18
  end
@@ -5,14 +5,14 @@ module Tina4
5
5
  class MysqlDriver
6
6
  attr_reader :connection
7
7
 
8
- def connect(connection_string)
8
+ def connect(connection_string, username: nil, password: nil)
9
9
  require "mysql2"
10
- uri = URI.parse(connection_string.sub(/^mysql:\/\//, "mysql2://"))
10
+ uri = URI.parse(connection_string)
11
11
  @connection = Mysql2::Client.new(
12
12
  host: uri.host || "localhost",
13
13
  port: uri.port || 3306,
14
- username: uri.user,
15
- password: uri.password,
14
+ username: username || uri.user,
15
+ password: password || uri.password,
16
16
  database: uri.path&.sub("/", "")
17
17
  )
18
18
  end
@@ -5,9 +5,16 @@ module Tina4
5
5
  class PostgresDriver
6
6
  attr_reader :connection
7
7
 
8
- def connect(connection_string)
8
+ def connect(connection_string, username: nil, password: nil)
9
9
  require "pg"
10
- @connection = PG.connect(connection_string)
10
+ url = connection_string
11
+ if username || password
12
+ uri = URI.parse(url)
13
+ uri.user = username if username
14
+ uri.password = password if password
15
+ url = uri.to_s
16
+ end
17
+ @connection = PG.connect(url)
11
18
  end
12
19
 
13
20
  def close
@@ -5,7 +5,7 @@ module Tina4
5
5
  class SqliteDriver
6
6
  attr_reader :connection
7
7
 
8
- def connect(connection_string)
8
+ def connect(connection_string, username: nil, password: nil)
9
9
  require "sqlite3"
10
10
  db_path = connection_string.sub(/^sqlite:\/\//, "").sub(/^sqlite:/, "")
11
11
  @connection = SQLite3::Database.new(db_path)
data/lib/tina4/env.rb CHANGED
@@ -7,12 +7,21 @@ module Tina4
7
7
  "PROJECT_NAME" => "Tina4 Ruby Project",
8
8
  "VERSION" => "1.0.0",
9
9
  "TINA4_LANGUAGE" => "en",
10
- "TINA4_DEBUG_LEVEL" => "[TINA4_LOG_ALL]",
10
+ "TINA4_DEBUG" => "true",
11
+ "TINA4_LOG_LEVEL" => "[TINA4_LOG_ALL]",
11
12
  "SECRET" => "tina4-secret-change-me"
12
13
  }.freeze
13
14
 
15
+ # Check if a value is truthy for env boolean checks.
16
+ #
17
+ # Accepts: "true", "True", "TRUE", "1", "yes", "Yes", "YES", "on", "On", "ON".
18
+ # Everything else is falsy (including empty string, nil, not set).
19
+ def self.truthy?(val)
20
+ %w[true 1 yes on].include?(val.to_s.strip.downcase)
21
+ end
22
+
14
23
  class << self
15
- def load(root_dir = Dir.pwd)
24
+ def load_env(root_dir = Dir.pwd)
16
25
  env_file = resolve_env_file(root_dir)
17
26
  unless File.exist?(env_file)
18
27
  create_default_env(env_file)
@@ -20,6 +29,35 @@ module Tina4
20
29
  parse_env_file(env_file)
21
30
  end
22
31
 
32
+ # Get an env var value, with optional default
33
+ def get_env(key, default = nil)
34
+ ENV[key.to_s] || default
35
+ end
36
+
37
+ # Check if an env var exists
38
+ def has_env?(key)
39
+ ENV.key?(key.to_s)
40
+ end
41
+
42
+ # Return all current ENV vars as a hash
43
+ def all_env
44
+ ENV.to_h
45
+ end
46
+
47
+ # Raise if any of the given keys are missing from ENV
48
+ def require_env!(*keys)
49
+ missing = keys.map(&:to_s).reject { |k| ENV.key?(k) }
50
+ unless missing.empty?
51
+ raise KeyError, "Missing required env vars: #{missing.join(', ')}"
52
+ end
53
+ end
54
+
55
+ # Reset: clear all env vars that were loaded (restore to process defaults)
56
+ def reset_env
57
+ @loaded_keys&.each { |k| ENV.delete(k) }
58
+ @loaded_keys = []
59
+ end
60
+
23
61
  private
24
62
 
25
63
  def resolve_env_file(root_dir)
@@ -47,6 +85,8 @@ module Tina4
47
85
  key = match[1]
48
86
  value = match[2].gsub(/["']\z/, "")
49
87
  ENV[key] ||= value
88
+ @loaded_keys ||= []
89
+ @loaded_keys << key
50
90
  end
51
91
  end
52
92
  end
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Tina4 Debug — Rich error overlay for development mode.
4
+ #
5
+ # Renders a professional, syntax-highlighted HTML error page when an unhandled
6
+ # exception occurs in a route handler.
7
+ #
8
+ # begin
9
+ # handler.call(request, response)
10
+ # rescue => e
11
+ # Tina4::ErrorOverlay.render(e, request: env)
12
+ # end
13
+ #
14
+ # Only activate when TINA4_DEBUG is true.
15
+ # In production, call Tina4::ErrorOverlay.render_production instead.
16
+
17
+ module Tina4
18
+ module ErrorOverlay
19
+ # ── Colour palette (Catppuccin Mocha) ──────────────────────────────
20
+ BG = "#1e1e2e"
21
+ SURFACE = "#313244"
22
+ OVERLAY_COLOR = "#45475a"
23
+ TEXT_COLOR = "#cdd6f4"
24
+ SUBTEXT = "#a6adc8"
25
+ RED = "#f38ba8"
26
+ YELLOW = "#f9e2af"
27
+ BLUE = "#89b4fa"
28
+ GREEN = "#a6e3a1"
29
+ LAVENDER = "#b4befe"
30
+ PEACH = "#fab387"
31
+ ERROR_LINE_BG = "rgba(243,139,168,0.15)"
32
+
33
+ CONTEXT_LINES = 7
34
+
35
+ class << self
36
+ # Render a rich HTML error overlay.
37
+ #
38
+ # @param exception [Exception] the caught exception
39
+ # @param request [Hash, nil] optional request details (Rack env or custom hash)
40
+ # @return [String] complete HTML page
41
+ def render(exception, request: nil)
42
+ exc_type = exception.class.name
43
+ exc_msg = exception.message
44
+
45
+ # ── Stack trace ──
46
+ frames_html = +""
47
+ backtrace = exception.backtrace || []
48
+ backtrace.each do |line|
49
+ file, lineno, method = parse_backtrace_line(line)
50
+ frames_html << format_frame(file, lineno, method)
51
+ end
52
+
53
+ # ── Request info ──
54
+ request_pairs = []
55
+ if request.is_a?(Hash)
56
+ request.each do |k, v|
57
+ key = k.to_s
58
+ if v.is_a?(Hash)
59
+ v.each { |hk, hv| request_pairs << ["#{key}.#{hk}", hv.to_s] }
60
+ elsif key.start_with?("HTTP_") || %w[REQUEST_METHOD REQUEST_URI SERVER_PROTOCOL
61
+ REMOTE_ADDR SERVER_PORT QUERY_STRING CONTENT_TYPE CONTENT_LENGTH
62
+ method url path].include?(key)
63
+ request_pairs << [key, v.to_s]
64
+ end
65
+ end
66
+ end
67
+ request_section = request_pairs.empty? ? "" : collapsible("Request Details", table(request_pairs))
68
+
69
+ # ── Environment ──
70
+ env_pairs = [
71
+ ["Framework", "Tina4 Ruby"],
72
+ ["Version", defined?(Tina4::VERSION) ? Tina4::VERSION : "unknown"],
73
+ ["Ruby", RUBY_VERSION],
74
+ ["Platform", RUBY_PLATFORM],
75
+ ["Debug", ENV.fetch("TINA4_DEBUG", "false")],
76
+ ["Log Level", ENV.fetch("TINA4_LOG_LEVEL", "ERROR")]
77
+ ]
78
+ env_section = collapsible("Environment", table(env_pairs))
79
+ stack_section = collapsible("Stack Trace", frames_html, open_by_default: true)
80
+
81
+ <<~HTML
82
+ <!DOCTYPE html>
83
+ <html lang="en">
84
+ <head>
85
+ <meta charset="utf-8">
86
+ <meta name="viewport" content="width=device-width,initial-scale=1">
87
+ <title>Tina4 Error — #{esc(exc_type)}</title>
88
+ <style>
89
+ *{margin:0;padding:0;box-sizing:border-box;}
90
+ body{background:#{BG};color:#{TEXT_COLOR};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:24px;line-height:1.5;}
91
+ </style>
92
+ </head>
93
+ <body>
94
+ <div style="max-width:960px;margin:0 auto;">
95
+ <div style="margin-bottom:24px;">
96
+ <div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
97
+ <span style="background:#{RED};color:#{BG};padding:4px 12px;border-radius:4px;font-weight:700;font-size:13px;text-transform:uppercase;">Error</span>
98
+ <span style="color:#{SUBTEXT};font-size:14px;">Tina4 Debug Overlay</span>
99
+ </div>
100
+ <h1 style="color:#{RED};font-size:28px;font-weight:700;margin-bottom:8px;">#{esc(exc_type)}</h1>
101
+ <p style="color:#{TEXT_COLOR};font-size:18px;font-family:'SF Mono','Fira Code','Consolas',monospace;background:#{SURFACE};padding:12px 16px;border-radius:6px;border-left:4px solid #{RED};">#{esc(exc_msg)}</p>
102
+ </div>
103
+ #{stack_section}
104
+ #{request_section}
105
+ #{env_section}
106
+ <div style="margin-top:32px;padding-top:16px;border-top:1px solid #{OVERLAY_COLOR};color:#{SUBTEXT};font-size:12px;">
107
+ Tina4 Debug Overlay &mdash; This page is only shown in debug mode. Set TINA4_DEBUG=false in production.
108
+ </div>
109
+ </div>
110
+ </body>
111
+ </html>
112
+ HTML
113
+ end
114
+
115
+ # Render a safe, generic error page for production.
116
+ def render_production(status_code: 500, message: "Internal Server Error", path: "")
117
+ # Determine color based on status code
118
+ code_color = case status_code
119
+ when 403 then "#f59e0b"
120
+ when 404 then "#3b82f6"
121
+ else "#ef4444"
122
+ end
123
+
124
+ <<~HTML
125
+ <!DOCTYPE html>
126
+ <html lang="en">
127
+ <head>
128
+ <meta charset="utf-8">
129
+ <meta name="viewport" content="width=device-width, initial-scale=1">
130
+ <title>#{status_code} — #{esc(message)}</title>
131
+ <style>
132
+ * { box-sizing: border-box; margin: 0; padding: 0; }
133
+ body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
134
+ .error-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; text-align: center; max-width: 520px; width: 90%; }
135
+ .error-code { font-size: 8rem; font-weight: 900; color: #{code_color}; opacity: 0.6; line-height: 1; margin-bottom: 0.5rem; }
136
+ .error-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.75rem; }
137
+ .error-msg { color: #94a3b8; font-size: 1rem; margin-bottom: 1.5rem; line-height: 1.5; }
138
+ .error-path { font-family: 'SF Mono', monospace; background: #0f172a; color: #{code_color}; padding: 0.5rem 1rem; border-radius: 0.5rem; font-size: 0.85rem; word-break: break-all; margin-bottom: 1.5rem; display: inline-block; }
139
+ .error-home { display: inline-block; padding: 0.6rem 2rem; background: #3b82f6; color: #fff; text-decoration: none; border-radius: 0.5rem; font-size: 0.9rem; font-weight: 600; }
140
+ .error-home:hover { opacity: 0.9; }
141
+ .logo { font-size: 1.5rem; margin-bottom: 1rem; opacity: 0.5; }
142
+ </style>
143
+ </head>
144
+ <body>
145
+ <div class="error-card">
146
+ <div class="error-code">#{status_code}</div>
147
+ <div class="error-title">#{esc(message)}</div>
148
+ <div class="error-msg">Something went wrong while processing your request.</div>
149
+ #{path.to_s.empty? ? '' : "<div class=\"error-path\">#{esc(path)}</div><br>"}
150
+ <a href="/" class="error-home">Go Home</a>
151
+ </div>
152
+ </body>
153
+ </html>
154
+ HTML
155
+ end
156
+
157
+ # Return true if TINA4_DEBUG is enabled.
158
+ def debug_mode?
159
+ Tina4::Env.truthy?(ENV.fetch("TINA4_DEBUG", ""))
160
+ end
161
+
162
+ private
163
+
164
+ def esc(text)
165
+ text.to_s
166
+ .gsub("&", "&amp;")
167
+ .gsub("<", "&lt;")
168
+ .gsub(">", "&gt;")
169
+ .gsub('"', "&quot;")
170
+ .gsub("'", "&#39;")
171
+ end
172
+
173
+ def parse_backtrace_line(line)
174
+ if line =~ /\A(.+):(\d+):in [`'](.+)'\z/
175
+ [$1, $2.to_i, $3]
176
+ elsif line =~ /\A(.+):(\d+)\z/
177
+ [$1, $2.to_i, "{main}"]
178
+ else
179
+ [line, 0, "{unknown}"]
180
+ end
181
+ end
182
+
183
+ def read_source_lines(filename, lineno)
184
+ return [] unless filename && lineno.positive? && File.file?(filename) && File.readable?(filename)
185
+
186
+ all_lines = File.readlines(filename, chomp: true)
187
+ start_idx = [0, lineno - CONTEXT_LINES - 1].max
188
+ end_idx = [all_lines.length, lineno + CONTEXT_LINES].min
189
+ (start_idx...end_idx).map do |i|
190
+ num = i + 1
191
+ [num, all_lines[i] || "", num == lineno]
192
+ end
193
+ rescue StandardError
194
+ []
195
+ end
196
+
197
+ def format_source_block(filename, lineno)
198
+ lines = read_source_lines(filename, lineno)
199
+ return "" if lines.empty?
200
+
201
+ rows = lines.map do |num, text, is_error|
202
+ bg = is_error ? "background:#{ERROR_LINE_BG};" : ""
203
+ marker = is_error ? "&#x25b6;" : " "
204
+ "<div style=\"#{bg}display:flex;padding:1px 0;\">" \
205
+ "<span style=\"color:#{YELLOW};min-width:3.5em;text-align:right;padding-right:1em;user-select:none;\">#{num}</span>" \
206
+ "<span style=\"color:#{RED};width:1.2em;user-select:none;\">#{marker}</span>" \
207
+ "<span style=\"color:#{TEXT_COLOR};white-space:pre-wrap;tab-size:4;\">#{esc(text)}</span>" \
208
+ "</div>"
209
+ end.join("\n")
210
+
211
+ "<div style=\"background:#{SURFACE};border-radius:6px;padding:12px;overflow-x:auto;" \
212
+ "font-family:'SF Mono','Fira Code','Consolas',monospace;font-size:13px;line-height:1.6;\">" \
213
+ "#{rows}</div>"
214
+ end
215
+
216
+ def format_frame(filename, lineno, func_name)
217
+ source = (filename && lineno.positive?) ? format_source_block(filename, lineno) : ""
218
+ "<div style=\"margin-bottom:16px;\">" \
219
+ "<div style=\"margin-bottom:4px;\">" \
220
+ "<span style=\"color:#{BLUE};\">#{esc(filename.to_s)}</span>" \
221
+ "<span style=\"color:#{SUBTEXT};\"> : </span>" \
222
+ "<span style=\"color:#{YELLOW};\">#{lineno}</span>" \
223
+ "<span style=\"color:#{SUBTEXT};\"> in </span>" \
224
+ "<span style=\"color:#{GREEN};\">#{esc(func_name.to_s)}</span>" \
225
+ "</div>" \
226
+ "#{source}" \
227
+ "</div>"
228
+ end
229
+
230
+ def collapsible(title, content, open_by_default: false)
231
+ open_attr = open_by_default ? " open" : ""
232
+ "<details style=\"margin-top:16px;\"#{open_attr}>" \
233
+ "<summary style=\"cursor:pointer;color:#{LAVENDER};font-weight:600;font-size:15px;" \
234
+ "padding:8px 0;user-select:none;\">#{esc(title)}</summary>" \
235
+ "<div style=\"padding:8px 0;\">#{content}</div>" \
236
+ "</details>"
237
+ end
238
+
239
+ def table(pairs)
240
+ return "<span style=\"color:#{SUBTEXT};\">None</span>" if pairs.empty?
241
+
242
+ rows = pairs.map do |key, val|
243
+ "<tr>" \
244
+ "<td style=\"color:#{PEACH};padding:4px 16px 4px 0;vertical-align:top;white-space:nowrap;\">#{esc(key)}</td>" \
245
+ "<td style=\"color:#{TEXT_COLOR};padding:4px 0;word-break:break-all;\">#{esc(val)}</td>" \
246
+ "</tr>"
247
+ end.join
248
+ "<table style=\"border-collapse:collapse;width:100%;\">#{rows}</table>"
249
+ end
250
+ end
251
+ end
252
+ end