tina4ruby 3.10.91 → 3.10.92

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5a737d8b571f74eceea00422b681722ba711019f59c1fae14a8cc618bce5aa51
4
- data.tar.gz: 6834b22997597eeb49064e17f56a6fd00280f9dc57c699a413402ff4fcbcc1c4
3
+ metadata.gz: 872c1e0d5dbae634259ad2772d69630f7160784967c03cb0d5ec68da3b122673
4
+ data.tar.gz: c08ab7a877b583e3141dc523125bc236e6e548f2acc5b757114074b3547b27c2
5
5
  SHA512:
6
- metadata.gz: d85adcf4bfc4ca50956912e2fe926fc9cbe2833c0cb2a134d376ad63d13066d6c533e5418ff9aad40065a5c2a6313145d2a3da574f0a494fb86f906ca85545f5
7
- data.tar.gz: 93a399a93f6c98ce34e0c521c33221cf5ac6d69238c0103099e7ecaa0f9c3c9c1d6ae2db48f0a2e12585e7d7700545a045c1e2122152657bdb753f518c4efcb5
6
+ metadata.gz: 2657a571cda5d98dcd0e46a0db670b6bcb027d23376d8b198413ad3b9c2f8bc56bec600fff1f77d71a0cf33ca51e632c3f4923f3caf67a8b40fd80125432f3b3
7
+ data.tar.gz: ecca10a04db72af6d1e7f42d60c1509c9d98ccb516d2635ecfa78bc48d2def7ac63dbb14092fa27e9c668fea1f2c57982c280210e56f755a6d9ccc122f326747
data/lib/tina4/ai.rb CHANGED
@@ -29,7 +29,7 @@ module Tina4
29
29
  # @param root [String] project root directory
30
30
  # @param tool [Hash] tool entry from AI_TOOLS
31
31
  # @return [Boolean]
32
- def installed?(root, tool)
32
+ def is_installed(root, tool)
33
33
  File.exist?(File.join(File.expand_path(root), tool[:context_file]))
34
34
  end
35
35
 
@@ -44,7 +44,7 @@ module Tina4
44
44
 
45
45
  puts "\n Tina4 AI Context Installer\n"
46
46
  AI_TOOLS.each_with_index do |tool, i|
47
- marker = installed?(root, tool) ? " #{green}[installed]#{reset}" : ""
47
+ marker = is_installed(root, tool) ? " #{green}[installed]#{reset}" : ""
48
48
  puts format(" %d. %-20s %s%s", i + 1, tool[:description], tool[:context_file], marker)
49
49
  end
50
50
 
@@ -194,10 +194,37 @@ module Tina4
194
194
  }, swagger_meta: { summary: "Delete #{pretty_name}", tags: [table.to_s] })
195
195
  end
196
196
 
197
+ # Discover ORM model classes from a directory and register them.
198
+ #
199
+ # @param models_dir [String] directory to scan (default "src/orm")
200
+ # @param prefix [String] URL prefix for generated routes (default "/api")
201
+ # @return [Array<String>] list of discovered model class names
202
+ def discover(models_dir = "src/orm", prefix: "/api")
203
+ discovered = []
204
+ return discovered unless Dir.exist?(models_dir)
205
+
206
+ Dir.glob(File.join(models_dir, "*.rb")).each do |file|
207
+ require_relative File.expand_path(file)
208
+ end
209
+
210
+ # Find all ORM subclasses that have a table_name
211
+ ObjectSpace.each_object(Class).select { |c| c < Tina4::ORM rescue false }.each do |klass|
212
+ next unless klass.respond_to?(:table_name) && klass.table_name
213
+ register(klass)
214
+ discovered << klass.name
215
+ end
216
+
217
+ generate_routes(prefix: prefix) unless discovered.empty?
218
+ discovered
219
+ end
220
+
197
221
  def clear!
198
222
  @models = []
199
223
  end
200
224
 
225
+ # Alias for parity with other frameworks
226
+ alias_method :clear, :clear!
227
+
201
228
  private
202
229
 
203
230
  # Parse sort parameter: "-name,created_at" => "name DESC, created_at ASC"
data/lib/tina4/cli.rb CHANGED
@@ -204,7 +204,7 @@ module Tina4
204
204
 
205
205
  app = Tina4::RackApp.new(root_dir: root_dir)
206
206
 
207
- is_debug = Tina4::Env.truthy?(ENV["TINA4_DEBUG"])
207
+ is_debug = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
208
208
 
209
209
  # Use Puma only when explicitly requested via --production flag
210
210
  # WEBrick is used for development (supports dev toolbar/reload)
@@ -122,6 +122,7 @@ module Tina4
122
122
  def initialize
123
123
  @mutex = Mutex.new
124
124
  @errors = nil # lazy-loaded
