tina4ruby 3.10.31 → 3.10.38

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.
@@ -616,16 +616,71 @@ module Tina4
616
616
 
617
617
  toolbar = <<~HTML.strip
618
618
  <div id="tina4-dev-toolbar" style="position:fixed;bottom:0;left:0;right:0;background:#333;color:#fff;font-family:monospace;font-size:12px;padding:6px 16px;z-index:99999;display:flex;align-items:center;gap:16px;">
619
- <span style="color:#d32f2f;font-weight:bold;">Tina4 v#{version}</span>
619
+ <span id="tina4-ver-btn" style="color:#d32f2f;font-weight:bold;cursor:pointer;text-decoration:underline dotted;" onclick="tina4VersionModal()" title="Click to check for updates">Tina4 v#{version}</span>
620
+ <div id="tina4-ver-modal" style="display:none;position:fixed;bottom:3rem;left:1rem;background:#1e1e2e;border:1px solid #d32f2f;border-radius:8px;padding:16px 20px;z-index:100000;min-width:320px;box-shadow:0 8px 32px rgba(0,0,0,0.5);font-family:monospace;font-size:13px;color:#cdd6f4;">
621
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
622
+ <strong style="color:#89b4fa;">Version Info</strong>
623
+ <span onclick="document.getElementById('tina4-ver-modal').style.display='none'" style="cursor:pointer;color:#888;">&times;</span>
624
+ </div>
625
+ <div id="tina4-ver-body" style="line-height:1.8;">
626
+ <div>Current: <strong style="color:#a6e3a1;">v#{version}</strong></div>
627
+ <div id="tina4-ver-latest" style="color:#888;">Checking for updates...</div>
628
+ </div>
629
+ </div>
620
630
  <span style="color:#4caf50;">#{method}</span>
621
631
  <span>#{path}</span>
622
632
  <span style="color:#666;">&rarr; #{matched_pattern}</span>
623
633
  <span style="color:#ffeb3b;">req:#{request_id}</span>
624
634
  <span style="color:#90caf9;">#{route_count} routes</span>
625
635
  <span style="color:#888;">Ruby #{RUBY_VERSION}</span>
626
- <a href="#" onclick="(function(e){e.preventDefault();var p=document.getElementById('tina4-dev-panel');if(p){p.style.display=p.style.display==='none'?'block':'none';return;}var c=document.createElement('div');c.id='tina4-dev-panel';c.style.cssText='position:fixed;bottom:2rem;right:1rem;width:min(90vw,1200px);height:min(80vh,700px);z-index:99998;transition:all 0.2s';var f=document.createElement('iframe');f.src='/__dev';f.style.cssText='width:100%;height:100%;border:1px solid #CC342D;border-radius:0.5rem;box-shadow:0 8px 32px rgba(0,0,0,0.5);background:#0f172a';c.appendChild(f);document.body.appendChild(c);})(event)" style="color:#ef9a9a;margin-left:auto;text-decoration:none;cursor:pointer;">Dashboard &#8599;</a>
636
+ <a href="#" onclick="(function(e){e.preventDefault();var p=document.getElementById('tina4-dev-panel');if(p){p.style.display=p.style.display==='none'?'block':'none';return;}var c=document.createElement('div');c.id='tina4-dev-panel';c.style.cssText='position:fixed;top:3rem;left:0;right:0;bottom:2rem;z-index:99998;transition:all 0.2s';var f=document.createElement('iframe');f.src='/__dev';f.style.cssText='width:100%;height:100%;border:1px solid #CC342D;border-radius:0.5rem;box-shadow:0 8px 32px rgba(0,0,0,0.5);background:#0f172a';c.appendChild(f);document.body.appendChild(c);})(event)" style="color:#ef9a9a;margin-left:auto;text-decoration:none;cursor:pointer;">Dashboard &#8599;</a>
627
637
  <span onclick="this.parentElement.style.display='none'" style="cursor:pointer;color:#888;margin-left:8px;">&#10005;</span>
628
638
  </div>