125
+ @registered = false
125
126
  @store_path = File.join(
126
127
  Dir.tmpdir,
127
128
  "tina4_dev_errors_#{Digest::MD5.hexdigest(Dir.pwd)}.json"
@@ -232,10 +233,26 @@ module Tina4
232
233
  end
233
234
  end
234
235
 
236
+ # Register Ruby error handlers to feed the tracker.
237
+ # Installs an at_exit hook that captures unhandled exceptions.
238
+ # Safe to call multiple times — only registers once.
239
+ def register
240
+ return if @registered
241
+
242
+ @registered = true
243
+ tracker = self
244
+ at_exit do
245
+ if (exc = $!) && !exc.is_a?(SystemExit)
246
+ tracker.capture_exception(exc)
247
+ end
248
+ end
249
+ end
250
+
235
251
  # Reset (for testing).
236
252
  def reset!
237
253
  @mutex.synchronize do
238
254
  @errors = {}
255
+ @registered = false
239
256
  File.delete(@store_path) if File.exist?(@store_path)
240
257
  end
241
258
  end
@@ -294,7 +311,7 @@ module Tina4
294
311
  end
295
312
 
296
313
  def enabled?
297
- Tina4::Env.truthy?(ENV["TINA4_DEBUG"])
314
+ Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
298
315
  end
299
316
 
300
317
  # Handle a /__dev request; returns [status, headers, body] or nil if not a dev path
data/lib/tina4/env.rb CHANGED
@@ -16,7 +16,7 @@ module Tina4
16
16
  #
17
17
  # Accepts: "true", "True", "TRUE", "1", "yes", "Yes", "YES", "on", "On", "ON".
18
18
  # Everything else is falsy (including empty string, nil, not set).
19
- def self.truthy?(val)
19
+ def self.is_truthy(val)
20
20
  %w[true 1 yes on].include?(val.to_s.strip.downcase)
21
21
  end
22
22
 
@@ -8,11 +8,11 @@
8
8
  # begin
9
9
  # handler.call(request, response)
10
10
  # rescue => e
11
- # Tina4::ErrorOverlay.render(e, request: env)
11
+ # Tina4::ErrorOverlay.render_error_overlay(e, request: env)
12
12
  # end
13
13
  #
14
14
  # Only activate when TINA4_DEBUG is true.
15
- # In production, call Tina4::ErrorOverlay.render_production instead.
15
+ # In production, call Tina4::ErrorOverlay.render_production_error instead.
16
16
 
17
17
  module Tina4
18
18
  module ErrorOverlay
@@ -38,7 +38,7 @@ module Tina4
38
38
  # @param exception [Exception] the caught exception
39
39
  # @param request [Hash, nil] optional request details (Rack env or custom hash)
40
40
  # @return [String] complete HTML page
41
- def render(exception, request: nil)
41
+ def render_error_overlay(exception, request: nil)
42
42
  exc_type = exception.class.name
43
43
  exc_msg = exception.message
44
44
 
@@ -113,7 +113,7 @@ module Tina4
113
113
  end
114
114
 
115
115
  # Render a safe, generic error page for production.
116
- def render_production(status_code: 500, message: "Internal Server Error", path: "")
116
+ def render_production_error(status_code: 500, message: "Internal Server Error", path: "")
117
117
  # Determine color based on status code
118
118
  code_color = case status_code
119
119
  when 403 then "#f59e0b"
@@ -155,8 +155,8 @@ module Tina4
155
155
  end
156
156
 
157
157
  # Return true if TINA4_DEBUG is enabled.
158
- def debug_mode?
159
- Tina4::Env.truthy?(ENV.fetch("TINA4_DEBUG", ""))
158
+ def is_debug_mode
159
+ Tina4::Env.is_truthy(ENV.fetch("TINA4_DEBUG", ""))
160
160
  end
161
161
 
162
162
  private
@@ -145,4 +145,26 @@ module Tina4
145
145
  def self.html_helpers
146
146
  HtmlHelpers
147
147
  end
148
+
149
+ # Inject _div(), _p(), _a(), etc. helper methods into the given namespace (hash or object).
150
+ #
151
+ # Usage:
152
+ # h = {}
153
+ # Tina4.add_html_helpers(h)
154
+ # h[:_div].call({ class: "card" }, h[:_p].call("Hello"))
155
+ #
156
+ def self.add_html_helpers(namespace)
157
+ helper = Object.new.extend(HtmlHelpers)
158
+
159
+ HtmlElement::HTML_TAGS.each do |tag|
160
+ name = "_#{tag}"
161
+ fn = helper.method(name.to_sym)
162
+
163
+ if namespace.is_a?(Hash)
164
+ namespace[name.to_sym] = fn
165
+ else
166
+ namespace.define_singleton_method(name.to_sym, &fn)
167
+ end
168
+ end
169
+ end
148
170
  end
data/lib/tina4/log.rb CHANGED
@@ -30,7 +30,7 @@ module Tina4
30
30
  class << self
31
31
  attr_reader :log_dir
32
32
 
33
- def setup(root_dir = Dir.pwd)
33
+ def configure(root_dir = Dir.pwd)
34
34
  @log_dir = File.join(root_dir, "logs")
35
35
  FileUtils.mkdir_p(@log_dir)
36
36
 
@@ -55,7 +55,7 @@ module Tina4
55
55
  @mutex.synchronize { @request_id = nil }
56
56
  end
57
57
 
58
- def request_id
58
+ def get_request_id
59
59
  @mutex.synchronize { @request_id }
60
60
  end
61
61
 
@@ -87,7 +87,7 @@ module Tina4
87
87
  end
88
88
 
89
89
  def log(level, message, context = {})
90
- setup unless @initialized
90
+ configure unless @initialized
91
91
  @current_context = context.is_a?(Hash) ? context : {}
92
92
 
93
93
  formatted = format_line(level, message)
@@ -135,7 +135,7 @@ module Tina4
135
135
  def format_line(level, message)
136
136
  level_str = severity_to_level(level)
137
137
  ts = utc_timestamp
138
- rid = request_id
138
+ rid = get_request_id
139
139
  rid_str = rid ? " [#{rid}]" : ""
140
140
  ctx = @current_context && !@current_context.empty? ? " #{JSON.generate(@current_context)}" : ""
141
141
  "#{ts} [#{level_str.ljust(7)}]#{rid_str} #{message}#{ctx}"
@@ -148,7 +148,7 @@ module Tina4
148
148
  level: level_str,
149
149
  message: message
150
150
  }