639
+ <script>
640
+ function tina4VersionModal(){
641
+ var m=document.getElementById('tina4-ver-modal');
642
+ if(m.style.display==='block'){m.style.display='none';return;}
643
+ m.style.display='block';
644
+ var el=document.getElementById('tina4-ver-latest');
645
+ el.innerHTML='Checking for updates...';
646
+ el.style.color='#888';
647
+ fetch('/__dev/api/version-check')
648
+ .then(function(r){return r.json()})
649
+ .then(function(d){
650
+ var latest=d.latest;
651
+ var current=d.current;
652
+ if(latest===current){
653
+ el.innerHTML='Latest: <strong style="color:#a6e3a1;">v'+latest+'</strong> &mdash; You are up to date!';
654
+ el.style.color='#a6e3a1';
655
+ }else{
656
+ var cParts=current.split('.').map(Number);
657
+ var lParts=latest.split('.').map(Number);
658
+ var isNewer=false;
659
+ for(var i=0;i<Math.max(cParts.length,lParts.length);i++){
660
+ var c=cParts[i]||0,l=lParts[i]||0;
661
+ if(l>c){isNewer=true;break;}
662
+ if(l<c)break;
663
+ }
664
+ if(isNewer){
665
+ var breaking=(lParts[0]!==cParts[0]||lParts[1]!==cParts[1]);
666
+ el.innerHTML='Latest: <strong style="color:#f9e2af;">v'+latest+'</strong>';
667
+ if(breaking){
668
+ el.innerHTML+='<div style="color:#f38ba8;margin-top:6px;">&#9888; Major/minor version change &mdash; check the <a href="https://github.com/tina4stack/tina4-ruby/releases" target="_blank" style="color:#89b4fa;">changelog</a> for breaking changes before upgrading.</div>';
669
+ }else{
670
+ el.innerHTML+='<div style="color:#f9e2af;margin-top:6px;">Patch update available. Run: <code style="background:#313244;padding:2px 6px;border-radius:3px;">gem install tina4ruby</code></div>';
671
+ }
672
+ }else{
673
+ el.innerHTML='Latest: <strong style="color:#a6e3a1;">v'+latest+'</strong> &mdash; You are up to date!';
674
+ el.style.color='#a6e3a1';
675
+ }
676
+ }
677
+ })
678
+ .catch(function(){
679
+ el.innerHTML='Could not check for updates (offline?)';
680
+ el.style.color='#f38ba8';
681
+ });
682
+ }
683
+ </script>
629
684
  HTML
630
685
 
631
686
  if body.include?("</body>")
data/lib/tina4/request.rb CHANGED
@@ -3,6 +3,38 @@ require "uri"
3
3
  require "json"
4
4
 
5
5
  module Tina4
6
+ # A Hash subclass that supports indifferent access (both string and symbol keys).
7
+ # Used by Request#params so that params[:id] and params["id"] both work.
8
+ class IndifferentHash < Hash
9
+ def [](key)
10
+ super(convert_key(key))
11
+ end
12
+
13
+ def []=(key, value)
14
+ super(convert_key(key), value)
15
+ end
16
+
17
+ def fetch(key, *args, &block)
18
+ super(convert_key(key), *args, &block)
19
+ end
20
+
21
+ def key?(key)
22
+ super(convert_key(key))
23
+ end
24
+ alias has_key? key?
25
+ alias include? key?
26
+
27
+ def delete(key, &block)
28
+ super(convert_key(key), &block)
29
+ end
30
+
31
+ private
32
+
33
+ def convert_key(key)
34
+ key.is_a?(Symbol) ? key.to_s : key
35
+ end
36
+ end
37
+
6
38
  class Request
7
39
  attr_reader :env, :method, :path, :query_string, :content_type,
8
40
  :path_params, :ip
@@ -88,10 +120,16 @@ module Tina4
88
120
  end
89
121
 
90
122
  # Merged params: query + body + path_params (path_params highest priority)
123
+ # Supports both string and symbol key access (indifferent access).
91
124
  def params
92
125
  @params ||= build_params
93
126
  end
94
127
 
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]
131
+ end
132
+
95
133
  def [](key)
96
134
  params[key.to_s] || params[key.to_sym] || @path_params[key.to_sym]
97
135
  end
@@ -168,10 +206,10 @@ module Tina4
168
206
  end
169
207
 
170
208
  def build_params
171
- p = {}
209
+ p = IndifferentHash.new
172
210
 
173
211
  # Query string params
174
- query.each { |k, v| p[k] = v }
212
+ query.each { |k, v| p[k.to_s] = v }
175
213
 
176
214
  # Body params
177
215
  body_parsed.each { |k, v| p[k.to_s] = v }
@@ -103,10 +103,15 @@ module Tina4
103
103
  self
104
104
  end
105
105
 
106
- def render(template_path, data = {}, status: 200)
106
+ def render(template_path, data = {}, status: 200, template_dir: nil)
107
107
  @status_code = status
108
108
  @headers["content-type"] = HTML_CONTENT_TYPE
109
- @body = Tina4::Template.render(template_path, data)
109
+ if template_dir
110
+ frond = Tina4::Frond.new(template_dir: template_dir)
111
+ @body = frond.render(template_path, data)
112
+ else
113
+ @body = Tina4::Template.render(template_path, data)
114
+ end
110
115
  self