151
- rid = request_id
151
+ rid = get_request_id
152
152
  entry[:request_id] = rid if rid
153
153
  entry[:context] = @current_context if @current_context && !@current_context.empty?
154
154
  JSON.generate(entry)
data/lib/tina4/mcp.rb CHANGED
@@ -215,7 +215,7 @@ module Tina4
215
215
  end
216
216
 
217
217
  # Register HTTP routes for this MCP server on the Tina4 router.
218
- def register_routes
218
+ def register_routes(router = nil)
219
219
  server = self
220
220
  msg_path = "#{@path}/message"
221
221
  sse_path = "#{@path}/sse"
@@ -194,6 +194,31 @@ module Tina4
194
194
  []
195
195
  end
196
196
 
197
+ # Mark a message as read (set \Seen flag).
198
+ #
199
+ # @param uid [String, Integer] message UID
200
+ # @param folder [String] IMAP folder name
201
+ def mark_read(uid, folder: "INBOX")
202
+ imap_connect do |imap|
203
+ imap.select(folder)
204
+ imap.uid_store(uid.to_i, "+FLAGS", [:Seen])
205
+ end
206
+ rescue => e
207
+ Tina4::Log.error("IMAP mark_read failed: #{e.message}")
208
+ end
209
+
210
+ # Test IMAP connectivity without reading messages.
211
+ #
212
+ # @return [Hash] { success: Boolean, message: String }
213
+ def test_imap_connection
214
+ imap_connect do |_imap|
215
+ # Connection succeeded
216
+ end
217
+ { success: true, message: "Connected to #{@imap_host}:#{@imap_port}" }
218
+ rescue => e
219
+ { success: false, message: "IMAP connection failed: #{e.message}" }
220
+ end
221
+
197
222
  private
198
223
 
199
224
  # ── SMTP helpers ─────────────────────────────────────────────────────
@@ -515,7 +540,7 @@ module Tina4
515
540
  # Factory: returns a DevMailbox-intercepting messenger in dev mode,
516
541
  # or a real Messenger in production.
517
542
  def self.create_messenger(**options)
518
- dev_mode = Tina4::Env.truthy?(ENV["TINA4_DEBUG"])
543
+ dev_mode = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
519
544
 
520
545
  smtp_configured = (ENV["TINA4_MAIL_HOST"] && !ENV["TINA4_MAIL_HOST"].empty?) ||
521
546
  (ENV["SMTP_HOST"] && !ENV["SMTP_HOST"].empty?)
@@ -156,6 +156,12 @@ module Tina4
156
156
  }
157
157
  end
158
158
 
159
+ def is_preflight(request)
160
+ request.method&.upcase == "OPTIONS" &&
161
+ request.headers["origin"] &&
162
+ request.headers["access-control-request-method"]
163
+ end
164
+
159
165
  def resolve_origin(request, config)
160
166
  request_origin = request.headers["origin"] || request.headers["referer"]
161
167
 
@@ -219,6 +225,28 @@ module Tina4
219
225
  [request, response]
220
226
  end
221
227
 
228
+ def check(ip)
229
+ limit = (ENV["TINA4_RATE_LIMIT"] || 100).to_i
230
+ window = (ENV["TINA4_RATE_WINDOW"] || 60).to_i
231
+ now = Time.now
232
+
233
+ @mutex.synchronize do
234
+ @store[ip] ||= []
235
+ entries = @store[ip]
236
+ entries.reject! { |t| t < now - window }
237
+
238
+ remaining = [limit - entries.length, 0].max
239
+ reset_at = entries.empty? ? window : (entries.first + window - now).ceil
240
+
241
+ if entries.length >= limit
242
+ return [false, { limit: limit, remaining: 0, reset: reset_at, window: window }]
243
+ end
244
+
245
+ entries << now
246
+ [true, { limit: limit, remaining: remaining - 1, reset: window, window: window }]
247
+ end
248
+ end
249
+
222
250
  # Allow resetting state (useful in tests)
223
251
  def reset!
224
252
  @mutex.synchronize { @store.clear }
data/lib/tina4/orm.rb CHANGED
@@ -124,7 +124,7 @@ module Tina4
124
124
  #
125
125
  # @return [Tina4::QueryBuilder]
126
126
  def query # -> QueryBuilder
127
- QueryBuilder.from(table_name, db: db)
127
+ QueryBuilder.from_table(table_name, db: db)
128
128
  end
129
129
 
130
130
  # Find records by filter dict. Always returns an array.
@@ -732,7 +732,7 @@ module Tina4
732
732
 
733
733
  # ── Imperative relationship methods (ad-hoc, like Python/PHP/Node) ──
734
734
 
735
- def query_has_one(related_class, foreign_key: nil)
735
+ def has_one(related_class, foreign_key: nil)
736
736
  pk = self.class.primary_key_field || :id
737
737
  pk_value = __send__(pk)
738
738
  return nil unless pk_value
@@ -744,7 +744,7 @@ module Tina4
744
744
  result ? related_class.from_hash(result) : nil
745
745
  end
746
746
 
747
- def query_has_many(related_class, foreign_key: nil, limit: 100, offset: 0)
747
+ def has_many(related_class, foreign_key: nil, limit: 100, offset: 0)
748
748
  pk = self.class.primary_key_field || :id
749
749
  pk_value = __send__(pk)
750
750
  return [] unless pk_value
@@ -757,18 +757,12 @@ module Tina4
757
757
  results.map { |row| related_class.from_hash(row) }
758
758
  end
759
759
 
760
- def query_belongs_to(related_class, foreign_key: nil)
760
+ def belongs_to(related_class, foreign_key: nil)
761
761
  fk = foreign_key || "#{related_class.name.split('::').last.downcase}_id"
762
762
  fk_value = respond_to?(fk.to_sym) ? __send__(fk.to_sym) : nil
763
763
  return nil unless fk_value
764
764
 
765
765
  related_class.find_by_id(fk_value)
766
766
  end
767
-
768
- # Instance-level aliases matching Python/PHP/Node.js naming
769
- # These are imperative relationship queries (not class-level declarations)
770
- alias imperative_has_one query_has_one
771
- alias imperative_has_many query_has_many
772
- alias imperative_belongs_to query_belongs_to
773
767
  end
774
768
  end
@@ -5,7 +5,7 @@ module Tina4
5
5
  #
6
6
  # Usage:
7
7
  # # Standalone
8
- # result = Tina4::QueryBuilder.from("users", db: db)
8
+ # result = Tina4::QueryBuilder.from_table("users", db: db)
9
9
  # .select("id", "name")
10
10
  # .where("active = ?", [1])
11
11
  # .order_by("name ASC")
@@ -39,7 +39,7 @@ module Tina4
39
39
  # @param table_name [String] The database table name.
40
40
  # @param db [Object, nil] Optional database connection.
41
41
  # @return [QueryBuilder]
42
- def self.from(table_name, db: nil)
42
+ def self.from_table(table_name, db: nil)
43
43
  new(table_name, db: db)
44
44
  end
45
45
 
@@ -589,7 +589,7 @@ module Tina4
589
589
  Tina4::Log.error(error.backtrace&.first(10)&.join("\n"))
590
590
  if dev_mode?
591
591
  # Rich error overlay with stack trace, source context, and line numbers
592
- body = Tina4::ErrorOverlay.render(error, request: env)
592
+ body = Tina4::ErrorOverlay.render_error_overlay(error, request: env)
593
593
  else