111
116
  end
112
117
 
data/lib/tina4/router.rb CHANGED
@@ -89,10 +89,14 @@ module Tina4
89
89
  parts = path.split("/").reject(&:empty?)
90
90
  regex_parts = parts.map do |part|
91
91
  if part =~ /\A\*(\w+)\z/
92
- # Catch-all splat parameter: *path captures everything after
92
+ # Named catch-all splat parameter: *path captures everything after
93
93
  name = Regexp.last_match(1)
94
94
  @param_names << { name: name.to_sym, type: "path" }
95
95
  '(.+)'
96
+ elsif part == "*"
97
+ # Bare catch-all wildcard: captures everything after (unnamed)
98
+ @param_names << { name: :splat, type: "path" }
99
+ '(.+)'
96
100
  elsif part =~ /\A\{(\w+)(?::(\w+))?\}\z/
97
101
  # Tina4/Python-style brace params: {id} or {id:int}
98
102
  # This is the ONLY supported param syntax, matching Python exactly.
@@ -0,0 +1,159 @@
1
+ # Tina4 Test Client — Test routes without starting a server.
2
+ #
3
+ # Usage:
4
+ #
5
+ # client = Tina4::TestClient.new
6
+ #
7
+ # response = client.get("/api/users")
8
+ # assert_equal 200, response.status
9
+ # assert response.json["users"]
10
+ #
11
+ # response = client.post("/api/users", json: { name: "Alice" })
12
+ # assert_equal 201, response.status
13
+ #
14
+ # response = client.get("/api/users/1", headers: { "Authorization" => "Bearer token123" })
15
+ #
16
+ module Tina4
17
+ class TestResponse
18
+ attr_reader :status, :body, :headers, :content_type
19
+
20
+ # Build from a Rack response tuple [status, headers, body_array]
21
+ def initialize(rack_response)
22
+ @status = rack_response[0]
23
+ @headers = rack_response[1] || {}
24
+ @content_type = @headers["content-type"] || ""
25
+ raw_body = rack_response[2]
26
+ @body = raw_body.is_a?(Array) ? raw_body.join : raw_body.to_s
27
+ end
28
+
29
+ # Parse body as JSON.
30
+ def json
31
+ return nil if @body.nil? || @body.empty?
32
+ JSON.parse(@body)
33
+ rescue JSON::ParserError
34
+ nil
35
+ end
36
+
37
+ # Return body as a string.
38
+ def text
39
+ @body.to_s
40
+ end
41
+
42
+ def inspect
43
+ "<TestResponse status=#{@status} content_type=#{@content_type.inspect}>"
44
+ end
45
+ end
46
+
47
+ class TestClient
48
+ # Send a GET request.
49
+ def get(path, headers: nil)
50
+ request("GET", path, headers: headers)
51
+ end
52
+
53
+ # Send a POST request.
54
+ def post(path, json: nil, body: nil, headers: nil)
55
+ request("POST", path, json: json, body: body, headers: headers)
56
+ end
57
+
58
+ # Send a PUT request.
59
+ def put(path, json: nil, body: nil, headers: nil)
60
+ request("PUT", path, json: json, body: body, headers: headers)
61
+ end
62
+
63
+ # Send a PATCH request.
64
+ def patch(path, json: nil, body: nil, headers: nil)
65
+ request("PATCH", path, json: json, body: body, headers: headers)
66
+ end
67
+
68
+ # Send a DELETE request.
69
+ def delete(path, headers: nil)
70
+ request("DELETE", path, headers: headers)
71
+ end
72
+
73
+ private
74
+
75
+ # Build a mock Rack env, match the route, execute the handler.
76
+ def request(method, path, json: nil, body: nil, headers: nil)
77
+ # Build raw body
78
+ raw_body = ""
79
+ content_type = ""
80
+
81
+ if json
82
+ raw_body = JSON.generate(json)
83
+ content_type = "application/json"
84
+ elsif body
85
+ raw_body = body.to_s
86
+ end
87
+
88
+ # Split path and query string
89
+ clean_path, query_string = path.include?("?") ? path.split("?", 2) : [path, ""]
90
+
91
+ # Build Rack env hash
92
+ env = {
93
+ "REQUEST_METHOD" => method.upcase,
94
+ "PATH_INFO" => clean_path,
95
+ "QUERY_STRING" => query_string || "",
96
+ "SERVER_NAME" => "localhost",
97
+ "SERVER_PORT" => "7145",
98
+ "HTTP_HOST" => "localhost:7145",
99
+ "REMOTE_ADDR" => "127.0.0.1",
100
+ "rack.input" => StringIO.new(raw_body),
101
+ "rack.url_scheme" => "http"
102
+ }
103
+
104
+ # Add content type
105
+ env["CONTENT_TYPE"] = content_type unless content_type.empty?
106
+ env["CONTENT_LENGTH"] = raw_body.bytesize.to_s unless raw_body.empty?
107
+
108
+ # Add custom headers (convert to Rack format: X-Custom → HTTP_X_CUSTOM)
109
+ if headers
110
+ headers.each do |key, value|
111
+ rack_key = "HTTP_#{key.upcase.tr('-', '_')}"
112
+ env[rack_key] = value
113
+ end
114
+ end
115
+
116
+ # Match route
117
+ result = Tina4::Router.find_route(clean_path, method.upcase)
118
+
119
+ unless result
120
+ return TestResponse.new([404, { "content-type" => "application/json" }, ['{"error":"Not found"}']])
121
+ end
122
+
123
+ route, path_params = result
124
+
125
+ # Create request and response
126
+ req = Tina4::Request.new(env, path_params || {})
127
+ res = Tina4::Response.new
128
+
129
+ # Build handler args (same logic as RackApp.handle_route)
130
+ handler_params = route.handler.parameters.map(&:last)
131
+ route_params = path_params || {}
132
+ args = handler_params.map do |name|
133
+ if route_params.key?(name)
134
+ route_params[name]
135
+ elsif name == :request || name == :req
136
+ req
137
+ else
138
+ res
139
+ end
140
+ end
141
+
142
+ # Execute handler
143
+ handler_result = args.empty? ? route.handler.call : route.handler.call(*args)
144
+
145
+ # Auto-detect response type
146
+ if handler_result.is_a?(Tina4::Response)
147
+ final = handler_result
148
+ elsif route.respond_to?(:template) && route.template && handler_result.is_a?(Hash)
149
+ html = Tina4::Template.render(route.template, handler_result)
150
+ res.html(html)
151
+ final = res
152
+ else
153
+ final = Tina4::Response.auto_detect(handler_result, res)
154
+ end
155
+
156
+ TestResponse.new(final.to_rack)
157
+ end
158
+ end
159
+ end
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.31"
4
+ VERSION = "3.10.38"
5
5
  end