594
594
  body = Tina4::Template.render_error(500, {
595
595
  "error_message" => "#{error.message}\n#{error.backtrace&.first(10)&.join("\n")}",
@@ -600,7 +600,7 @@ module Tina4
600
600
  end
601
601
 
602
602
  def dev_mode?
603
- Tina4::Env.truthy?(ENV["TINA4_DEBUG"])
603
+ Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
604
604
  end
605
605
 
606
606
  def websocket_upgrade?(env)
@@ -652,7 +652,7 @@ module Tina4
652
652
  method = request_info[:method]
653
653
  path = request_info[:path]
654
654
  matched_pattern = request_info[:matched_pattern]
655
- request_id = Tina4::Log.request_id || "-"
655
+ request_id = Tina4::Log.get_request_id || "-"
656
656
  route_count = Tina4::Router.routes.length
657
657
 
658
658
  ai_badge = ai_port ? '<span style="background:#7c3aed;color:#fff;font-size:10px;padding:1px 6px;border-radius:3px;font-weight:bold;">AI PORT</span>' : ""
@@ -86,8 +86,15 @@ module Tina4
86
86
  true
87
87
  end
88
88
 
89
+ # Standardized middleware hook — enforces rate limiting before the route handler.
90
+ def before_rate_limit(request, response)
91
+ ip = request.respond_to?(:ip) ? request.ip : "unknown"
92
+ apply(ip, response)
93
+ [request, response]
94
+ end
95
+
89
96
  # Reset tracking for a specific IP (useful for testing)
90
- def reset!(ip = nil)
97
+ def reset(ip = nil)
91
98
  @mutex.synchronize do
92
99
  if ip
93
100
  @store.delete(ip)
data/lib/tina4/request.rb CHANGED
@@ -126,8 +126,8 @@ module Tina4
126
126
  end
127
127
 
128
128
  # Look up a param by symbol or string key (indifferent access shortcut).
129
- def param(key)
130
- params[key.to_s] || params[key.to_sym]
129
+ def param(key, default = nil)
130
+ params[key.to_s] || params[key.to_sym] || default
131
131
  end
132
132
 
133
133
  def [](key)
@@ -3,6 +3,37 @@ require "json"
3
3
  require "uri"
4
4
 
5
5
  module Tina4
6
+ # ---------------------------------------------------------------------------
7
+ # Global Frond template engine registry
8
+ # ---------------------------------------------------------------------------
9
+ @_global_frond = nil
10
+ @_framework_frond = nil
11
+
12
+ # Return the global Frond engine, creating a default if needed.
13
+ def self.get_frond
14
+ @_global_frond ||= Tina4::Frond.new(template_dir: "src/templates")
15
+ end
16
+
17
+ # Return the singleton Frond engine for built-in framework templates.
18
+ def self.get_framework_frond
19
+ framework_dir = ::File.join(::File.dirname(__FILE__), "templates")
20
+ if @_framework_frond.nil? && ::File.directory?(framework_dir)
21
+ @_framework_frond = Tina4::Frond.new(template_dir: framework_dir)
22
+ end
23
+ # Sync custom filters/globals from the user engine
24
+ if @_framework_frond
25
+ user_engine = get_frond
26
+ @_framework_frond.instance_variable_get(:@filters).merge!(user_engine.instance_variable_get(:@filters))
27
+ @_framework_frond.instance_variable_get(:@globals).merge!(user_engine.instance_variable_get(:@globals))
28
+ end
29
+ @_framework_frond
30
+ end
31
+
32
+ # Register a pre-configured Frond engine for response.render().
33
+ def self.set_frond(engine)
34
+ @_global_frond = engine
35
+ end
36
+
6
37
  class Response
7
38
  MIME_TYPES = {
8
39
  ".html" => "text/html", ".htm" => "text/html",
@@ -123,12 +154,38 @@ module Tina4
123
154
  def render(template_path, data = {}, status: 200, template_dir: nil)
124
155
  @status_code = status
125
156
  @headers["content-type"] = HTML_CONTENT_TYPE
126
- if template_dir
127
- frond = Tina4::Frond.new(template_dir: template_dir)
128
- @body = frond.render(template_path, data)
129
- else
130
- @body = Tina4::Template.render(template_path, data)
157
+
158
+ engine = template_dir ? Tina4::Frond.new(template_dir: template_dir) : Tina4.get_frond
159
+
160
+ # Try user templates first
161
+ begin
162
+ @body = engine.render(template_path, data)
163
+ return self
164
+ rescue Errno::ENOENT
165
+ # Not found in user templates — try framework templates
166
+ rescue => e
167
+ @body = "<pre>Template error: #{e.message}</pre>"
168
+ @status_code = 500
169
+ return self
170
+ end
171
+
172
+ # Fallback: framework templates
173
+ fw_engine = Tina4.get_framework_frond
174
+ if fw_engine
175
+ begin
176
+ @body = fw_engine.render(template_path, data)
177
+ return self
178
+ rescue Errno::ENOENT
179
+ # Not found in framework templates either
180
+ rescue => e
181
+ @body = "<pre>Template error: #{e.message}</pre>"
182
+ @status_code = 500
183
+ return self
184
+ end
131
185
  end
186
+
187
+ @body = "<pre>Template not found: #{template_path}</pre>"
188
+ @status_code = 404
132
189
  self
133
190
  end
134
191
 
@@ -152,9 +209,9 @@ module Tina4
152
209
  # Build a standard error envelope hash (class method).
153
210
  #
154
211
  # Usage:
155
- # response.json(Tina4::Response.error_envelope("NOT_FOUND", "Resource not found", 404), status: 404)
212
+ # response.json(Tina4::Response.error_response("NOT_FOUND", "Resource not found", 404), status: 404)
156
213
  #
157
- def self.error_envelope(code, message, status = 400)
214
+ def self.error_response(code, message, status = 400)
158
215
  { error: true, code: code, message: message, status: status }
159
216
  end
160
217
 
@@ -231,8 +288,17 @@ module Tina4
231
288
  self
232
289
  end
233
290
 
234
- # Flush / finalize -- alias for to_rack for semantic clarity
235
- def send
291
+ # Finalize and return the response matches Python/Node API.
292
+ def send(data = nil, status_code: nil, content_type: nil)
293
+ if data
294
+ if data.is_a?(Hash) || data.is_a?(Array)
295
+ return json(data, status_code || 200)
296
+ end
297
+ @headers["content-type"] = content_type if content_type
298
+ @body = data.to_s
299
+ @status_code = status_code if status_code
300
+ return self
301
+ end
236
302
  to_rack
237
303
  end
238
304
 
@@ -6,7 +6,37 @@ module Tina4
6
6
  SCSS_DIRS = %w[src/scss scss src/styles styles].freeze
7
7
  CSS_OUTPUT = "public/css"
8
8
 
9
+ # Module-level state for import paths and variables
10
+ @import_paths = []
11
+ @variables = {}
12
+
9
13
  class << self
14
+ # Add a search path for @import resolution.
15
+ def add_import_path(path)
16
+ @import_paths ||= []
17
+ @import_paths << path
18
+ end
19
+
20
+ # Set a variable that will be available during compilation.
21
+ def set_variable(name, value)
22
+ @variables ||= {}
23
+ name = name.sub(/^\$/, "")
24
+ @variables[name] = value
25
+ end
26
+
27
+ # Compile an SCSS string to CSS.
28
+ def compile(source)
29
+ @variables ||= {}
30
+ @import_paths ||= []
31
+ # Inject preset variables
32
+ unless @variables.empty?
33
+ var_block = @variables.map { |k, v| "$#{k}: #{v};" }.join("\n")
34
+ source = "#{var_block}\n#{source}"
35
+ end
36
+ basic_compile(source, @import_paths.first || Dir.pwd)
37
+ end
38
+
39
+ # Compile all .scss files from known directories.
10
40
  def compile_all(root_dir = Dir.pwd)
11
41
  output_dir = File.join(root_dir, CSS_OUTPUT)
12
42
  FileUtils.mkdir_p(output_dir)
@@ -22,20 +52,28 @@ module Tina4
22
52
  end
23
53
  end
24
54
 
25
- def compile_file(scss_file, output_dir, base_dir)
26
- relative = scss_file.sub(base_dir, "").sub(/\.scss$/, ".css")
27
- css_file = File.join(output_dir, relative)
28
- FileUtils.mkdir_p(File.dirname(css_file))
29
-
55
+ # Compile a single SCSS file. If output_dir is provided, writes CSS there.
56
+ # Always returns the compiled CSS string.
57
+ def compile_file(scss_file, output_dir = nil, base_dir = nil)
58
+ base_dir ||= File.dirname(scss_file)
30
59
  scss_content = File.read(scss_file)
31
60
  css_content = compile_scss(scss_content, File.dirname(scss_file))
32
- File.write(css_file, css_content)
33
61
 
34
- Tina4::Log.debug("Compiled SCSS: #{scss_file} -> #{css_file}")
62
+ if output_dir
63
+ relative = scss_file.sub(base_dir, "").sub(/\.scss$/, ".css")
64
+ css_file = File.join(output_dir, relative)
65
+ FileUtils.mkdir_p(File.dirname(css_file))
66
+ File.write(css_file, css_content)
67
+ Tina4::Log.debug("Compiled SCSS: #{scss_file} -> #{css_file}")
68
+ end
69
+
70
+ css_content
35
71
  rescue => e
36
72
  Tina4::Log.error("SCSS compilation failed: #{scss_file} - #{e.message}")
73
+ ""
37
74
  end
38
75
 
76
+ # Compile an SCSS content string with a base directory for import resolution.
39
77
  def compile_scss(content, base_dir)
40
78
  # Try sassc gem first
41
79
  begin
@@ -84,6 +122,12 @@ module Tina4
84
122
  File.join(base_dir, "_#{import_path}.scss"),
85
123
  File.join(base_dir, import_path)
86
124
  ]
125
+ # Also search additional import paths
126
+ (@import_paths || []).each do |search_path|
127
+ candidates << File.join(search_path, "#{import_path}.scss")
128
+ candidates << File.join(search_path, "_#{import_path}.scss")
129
+ candidates << File.join(search_path, import_path)
130
+ end
87
131
  found = candidates.find { |c| File.exist?(c) }
88
132
  if found
89
133
  imported = File.read(found)
@@ -133,7 +133,7 @@ module Tina4
133
133
  end
134
134
 
135
135
  # Check if a specific service is currently running.
136
- def running?(name)
136
+ def is_running(name)
137
137
  ctx = @contexts[name.to_s]
138
138
  ctx&.running == true && @threads[name.to_s]&.alive? == true
139
139
  end
data/lib/tina4/swagger.rb CHANGED
@@ -4,9 +4,10 @@ require "json"
4
4
  module Tina4
5
5
  module Swagger
6
6
  class << self
7
- def generate
7
+ def generate(routes = [])
8
8
  spec = base_spec
9
- Tina4::Router.routes.each do |route|
9
+ route_list = routes.empty? ? Tina4::Router.routes : routes
10
+ route_list.each do |route|
10
11
  add_route_to_spec(spec, route)
11
12
  end
12
13
  spec
data/lib/tina4/testing.rb CHANGED
@@ -15,6 +15,7 @@ module Tina4
15
15
 
16
16
  def reset!
17
17
  @suites = []
18
+ @inline_registry = []
18
19
  @results = { passed: 0, failed: 0, errors: 0, tests: [] }
19
20
  end
20
21
 
@@ -24,43 +25,159 @@ module Tina4
24
25
  suites << suite
25
26
  end
26
27
 
27
- def run_all
28
+ def run_all(quiet: false, failfast: false)
28
29
  reset_results
29
30
  suites.each do |suite|
30
- run_suite(suite)
31
+ run_suite(suite, quiet: quiet, failfast: failfast)
32
+ break if failfast && results[:failed] > 0
31
33
  end
32
- print_results
34
+ # Run inline-registered tests
35
+ inline_registry.each do |entry|
36
+ run_inline_entry(entry, quiet: quiet)
37
+ break if failfast && results[:failed] > 0
38
+ end
39
+ print_results unless quiet
33
40
  results
34
41
  end
35
42
 
43
+ # ── Inline testing (parity with Python/PHP/Node decorator pattern) ──
44
+
45
+ # Assertion builder: assert_equal(args, expected)
46
+ def assert_equal(args, expected)
47
+ { type: :equal, args: args, expected: expected }
48
+ end
49
+
50
+ # Assertion builder: assert_raises(exception_class, args)
51
+ def assert_raises(exception_class, args)
52
+ { type: :raises, exception: exception_class, args: args }
53
+ end
54
+
55
+ # Assertion builder: assert_true(args)
56
+ def assert_true(args)
57
+ { type: :true, args: args }
58
+ end
59
+
60
+ # Assertion builder: assert_false(args)
61
+ def assert_false(args)
62
+ { type: :false, args: args }
63
+ end
64
+
65
+ # Register a callable with inline assertions (mirrors Python's @tests decorator).
66
+ #
67
+ # Tina4::Testing.tests(
68
+ # Tina4::Testing.assert_equal([5, 3], 8),
69
+ # Tina4::Testing.assert_raises(ArgumentError, [nil]),
70
+ # ) { |a, b| raise ArgumentError, "b required" if b.nil?; a + b }
71
+ #
72
+ def tests(*assertions, name: nil, &block)
73
+ raise ArgumentError, "tests requires a block" unless block_given?
74
+ inline_registry << {
75
+ fn: block,
76
+ name: name || "anonymous",
77
+ assertions: assertions
78
+ }
79
+ block
80
+ end
81
+
82
+ def inline_registry
83
+ @inline_registry ||= []
84
+ end
85
+
36
86
  private
37
87
 
88
+ def run_inline_entry(entry, quiet: false)
89
+ fn = entry[:fn]
90
+ name = entry[:name]
91
+ puts "\n #{name}" unless entry[:assertions].empty? || quiet
92
+
93
+ entry[:assertions].each do |assertion|
94
+ args = assertion[:args]
95
+ case assertion[:type]
96
+ when :equal
97
+ begin
98
+ result = fn.call(*args)
99
+ if result == assertion[:expected]
100
+ results[:passed] += 1
101
+ puts " \e[32m✓\e[0m #{name}(#{args.inspect}) == #{assertion[:expected].inspect}" unless quiet
102
+ else
103
+ results[:failed] += 1
104
+ puts " \e[31m✗\e[0m #{name}(#{args.inspect}) expected #{assertion[:expected].inspect}, got #{result.inspect}" unless quiet
105
+ end
106
+ rescue => e
107
+ results[:errors] += 1
108
+ puts " \e[33m!\e[0m #{name}(#{args.inspect}) raised #{e.class}: #{e.message}" unless quiet
109
+ end
110
+ when :raises
111
+ begin
112
+ fn.call(*args)
113
+ results[:failed] += 1
114
+ puts " \e[31m✗\e[0m #{name}(#{args.inspect}) expected #{assertion[:exception]} but none raised" unless quiet
115
+ rescue assertion[:exception]
116
+ results[:passed] += 1
117
+ puts " \e[32m✓\e[0m #{name}(#{args.inspect}) raises #{assertion[:exception]}" unless quiet
118
+ rescue => e
119
+ results[:failed] += 1
120
+ puts " \e[31m✗\e[0m #{name}(#{args.inspect}) expected #{assertion[:exception]}, got #{e.class}" unless quiet
121
+ end
122
+ when :true
123
+ begin
124
+ result = fn.call(*args)
125
+ if result
126
+ results[:passed] += 1
127
+ puts " \e[32m✓\e[0m #{name}(#{args.inspect}) is truthy" unless quiet
128
+ else
129
+ results[:failed] += 1
130
+ puts " \e[31m✗\e[0m #{name}(#{args.inspect}) expected truthy, got #{result.inspect}" unless quiet
131
+ end
132
+ rescue => e
133
+ results[:errors] += 1
134
+ puts " \e[33m!\e[0m #{name}(#{args.inspect}) raised #{e.class}: #{e.message}" unless quiet
135
+ end
136
+ when :false
137
+ begin
138
+ result = fn.call(*args)
139
+ if !result
140
+ results[:passed] += 1
141
+ puts " \e[32m✓\e[0m #{name}(#{args.inspect}) is falsy" unless quiet
142
+ else
143
+ results[:failed] += 1
144
+ puts " \e[31m✗\e[0m #{name}(#{args.inspect}) expected falsy, got #{result.inspect}" unless quiet
145
+ end
146
+ rescue => e
147
+ results[:errors] += 1
148
+ puts " \e[33m!\e[0m #{name}(#{args.inspect}) raised #{e.class}: #{e.message}" unless quiet
149
+ end
150
+ end
151
+ end
152
+ end
153
+
38
154
  def reset_results
39
155
  @results = { passed: 0, failed: 0, errors: 0, tests: [] }
40
156
  end
41
157
 
42
- def run_suite(suite)
43
- puts "\n #{suite.name}"
158
+ def run_suite(suite, quiet: false, failfast: false)
159
+ puts "\n #{suite.name}" unless quiet
44
160
  suite.tests.each do |test|
45
- run_test(suite, test)
161
+ run_test(suite, test, quiet: quiet)
162
+ break if failfast && results[:failed] > 0
46
163
  end
47
164
  end
48
165
 
49
- def run_test(suite, test)
166
+ def run_test(suite, test, quiet: false)
50
167
  suite.run_before_each
51
168
  context = TestContext.new
52
169
  context.instance_eval(&test[:block])
53
170
  results[:passed] += 1
54
171
  results[:tests] << { name: test[:name], status: :passed, suite: suite.name }
55
- puts " \e[32m✓\e[0m #{test[:name]}"
172
+ puts " \e[32m✓\e[0m #{test[:name]}" unless quiet
56
173
  rescue TestFailure => e
57
174
  results[:failed] += 1
58
175
  results[:tests] << { name: test[:name], status: :failed, suite: suite.name, message: e.message }
59
- puts " \e[31m✗\e[0m #{test[:name]}: #{e.message}"
176
+ puts " \e[31m✗\e[0m #{test[:name]}: #{e.message}" unless quiet
60
177
  rescue => e
61
178
  results[:errors] += 1
62
179
  results[:tests] << { name: test[:name], status: :error, suite: suite.name, message: e.message }
63
- puts " \e[33m!\e[0m #{test[:name]}: #{e.message}"
180
+ puts " \e[33m!\e[0m #{test[:name]}: #{e.message}" unless quiet
64
181
  ensure
65
182
  suite.run_after_each
66
183
  end
@@ -143,6 +260,16 @@ module Tina4
143
260
  true
144
261
  end
145
262
 
263
+ def assert_true(value, message = nil)
264
+ msg = message || "Expected truthy, got #{value.inspect}"
265
+ raise TestFailure, msg unless value
266
+ end
267
+
268
+ def assert_false(value, message = nil)
269
+ msg = message || "Expected falsy, got #{value.inspect}"
270
+ raise TestFailure, msg if value
271
+ end
272
+
146
273
  def assert_match(pattern, string, message = nil)
147
274
  msg = message || "Expected #{string.inspect} to match #{pattern.inspect}"
148
275
  raise TestFailure, msg unless pattern.match?(string)
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.10.91"
4
+ VERSION = "3.10.92"
5
5
  end
@@ -276,5 +276,16 @@ module Tina4
276
276
  @ai_thread&.join(5)
277
277
  @server&.shutdown
278
278
  end
279
+
280
+ # Dispatch a Rack-style env through the Tina4 app and return [status, headers, body].
281
+ #
282
+ # Useful for testing and embedding — does not require a running server.
283
+ # Cross-framework parity with Python and Node.js.
284
+ #
285
+ # @param env [Hash] A Rack environment hash
286
+ # @return [Array] Rack-style response triple [status, headers, body]
287
+ def handle(env)
288
+ @app.call(env)
289
+ end
279
290
  end
280
291
  end
@@ -5,8 +5,32 @@ require "base64"
5
5
  require "set"
6
6
 
7
7
  module Tina4
8
+ WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-5AB5DC11AD37"
9
+
10
+ # Compute Sec-WebSocket-Accept from Sec-WebSocket-Key per RFC 6455.
11
+ def self.compute_accept_key(key)
12
+ Base64.strict_encode64(Digest::SHA1.digest("#{key}#{WEBSOCKET_GUID}"))
13
+ end
14
+
15
+ # Build a WebSocket frame (server→client, never masked).
16
+ def self.build_frame(opcode, data, fin: true)
17
+ first_byte = (fin ? 0x80 : 0x00) | opcode
18
+ frame = [first_byte].pack("C")
19
+ length = data.bytesize
20
+
21
+ if length < 126
22
+ frame += [length].pack("C")
23
+ elsif length < 65536
24
+ frame += [126, length].pack("Cn")
25
+ else
26
+ frame += [127, length].pack("CQ>")
27
+ end
28
+
29
+ frame + data
30
+ end
31
+
8
32
  class WebSocket
9
- GUID = "258EAFA5-E914-47DA-95CA-5AB5DC11AD37"
33
+ GUID = WEBSOCKET_GUID
10
34
 
11
35
  attr_reader :connections
12
36
 
@@ -68,6 +92,16 @@ module Tina4
68
92
  end
69
93
  end
70
94
 
95
+ def send_to(conn_id, message)
96
+ conn = @connections[conn_id]
97
+ conn&.send_text(message)
98
+ end
99
+
100
+ def close(conn_id, code: 1000, reason: "")
101
+ conn = @connections[conn_id]
102
+ conn&.close(code: code, reason: reason)
103
+ end
104
+
71
105
  # ── Rooms ──────────────────────────────────────────────────
72
106
 
73
107
  def join_room_for(conn_id, room_name)
@@ -99,9 +133,7 @@ module Tina4
99
133
  key = env["HTTP_SEC_WEBSOCKET_KEY"]
100
134
  return unless key
101
135
 
102
- accept = Base64.strict_encode64(
103
- Digest::SHA1.digest("#{key}#{GUID}")
104
- )
136
+ accept = Tina4.compute_accept_key(key)
105
137
 
106
138
  response = "HTTP/1.1 101 Switching Protocols\r\n" \
107
139
  "Upgrade: websocket\r\n" \
@@ -247,18 +279,7 @@ module Tina4
247
279
  end
248
280
 
249
281
  def build_frame(opcode, data)
250
- frame = [0x80 | opcode].pack("C")
251
- length = data.bytesize
252
-
253
- if length < 126
254
- frame += [length].pack("C")
255
- elsif length < 65536
256
- frame += [126, length].pack("Cn")
257
- else
258
- frame += [127, length].pack("CQ>")
259
- end
260
-
261
- frame + data
282
+ Tina4.build_frame(opcode, data)
262
283
  end
263
284
  end
264
285
  end
@@ -11,7 +11,7 @@
11
11
  # TINA4_WS_BACKPLANE_URL — Connection string (default: redis://localhost:6379)
12
12
  #
13
13
  # Usage:
14
- # backplane = Tina4::WebSocketBackplane.create
14
+ # backplane = Tina4::WebSocketBackplane.create_backplane
15
15
  # if backplane
16
16
  # backplane.subscribe("chat") { |msg| relay_to_local(msg) }
17
17
  # backplane.publish("chat", '{"user":"A","text":"hello"}')
@@ -49,7 +49,7 @@ module Tina4
49
49
  #
50
50
  # This keeps backplane usage entirely optional — callers simply check
51
51
  # +if backplane+ before publishing.
52
- def self.create(url: nil)
52
+ def self.create_backplane(url: nil)
53
53
  backend = ENV.fetch("TINA4_WS_BACKPLANE", "").strip.downcase
54
54
 
55
55
  case backend
data/lib/tina4/wsdl.rb CHANGED
@@ -155,7 +155,8 @@ module Tina4
155
155
 
156
156
  # ── WSDL generation ──────────────────────────────────────────────────
157
157
 
158
- def generate_wsdl
158
+ def generate_wsdl(endpoint_url = "")
159
+ @service_url = endpoint_url unless endpoint_url.empty?
159
160
  service_name = self.class.name ? self.class.name.split("::").last : "AnonymousService"
160
161
  tns = "urn:#{service_name}"
161
162
 
data/lib/tina4.rb CHANGED
@@ -120,7 +120,7 @@ module Tina4
120
120
  color = is_tty ? "\e[31m" : ""
121
121
  reset = is_tty ? "\e[0m" : ""
122
122
 
123
- is_debug = Tina4::Env.truthy?(ENV["TINA4_DEBUG"])
123
+ is_debug = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
124
124
  log_level = (ENV["TINA4_LOG_LEVEL"] || "[TINA4_LOG_ALL]").upcase
125
125
  display = (host == "0.0.0.0" || host == "::") ? "localhost" : host
126
126
 
@@ -161,7 +161,7 @@ module Tina4
161
161
  Tina4::Env.load_env(root_dir)
162
162
 
163
163
  # Setup debug logging
164
- Tina4::Log.setup(root_dir)
164
+ Tina4::Log.configure(root_dir)
165
165
  Tina4::Log.info("Tina4 Ruby v#{VERSION} initializing...")
166
166
 
167
167
  # Setup auth keys
@@ -240,7 +240,7 @@ module Tina4
240
240
  url = "http://#{display_host}:#{port}"
241
241
 
242
242
  app = Tina4::RackApp.new(root_dir: root_dir)
243
- is_debug = Tina4::Env.truthy?(ENV["TINA4_DEBUG"])
243
+ is_debug = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
244
244
 
245
245
  # Try Puma first (production-grade), fall back to WEBrick
246
246
  if !is_debug
@@ -390,7 +390,7 @@ module Tina4
390
390
  Tina4::Container.singleton(name, &block)
391
391
  end
392
392
 
393
- def get(name)
393
+ def resolve(name)
394
394
  Tina4::Container.get(name)
395
395
  end
396
396
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.10.91
4
+ version: 3.10.92
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team