data/lib/tina4.rb CHANGED
@@ -48,6 +48,8 @@ require_relative "tina4/sql_translation"
48
48
  require_relative "tina4/response_cache"
49
49
  require_relative "tina4/html_element"
50
50
  require_relative "tina4/error_overlay"
51
+ require_relative "tina4/test_client"
52
+ require_relative "tina4/mcp"
51
53
 
52
54
  module Tina4
53
55
  # ── Lazy-loaded: database drivers ─────────────────────────────────────
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.10.31
4
+ version: 3.10.38
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-04-01 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: rack
@@ -170,7 +171,7 @@ dependencies:
170
171
  - - "~>"
171
172
  - !ruby/object:Gem::Version
172
173
  version: '1.8'
173
- type: :development
174
+ type: :runtime
174
175
  prerelease: false
175
176
  version_requirements: !ruby/object:Gem::Requirement
176
177
  requirements:
@@ -321,7 +322,9 @@ files:
321
322
  - lib/tina4/html_element.rb
322
323
  - lib/tina4/localization.rb
323
324
  - lib/tina4/log.rb
325
+ - lib/tina4/mcp.rb
324
326
  - lib/tina4/messenger.rb
327
+ - lib/tina4/metrics.rb
325
328
  - lib/tina4/middleware.rb
326
329
  - lib/tina4/migration.rb
327
330
  - lib/tina4/orm.rb
@@ -386,6 +389,7 @@ files:
386
389
  - lib/tina4/templates/errors/502.twig
387
390
  - lib/tina4/templates/errors/503.twig
388
391
  - lib/tina4/templates/errors/base.twig
392
+ - lib/tina4/test_client.rb
389
393
  - lib/tina4/testing.rb
390
394
  - lib/tina4/validator.rb
391
395
  - lib/tina4/version.rb
@@ -399,6 +403,7 @@ licenses:
399
403
  - MIT
400
404
  metadata:
401
405
  homepage_uri: https://tina4.com
406
+ post_install_message:
402
407
  rdoc_options: []
403
408
  require_paths:
404
409
  - lib
@@ -413,7 +418,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
413
418
  - !ruby/object:Gem::Version
414
419
  version: '0'
415
420
  requirements: []
416
- rubygems_version: 4.0.3
421
+ rubygems_version: 3.4.19
422
+ signing_key:
417
423
  specification_version: 4
418
424
  summary: Simple. Fast. Human. This is not a framework.
419
425
  test_files: []