tina4ruby 3.13.50 → 3.13.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/tina4/cli.rb +4 -0
- data/lib/tina4/database.rb +1 -0
- data/lib/tina4/dev_admin.rb +48 -11
- data/lib/tina4/frond.rb +210 -1
- data/lib/tina4/mcp.rb +120 -15
- data/lib/tina4/public/js/frond.js +138 -1
- data/lib/tina4/public/js/frond.min.js +2 -2
- data/lib/tina4/rack_app.rb +4 -1
- data/lib/tina4/scss_compiler.rb +107 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +10 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b6a859f05cd2ac97b5e38ab9ee678cd8419f91be1aca0dedaed1e1d63b268968
|
|
4
|
+
data.tar.gz: 4454362a269ef77b5992a0ec4d2e30e6dd64134ed64265d1e8fd6f9432fcbc0f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 595d84b72e3b75a263f54ff93859a9f92f52fc76434b7633d149d11a2fa8a172a72478a13b9aa649ea0189a1f975d0a98daf7afa7cbceeb5bec9636433c95dad
|
|
7
|
+
data.tar.gz: 66634157b45125bb796277f54a9b93d12ab721c237b0dec39450d78d69ba4f3162972a178ef742607d45e4cc33d572c3460d7d336e0133e7c29ea902e21b68ae
|
data/lib/tina4/cli.rb
CHANGED
|
@@ -200,6 +200,10 @@ module Tina4
|
|
|
200
200
|
# Register health check endpoint
|
|
201
201
|
Tina4::Health.register!
|
|
202
202
|
|
|
203
|
+
# Register the always-on Frond {% live %} refresh endpoint
|
|
204
|
+
# (GET /__frond/live/{name}) so server-rendered live blocks can poll/SSE.
|
|
205
|
+
Tina4::Frond.register_live_endpoint!
|
|
206
|
+
|
|
203
207
|
# Load route files
|
|
204
208
|
load_routes(root_dir)
|
|
205
209
|
|
data/lib/tina4/database.rb
CHANGED
|
@@ -111,6 +111,7 @@ module Tina4
|
|
|
111
111
|
"sqlite3" => "Tina4::Drivers::SqliteDriver",
|
|
112
112
|
"postgres" => "Tina4::Drivers::PostgresDriver",
|
|
113
113
|
"postgresql" => "Tina4::Drivers::PostgresDriver",
|
|
114
|
+
"pgsql" => "Tina4::Drivers::PostgresDriver",
|
|
114
115
|
"mysql" => "Tina4::Drivers::MysqlDriver",
|
|
115
116
|
"mssql" => "Tina4::Drivers::MssqlDriver",
|
|
116
117
|
"sqlserver" => "Tina4::Drivers::MssqlDriver",
|
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -339,12 +339,13 @@ module Tina4
|
|
|
339
339
|
return unless enabled?
|
|
340
340
|
|
|
341
341
|
port = ENV["TINA4_PORT"] || ENV["PORT"] || "7147"
|
|
342
|
-
url = "http://localhost:#{port}/__dev/
|
|
342
|
+
url = "http://localhost:#{port}/__dev/mcp"
|
|
343
343
|
target_dir = File.join(project_root, ".tina4")
|
|
344
344
|
target = File.join(target_dir, "mcp.json")
|
|
345
345
|
payload = {
|
|
346
346
|
"mcpServers" => {
|
|
347
347
|
"tina4-live-docs" => {
|
|
348
|
+
"type" => "http",
|
|
348
349
|
"url" => url,
|
|
349
350
|
"description" => "Live API docs for this Tina4 project (framework + user code)",
|
|
350
351
|
},
|
|
@@ -659,8 +660,14 @@ module Tina4
|
|
|
659
660
|
body = read_json_body(env) || {}
|
|
660
661
|
json_response(mcp_tool_call(body))
|
|
661
662
|
end
|
|
662
|
-
when ["POST", "/__dev/mcp"]
|
|
663
|
-
with_mcp_gate(env) {
|
|
663
|
+
when ["POST", "/__dev/mcp"]
|
|
664
|
+
with_mcp_gate(env) { mcp_streamable(env) }
|
|
665
|
+
when ["DELETE", "/__dev/mcp"]
|
|
666
|
+
with_mcp_gate(env) { mcp_delete(env) }
|
|
667
|
+
when ["GET", "/__dev/mcp"]
|
|
668
|
+
with_mcp_gate(env) { mcp_get_405 }
|
|
669
|
+
when ["POST", "/__dev/mcp/message"]
|
|
670
|
+
with_mcp_gate(env) { mcp_legacy_message(env) }
|
|
664
671
|
when ["GET", "/__dev/mcp/sse"]
|
|
665
672
|
with_mcp_gate(env) { mcp_sse_handshake }
|
|
666
673
|
when ["GET", "/__dev/api/scaffold"]
|
|
@@ -1683,15 +1690,45 @@ module Tina4
|
|
|
1683
1690
|
# clients POST a JSON-RPC 2.0 request; we hand the parsed body straight
|
|
1684
1691
|
# to the default server's handle_message and echo the response. A
|
|
1685
1692
|
# notification (no id) yields an empty 204, mirroring Python.
|
|
1686
|
-
|
|
1687
|
-
|
|
1693
|
+
# Streamable HTTP POST /__dev/mcp — the current MCP transport. initialize
|
|
1694
|
+
# issues an Mcp-Session-Id; an unknown session on a non-initialize request
|
|
1695
|
+
# is a 404; a notification is 202; anything else answers inline (200).
|
|
1696
|
+
def mcp_streamable(env)
|
|
1688
1697
|
server = Tina4._default_mcp_server
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1698
|
+
out = server.dispatch_http(read_json_body(env) || {}, mcp_session_id(env))
|
|
1699
|
+
mcp_triple(out)
|
|
1700
|
+
end
|
|
1701
|
+
|
|
1702
|
+
# Legacy HTTP+SSE POST /__dev/mcp/message — inline, session-lenient.
|
|
1703
|
+
def mcp_legacy_message(env)
|
|
1704
|
+
server = Tina4._default_mcp_server
|
|
1705
|
+
out = server.dispatch_http(read_json_body(env) || {}, "")
|
|
1706
|
+
mcp_triple(out)
|
|
1707
|
+
end
|
|
1708
|
+
|
|
1709
|
+
# DELETE /__dev/mcp — terminate the session (Streamable HTTP spec).
|
|
1710
|
+
def mcp_delete(env)
|
|
1711
|
+
Tina4._default_mcp_server.close_session(mcp_session_id(env))
|
|
1712
|
+
[204, {}, []]
|
|
1713
|
+
end
|
|
1714
|
+
|
|
1715
|
+
# GET /__dev/mcp — a server->client stream we do not open here.
|
|
1716
|
+
def mcp_get_405
|
|
1717
|
+
[405,
|
|
1718
|
+
{ "allow" => "POST, DELETE", "content-type" => "application/json; charset=utf-8" },
|
|
1719
|
+
[McpProtocol.encode_error(nil, McpProtocol::INVALID_REQUEST, "Method Not Allowed")]]
|
|
1720
|
+
end
|
|
1721
|
+
|
|
1722
|
+
# Read the Mcp-Session-Id request header (Rack: HTTP_MCP_SESSION_ID).
|
|
1723
|
+
def mcp_session_id(env)
|
|
1724
|
+
(env["HTTP_MCP_SESSION_ID"] || "").to_s
|
|
1725
|
+
end
|
|
1726
|
+
|
|
1727
|
+
# Turn a dispatch_http result hash into a Rack [status, headers, body].
|
|
1728
|
+
def mcp_triple(out)
|
|
1729
|
+
headers = { "content-type" => "application/json; charset=utf-8" }.merge(out[:headers] || {})
|
|
1730
|
+
body = out[:body].to_s
|
|
1731
|
+
[out[:status], headers, body.empty? ? [] : [body]]
|
|
1695
1732
|
end
|
|
1696
1733
|
|
|
1697
1734
|
# SSE handshake (GET /__dev/mcp/sse). Tells the client where to POST
|
data/lib/tina4/frond.rb
CHANGED
|
@@ -39,7 +39,23 @@ module Tina4
|
|
|
39
39
|
@@class_globals = {}
|
|
40
40
|
@@class_tests = {}
|
|
41
41
|
|
|
42
|
-
#
|
|
42
|
+
# -- Live-block registries (server-rendered {% live %} regions) -----------
|
|
43
|
+
# A {% live %} block registers three things when its page first renders:
|
|
44
|
+
# * class_live_fragments[name] -> the raw body source, re-rendered on
|
|
45
|
+
# every refresh by the /__frond/live/<name>
|
|
46
|
+
# endpoint or push_live
|
|
47
|
+
# * class_live_sources[name] -> an optional data provider (live_source)
|
|
48
|
+
# that re-runs with the LIVE request each
|
|
49
|
+
# refresh, so auth re-applies (IDOR guard)
|
|
50
|
+
# * class_live_ws_paths[name] -> the ws path a `ws "path"` block declared,
|
|
51
|
+
# used as the push_live broadcast target
|
|
52
|
+
# These persist across requests in the long-lived server (parity with the
|
|
53
|
+
# Python master's class-level dicts and PHP's static registries).
|
|
54
|
+
@@class_live_fragments = {}
|
|
55
|
+
@@class_live_sources = {}
|
|
56
|
+
@@class_live_ws_paths = {}
|
|
57
|
+
|
|
58
|
+
# Clear the class-level globals/filters/tests/live registries.
|
|
43
59
|
#
|
|
44
60
|
# Useful in test fixtures to prevent leaking state between tests. Does
|
|
45
61
|
# NOT affect built-in filters or globals — only user-registered ones.
|
|
@@ -47,6 +63,9 @@ module Tina4
|
|
|
47
63
|
@@class_filters = {}
|
|
48
64
|
@@class_globals = {}
|
|
49
65
|
@@class_tests = {}
|
|
66
|
+
@@class_live_fragments = {}
|
|
67
|
+
@@class_live_sources = {}
|
|
68
|
+
@@class_live_ws_paths = {}
|
|
50
69
|
end
|
|
51
70
|
|
|
52
71
|
# -- Token types ----------------------------------------------------------
|
|
@@ -91,6 +110,18 @@ module Tina4
|
|
|
91
110
|
SET_RE = /\Aset\s+(\w+)\s*=\s*(.+)\z/m
|
|
92
111
|
INCLUDE_RE = /\Ainclude\s+["'](.+?)["'](?:\s+with\s+(.+))?\z/
|
|
93
112
|
MACRO_RE = /\Amacro\s+(\w+)\s*\(([^)]*)\)/
|
|
113
|
+
# {% live "name" poll N | sse | ws "path" [src "url"] %}
|
|
114
|
+
LIVE_RE = /\Alive\s+["']([^"']+)["'](.*)\z/m
|
|
115
|
+
LIVE_WS_RE = /ws\s+["']([^"']+)["']/
|
|
116
|
+
LIVE_SRC_RE = /src\s+["']([^"']+)["']/
|
|
117
|
+
|
|
118
|
+
# Escape a value for use inside an HTML attribute on a live marker.
|
|
119
|
+
# Byte-identical order to the Python master / PHP liveAttr so the emitted
|
|
120
|
+
# marker element matches across all four frameworks.
|
|
121
|
+
def self.live_attr(value)
|
|
122
|
+
value.to_s.gsub("&", "&").gsub('"', """)
|
|
123
|
+
.gsub("<", "<").gsub(">", ">")
|
|
124
|
+
end
|
|
94
125
|
FROM_IMPORT_RE = /\Afrom\s+["'](.+?)["']\s+import\s+(.+)/
|
|
95
126
|
CACHE_RE = /\Acache\s+["'](.+?)["']\s*(\d+)?/
|
|
96
127
|
SPACELESS_RE = />\s+</
|
|
@@ -587,6 +618,9 @@ module Tina4
|
|
|
587
618
|
when "cache"
|
|
588
619
|
result, i = handle_cache(tokens, i, context)
|
|
589
620
|
output << result
|
|
621
|
+
when "live"
|
|
622
|
+
result, i = handle_live(tokens, i, context)
|
|
623
|
+
output << result
|
|
590
624
|
when "spaceless"
|
|
591
625
|
result, i = handle_spaceless(tokens, i, context)
|
|
592
626
|
output << result
|
|
@@ -1794,6 +1828,181 @@ module Tina4
|
|
|
1794
1828
|
[rendered, i]
|
|
1795
1829
|
end
|
|
1796
1830
|
|
|
1831
|
+
# Handle {% live "name" poll N | sse | ws "path" [src "url"] %}...{% endlive %}.
|
|
1832
|
+
#
|
|
1833
|
+
# Server-rendered live region. The body renders once for first paint, is
|
|
1834
|
+
# registered under <name> so the /__frond/live/<name> endpoint (or a
|
|
1835
|
+
# live_source provider) can re-render it, and is wrapped in a marker element
|
|
1836
|
+
# that frond.js wires to the chosen transport (poll / sse / ws). Mirrors the
|
|
1837
|
+
# Python master's _handle_live and PHP's handleLive.
|
|
1838
|
+
def handle_live(tokens, start, context)
|
|
1839
|
+
content, _, _ = strip_tag(tokens[start][1])
|
|
1840
|
+
m = LIVE_RE.match(content)
|
|
1841
|
+
raise 'live: expected {% live "name" poll N | sse | ws "path" %}' unless m
|
|
1842
|
+
|
|
1843
|
+
name = m[1]
|
|
1844
|
+
rest = (m[2] || "").strip
|
|
1845
|
+
parts = rest.split
|
|
1846
|
+
mode = parts[0] || ""
|
|
1847
|
+
|
|
1848
|
+
src = nil
|
|
1849
|
+
if (sm = LIVE_SRC_RE.match(rest))
|
|
1850
|
+
src = sm[1]
|
|
1851
|
+
end
|
|
1852
|
+
if src && (src.start_with?("http://") || src.start_with?("https://") || src.start_with?("//"))
|
|
1853
|
+
raise "live: src must be a same-origin path, not an absolute URL"
|
|
1854
|
+
end
|
|
1855
|
+
|
|
1856
|
+
interval = nil
|
|
1857
|
+
ws_path = nil
|
|
1858
|
+
case mode
|
|
1859
|
+
when "poll"
|
|
1860
|
+
raise 'live: poll requires seconds, e.g. {% live "x" poll 5 %}' unless parts[1]&.match?(/\A\d+\z/)
|
|
1861
|
+
interval = parts[1].to_i
|
|
1862
|
+
when "sse"
|
|
1863
|
+
# no extra config
|
|
1864
|
+
when "ws"
|
|
1865
|
+
wm = LIVE_WS_RE.match(rest)
|
|
1866
|
+
raise 'live: ws requires a path, e.g. {% live "x" ws "/ws/x" %}' unless wm
|
|
1867
|
+
ws_path = wm[1]
|
|
1868
|
+
else
|
|
1869
|
+
raise %(live: unknown transport "#{mode}" (use poll N, sse, or ws "path"))
|
|
1870
|
+
end
|
|
1871
|
+
|
|
1872
|
+
# Collect body tokens up to {% endlive %}. Nested live is unsupported.
|
|
1873
|
+
body_tokens = []
|
|
1874
|
+
i = start + 1
|
|
1875
|
+
while i < tokens.length
|
|
1876
|
+
if tokens[i][0] == BLOCK
|
|
1877
|
+
tc, _, _ = strip_tag(tokens[i][1])
|
|
1878
|
+
tag = tc.split[0] || ""
|
|
1879
|
+
raise "live: nested live blocks are not supported" if tag == "live"
|
|
1880
|
+
if tag == "endlive"
|
|
1881
|
+
i += 1
|
|
1882
|
+
break
|
|
1883
|
+
end
|
|
1884
|
+
body_tokens << tokens[i]
|
|
1885
|
+
else
|
|
1886
|
+
body_tokens << tokens[i]
|
|
1887
|
+
end
|
|
1888
|
+
i += 1
|
|
1889
|
+
end
|
|
1890
|
+
|
|
1891
|
+
# Register the raw body source so the auto endpoint can re-render it.
|
|
1892
|
+
@@class_live_fragments[name] = body_tokens.map { |t| t[1] }.join
|
|
1893
|
+
|
|
1894
|
+
endpoint = src || ("/__frond/live/" + name)
|
|
1895
|
+
attrs = [%(data-frond-live="#{Frond.live_attr(name)}"), %(id="live-#{Frond.live_attr(name)}")]
|
|
1896
|
+
case mode
|
|
1897
|
+
when "poll"
|
|
1898
|
+
attrs << 'data-mode="poll"' << %(data-interval="#{interval}") << %(data-src="#{Frond.live_attr(endpoint)}")
|
|
1899
|
+
when "sse"
|
|
1900
|
+
attrs << 'data-mode="sse"' << %(data-src="#{Frond.live_attr(endpoint)}")
|
|
1901
|
+
when "ws"
|
|
1902
|
+
@@class_live_ws_paths[name] = ws_path
|
|
1903
|
+
attrs << 'data-mode="ws"' << %(data-ws="#{Frond.live_attr(ws_path)}")
|
|
1904
|
+
end
|
|
1905
|
+
|
|
1906
|
+
first_paint = render_tokens(body_tokens.dup, context)
|
|
1907
|
+
[%(<div #{attrs.join(' ')}>#{first_paint}</div>), i]
|
|
1908
|
+
end
|
|
1909
|
+
|
|
1910
|
+
# -- Live-block class API (mirrors Python master + PHP static facade) -----
|
|
1911
|
+
|
|
1912
|
+
# Re-render a registered {% live %} fragment by name with fresh data.
|
|
1913
|
+
# Returns the rendered HTML, or nil if no fragment is registered under that
|
|
1914
|
+
# name yet (its page has not rendered). The /__frond/live/<name> endpoint
|
|
1915
|
+
# calls this after resolving the provider data.
|
|
1916
|
+
def self.render_live(name, data = {})
|
|
1917
|
+
source = @@class_live_fragments[name]
|
|
1918
|
+
return nil if source.nil?
|
|
1919
|
+
|
|
1920
|
+
new.render_string(source, data || {})
|
|
1921
|
+
end
|
|
1922
|
+
|
|
1923
|
+
# Register a data provider for a {% live %} block. Accepts a block OR a
|
|
1924
|
+
# callable (proc/lambda); it is invoked with the live request on every
|
|
1925
|
+
# refresh so auth re-applies (IDOR guard). Mirrors Python's @live_source.
|
|
1926
|
+
def self.live_source(name, callable = nil, &blk)
|
|
1927
|
+
@@class_live_sources[name] = callable || blk
|
|
1928
|
+
end
|
|
1929
|
+
|
|
1930
|
+
# The provider registered for a live block, or nil.
|
|
1931
|
+
def self.get_live_source(name)
|
|
1932
|
+
@@class_live_sources[name]
|
|
1933
|
+
end
|
|
1934
|
+
|
|
1935
|
+
# Whether a live fragment has been registered (its page rendered).
|
|
1936
|
+
def self.has_live_fragment?(name)
|
|
1937
|
+
@@class_live_fragments.key?(name)
|
|
1938
|
+
end
|
|
1939
|
+
|
|
1940
|
+
# The ws path a live block declared (data-ws), or nil.
|
|
1941
|
+
def self.get_live_ws_path(name)
|
|
1942
|
+
@@class_live_ws_paths[name]
|
|
1943
|
+
end
|
|
1944
|
+
|
|
1945
|
+
# Handle GET /__frond/live/{name}: resolve the provider, run it with the
|
|
1946
|
+
# live request (auth re-applies), re-render the fragment, return via the
|
|
1947
|
+
# response callable. 404 for unknown name / unrendered fragment. Mirrors
|
|
1948
|
+
# Python's live_endpoint and PHP's respondLive.
|
|
1949
|
+
def self.respond_live(request, response, name)
|
|
1950
|
+
provider = @@class_live_sources[name]
|
|
1951
|
+
if !@@class_live_fragments.key?(name) && provider.nil?
|
|
1952
|
+
return response.call("live block not found: #{name}", 404)
|
|
1953
|
+
end
|
|
1954
|
+
|
|
1955
|
+
context = {}
|
|
1956
|
+
unless provider.nil?
|
|
1957
|
+
result = provider.call(request)
|
|
1958
|
+
context = result.is_a?(Hash) ? result : {}
|
|
1959
|
+
end
|
|
1960
|
+
|
|
1961
|
+
html = render_live(name, context)
|
|
1962
|
+
return response.call("live fragment not registered yet: #{name}", 404) if html.nil?
|
|
1963
|
+
|
|
1964
|
+
response.call(html)
|
|
1965
|
+
end
|
|
1966
|
+
|
|
1967
|
+
# Re-render the '<name>' live fragment and push it to connected clients.
|
|
1968
|
+
# Broadcasts a {type,name,html} envelope over WebSocket to the block's
|
|
1969
|
+
# declared data-ws path (else a room named <name>). Returns the rendered
|
|
1970
|
+
# HTML, or nil if the fragment is not registered. Mirrors Python push_live
|
|
1971
|
+
# / PHP pushLive. The broadcast is best-effort — a missing/failed WS engine
|
|
1972
|
+
# never raises into the caller.
|
|
1973
|
+
def self.push_live(name, data = {})
|
|
1974
|
+
html = render_live(name, data)
|
|
1975
|
+
return nil if html.nil?
|
|
1976
|
+
|
|
1977
|
+
envelope = { "type" => "live", "name" => name, "html" => html }.to_json
|
|
1978
|
+
engine = (Tina4::WebSocket.current if defined?(Tina4::WebSocket))
|
|
1979
|
+
if engine
|
|
1980
|
+
begin
|
|
1981
|
+
ws_path = get_live_ws_path(name)
|
|
1982
|
+
if ws_path
|
|
1983
|
+
engine.broadcast(envelope, path: ws_path)
|
|
1984
|
+
else
|
|
1985
|
+
engine.broadcast_to_room(name, envelope)
|
|
1986
|
+
end
|
|
1987
|
+
rescue StandardError => e
|
|
1988
|
+
Tina4::Log.error("push_live(#{name}) broadcast failed: #{e.message}") if defined?(Tina4::Log)
|
|
1989
|
+
end
|
|
1990
|
+
end
|
|
1991
|
+
html
|
|
1992
|
+
end
|
|
1993
|
+
|
|
1994
|
+
# Register the always-on GET /__frond/live/{name} endpoint that re-renders
|
|
1995
|
+
# a live block on demand. Idempotent — guarded against a re-register after a
|
|
1996
|
+
# Router.clear! (specs / hot-reload rescans). Mirrors PHP App::registerLiveEndpoint.
|
|
1997
|
+
def self.register_live_endpoint!
|
|
1998
|
+
return if Tina4::Router.find_route("GET", "/__frond/live/live-probe")
|
|
1999
|
+
|
|
2000
|
+
Tina4::Router.add(
|
|
2001
|
+
"GET", "/__frond/live/{name}",
|
|
2002
|
+
lambda { |request, response, name| Tina4::Frond.respond_live(request, response, name) }
|
|
2003
|
+
)
|
|
2004
|
+
end
|
|
2005
|
+
|
|
1797
2006
|
def handle_spaceless(tokens, start, context)
|
|
1798
2007
|
body_tokens = []
|
|
1799
2008
|
i = start + 1
|
data/lib/tina4/mcp.rb
CHANGED
|
@@ -22,6 +22,7 @@ require "json"
|
|
|
22
22
|
require "socket"
|
|
23
23
|
require "fileutils"
|
|
24
24
|
require "open3"
|
|
25
|
+
require "securerandom"
|
|
25
26
|
|
|
26
27
|
module Tina4
|
|
27
28
|
# ── JSON-RPC 2.0 codec ────────────────────────────────────────────
|
|
@@ -216,6 +217,12 @@ module Tina4
|
|
|
216
217
|
|
|
217
218
|
# ── McpServer ─────────────────────────────────────────────────────
|
|
218
219
|
class McpServer
|
|
220
|
+
# MCP protocol versions this server can speak, newest first. The 2025-*
|
|
221
|
+
# versions are the Streamable HTTP era; 2024-11-05 is the legacy HTTP+SSE
|
|
222
|
+
# transport we still accept for older clients (Claude Desktop et al.).
|
|
223
|
+
SUPPORTED_PROTOCOL_VERSIONS = %w[2025-06-18 2025-03-26 2024-11-05].freeze
|
|
224
|
+
LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS.first
|
|
225
|
+
|
|
219
226
|
attr_reader :path, :name, :version
|
|
220
227
|
|
|
221
228
|
# Class-level registry of all MCP server instances
|
|
@@ -231,6 +238,10 @@ module Tina4
|
|
|
231
238
|
@tools = {}
|
|
232
239
|
@resources = {}
|
|
233
240
|
@initialized = false
|
|
241
|
+
# Streamable HTTP sessions: id => opened-at epoch. `initialize` mints one
|
|
242
|
+
# (returned in the Mcp-Session-Id header); a request bearing an unknown id
|
|
243
|
+
# gets a 404 so the client re-initializes.
|
|
244
|
+
@sessions = {}
|
|
234
245
|
self.class.instances << self
|
|
235
246
|
end
|
|
236
247
|
|
|
@@ -292,23 +303,102 @@ module Tina4
|
|
|
292
303
|
end
|
|
293
304
|
end
|
|
294
305
|
|
|
295
|
-
#
|
|
306
|
+
# ── Streamable HTTP transport ─────────────────────────────────────
|
|
307
|
+
# Session lifecycle + protocol negotiation shared by every transport
|
|
308
|
+
# entry point so the wire behaviour is identical across all 4 frameworks.
|
|
309
|
+
|
|
310
|
+
# Mint a new session id and remember it. Called on `initialize`.
|
|
311
|
+
def open_session
|
|
312
|
+
sid = SecureRandom.hex(16)
|
|
313
|
+
@sessions[sid] = Time.now.to_f
|
|
314
|
+
sid
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# True when +session_id+ was issued by this server and is still open.
|
|
318
|
+
def is_valid_session(session_id)
|
|
319
|
+
!session_id.to_s.empty? && @sessions.key?(session_id)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Forget a session (client DELETE or SSE stream close). Returns true when a
|
|
323
|
+
# live session was actually removed.
|
|
324
|
+
def close_session(session_id)
|
|
325
|
+
!@sessions.delete(session_id).nil?
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Pick the protocol version to run on. Echo the client's requested version
|
|
329
|
+
# when we support it (proper negotiation), else fall back to the newest
|
|
330
|
+
# version we speak so an unversioned/old client still connects.
|
|
331
|
+
def negotiate_protocol_version(requested)
|
|
332
|
+
return requested if SUPPORTED_PROTOCOL_VERSIONS.include?(requested)
|
|
333
|
+
|
|
334
|
+
LATEST_PROTOCOL_VERSION
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Transport-agnostic Streamable HTTP POST handler. Every transport calls
|
|
338
|
+
# this so the wire behaviour stays identical:
|
|
339
|
+
# - `initialize` mints a session id, returned in the Mcp-Session-Id header.
|
|
340
|
+
# - a non-initialize request carrying an unknown session id is a 404
|
|
341
|
+
# (JSON-RPC error) so the client knows to re-initialize.
|
|
342
|
+
# - a notification / response-only POST (no id) yields 202 with an empty
|
|
343
|
+
# body.
|
|
344
|
+
# - anything else returns 200 with the JSON-RPC response as
|
|
345
|
+
# application/json (the MCP Streamable HTTP spec permits a POST that
|
|
346
|
+
# resolves to a single response to answer inline).
|
|
347
|
+
# Returns { status:, headers:, body: }.
|
|
348
|
+
def dispatch_http(raw_data, session_id = "")
|
|
349
|
+
is_init = peek_method(raw_data) == "initialize"
|
|
350
|
+
if !is_init && !session_id.to_s.empty? && !is_valid_session(session_id)
|
|
351
|
+
return {
|
|
352
|
+
status: 404,
|
|
353
|
+
headers: {},
|
|
354
|
+
body: McpProtocol.encode_error(nil, McpProtocol::INVALID_REQUEST, "session not found")
|
|
355
|
+
}
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
body = handle_message(raw_data)
|
|
359
|
+
headers = {}
|
|
360
|
+
headers["Mcp-Session-Id"] = open_session if is_init
|
|
361
|
+
return { status: 202, headers: headers, body: "" } if body.nil? || body.empty?
|
|
362
|
+
|
|
363
|
+
{ status: 200, headers: headers, body: body }
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Register HTTP routes for this MCP server on the Tina4 router. Custom
|
|
367
|
+
# (developer-created) MCP servers use this; the built-in dev server mounts
|
|
368
|
+
# the same transport via DevAdmin's dispatcher.
|
|
296
369
|
def register_routes(router = nil)
|
|
297
|
-
server
|
|
298
|
-
|
|
299
|
-
|
|
370
|
+
server = self
|
|
371
|
+
base_path = @path
|
|
372
|
+
msg_path = "#{@path}/message"
|
|
373
|
+
sse_path = "#{@path}/sse"
|
|
374
|
+
|
|
375
|
+
# Streamable HTTP (current transport) — single POST endpoint.
|
|
376
|
+
Tina4::Router.post(base_path) do |request, response|
|
|
377
|
+
out = server.dispatch_http(request.body, (request.header("mcp-session-id") || "").to_s)
|
|
378
|
+
out[:headers].each { |k, v| response.header(k, v) }
|
|
379
|
+
response.call(out[:body], out[:status], "application/json")
|
|
380
|
+
end
|
|
300
381
|
|
|
382
|
+
# DELETE terminates the session (Streamable HTTP spec).
|
|
383
|
+
Tina4::Router.delete(base_path) do |request, response|
|
|
384
|
+
server.close_session((request.header("mcp-session-id") || "").to_s)
|
|
385
|
+
response.call("", 204)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# GET on the endpoint is a server->client stream we do not open here.
|
|
389
|
+
Tina4::Router.get(base_path) do |_request, response|
|
|
390
|
+
response.header("allow", "POST, DELETE")
|
|
391
|
+
response.call({ "error" => "Method Not Allowed" }, 405, "application/json")
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Legacy HTTP+SSE POST target — inline, session-lenient.
|
|
301
395
|
Tina4::Router.post(msg_path) do |request, response|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
if result.nil? || result.empty?
|
|
306
|
-
response.call("", 204)
|
|
307
|
-
else
|
|
308
|
-
response.call(JSON.parse(result))
|
|
309
|
-
end
|
|
396
|
+
out = server.dispatch_http(request.body, "")
|
|
397
|
+
out[:headers].each { |k, v| response.header(k, v) }
|
|
398
|
+
response.call(out[:body], out[:status], "application/json")
|
|
310
399
|
end
|
|
311
400
|
|
|
401
|
+
# Legacy HTTP+SSE handshake — one-shot endpoint event.
|
|
312
402
|
Tina4::Router.get(sse_path) do |request, response|
|
|
313
403
|
endpoint_url = "#{request.url.sub(%r{/sse\z}, "")}/message"
|
|
314
404
|
sse_data = "event: endpoint\ndata: #{endpoint_url}\n\n"
|
|
@@ -334,7 +424,8 @@ module Tina4
|
|
|
334
424
|
config["mcpServers"] ||= {}
|
|
335
425
|
server_key = @name.downcase.gsub(" ", "-")
|
|
336
426
|
config["mcpServers"][server_key] = {
|
|
337
|
-
"
|
|
427
|
+
"type" => "http",
|
|
428
|
+
"url" => "http://localhost:#{port}#{@path}"
|
|
338
429
|
}
|
|
339
430
|
|
|
340
431
|
File.write(config_file, JSON.pretty_generate(config) + "\n")
|
|
@@ -352,10 +443,24 @@ module Tina4
|
|
|
352
443
|
|
|
353
444
|
private
|
|
354
445
|
|
|
355
|
-
|
|
446
|
+
# Read the JSON-RPC `method` from a raw request without dispatching. Used by
|
|
447
|
+
# the transport to spot `initialize` (mint a session) before handing the
|
|
448
|
+
# message to handle_message. Accepts a parsed Hash or a raw JSON string.
|
|
449
|
+
def peek_method(raw_data)
|
|
450
|
+
obj = if raw_data.is_a?(Hash)
|
|
451
|
+
raw_data
|
|
452
|
+
else
|
|
453
|
+
str = raw_data.to_s
|
|
454
|
+
str.empty? ? {} : (JSON.parse(str) rescue nil)
|
|
455
|
+
end
|
|
456
|
+
obj.is_a?(Hash) ? obj["method"] : nil
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def _handle_initialize(params)
|
|
356
460
|
@initialized = true
|
|
461
|
+
requested = params.is_a?(Hash) ? params["protocolVersion"] : nil
|
|
357
462
|
{
|
|
358
|
-
"protocolVersion" =>
|
|
463
|
+
"protocolVersion" => negotiate_protocol_version(requested),
|
|
359
464
|
"capabilities" => {
|
|
360
465
|
"tools" => { "listChanged" => false },
|
|
361
466
|
"resources" => { "subscribe" => false, "listChanged" => false }
|
|
@@ -558,6 +558,132 @@ var _frondModule = (() => {
|
|
|
558
558
|
}
|
|
559
559
|
});
|
|
560
560
|
}
|
|
561
|
+
function _liveKey(el) {
|
|
562
|
+
const d = el.dataset;
|
|
563
|
+
return d && d.key ? d.key : null;
|
|
564
|
+
}
|
|
565
|
+
function _liveSyncAttrs(oldNode, newNode) {
|
|
566
|
+
const na = newNode.attributes;
|
|
567
|
+
for (let i = 0; i < na.length; i++) {
|
|
568
|
+
const a = na[i];
|
|
569
|
+
if (oldNode.getAttribute(a.name) !== a.value) oldNode.setAttribute(a.name, a.value);
|
|
570
|
+
}
|
|
571
|
+
const oa = Array.prototype.slice.call(oldNode.attributes);
|
|
572
|
+
oa.forEach(function(a) {
|
|
573
|
+
if (!newNode.hasAttribute(a.name)) oldNode.removeAttribute(a.name);
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
function _liveMorphNode(oldNode, newNode) {
|
|
577
|
+
const tag = newNode.tagName;
|
|
578
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
|
579
|
+
_liveSyncAttrs(oldNode, newNode);
|
|
580
|
+
if (oldNode.children.length || newNode.children.length) {
|
|
581
|
+
_liveReconcile(oldNode, newNode);
|
|
582
|
+
} else if (oldNode.innerHTML !== newNode.innerHTML) {
|
|
583
|
+
oldNode.innerHTML = newNode.innerHTML;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
function _liveReconcile(parent, next) {
|
|
587
|
+
const oldKids = Array.prototype.slice.call(parent.children);
|
|
588
|
+
const newKids = Array.prototype.slice.call(next.children);
|
|
589
|
+
const oldByKey = {};
|
|
590
|
+
oldKids.forEach(function(c) {
|
|
591
|
+
const k = _liveKey(c);
|
|
592
|
+
if (k) oldByKey[k] = c;
|
|
593
|
+
});
|
|
594
|
+
const order = [];
|
|
595
|
+
for (let i = 0; i < newKids.length; i++) {
|
|
596
|
+
const nk = newKids[i];
|
|
597
|
+
const k = _liveKey(nk);
|
|
598
|
+
let match = null;
|
|
599
|
+
if (k && oldByKey[k]) {
|
|
600
|
+
match = oldByKey[k];
|
|
601
|
+
} else if (!k && oldKids[i] && !_liveKey(oldKids[i]) && oldKids[i].tagName === nk.tagName) {
|
|
602
|
+
match = oldKids[i];
|
|
603
|
+
}
|
|
604
|
+
if (match && match.tagName === nk.tagName) {
|
|
605
|
+
_liveMorphNode(match, nk);
|
|
606
|
+
reused.push(match);
|
|
607
|
+
order.push(match);
|
|
608
|
+
} else {
|
|
609
|
+
order.push(nk);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
let cursor = parent.firstElementChild;
|
|
613
|
+
for (let i = 0; i < order.length; i++) {
|
|
614
|
+
const node = order[i];
|
|
615
|
+
if (node === cursor) {
|
|
616
|
+
cursor = cursor.nextElementSibling;
|
|
617
|
+
} else {
|
|
618
|
+
parent.insertBefore(node, cursor);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
oldKids.forEach(function(c) {
|
|
622
|
+
if (order.indexOf(c) === -1 && c.parentNode === parent) parent.removeChild(c);
|
|
623
|
+
});
|
|
624
|
+
void reused;
|
|
625
|
+
}
|
|
626
|
+
function _liveSwap(container, html) {
|
|
627
|
+
const tmp = document.createElement("div");
|
|
628
|
+
tmp.innerHTML = html;
|
|
629
|
+
if (!tmp.children.length || !container.children.length) {
|
|
630
|
+
container.innerHTML = html;
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
_liveReconcile(container, tmp);
|
|
634
|
+
}
|
|
635
|
+
function _liveWsUrl(path) {
|
|
636
|
+
if (/^wss?:\/\//.test(path)) return path;
|
|
637
|
+
const proto = typeof location !== "undefined" && location.protocol === "https:" ? "wss" : "ws";
|
|
638
|
+
return proto + "://" + location.host + path;
|
|
639
|
+
}
|
|
640
|
+
function _liveExtract(msg, name) {
|
|
641
|
+
if (msg && typeof msg === "object") {
|
|
642
|
+
if (msg.type === "live") {
|
|
643
|
+
if (name && msg.name && msg.name !== name) return null;
|
|
644
|
+
return msg.html != null ? String(msg.html) : null;
|
|
645
|
+
}
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
return typeof msg === "string" ? msg : null;
|
|
649
|
+
}
|
|
650
|
+
function liveInit(root) {
|
|
651
|
+
if (typeof document === "undefined") return;
|
|
652
|
+
const scope = root || document;
|
|
653
|
+
const blocks = scope.querySelectorAll("[data-frond-live]");
|
|
654
|
+
Array.prototype.slice.call(blocks).forEach(function(el) {
|
|
655
|
+
if (el.__frondLive) return;
|
|
656
|
+
el.__frondLive = true;
|
|
657
|
+
const mode = el.getAttribute("data-mode");
|
|
658
|
+
const name = el.getAttribute("data-frond-live");
|
|
659
|
+
if (mode === "poll") {
|
|
660
|
+
const src = el.getAttribute("data-src");
|
|
661
|
+
const interval = (parseInt(el.getAttribute("data-interval"), 10) || 5) * 1e3;
|
|
662
|
+
const timer = setInterval(function() {
|
|
663
|
+
if (typeof document !== "undefined" && document.hidden) return;
|
|
664
|
+
request(src, { method: "GET", onSuccess: function(data) {
|
|
665
|
+
_liveSwap(el, typeof data === "string" ? data : String(data));
|
|
666
|
+
} });
|
|
667
|
+
}, interval);
|
|
668
|
+
el.__frondLiveStop = function() {
|
|
669
|
+
clearInterval(timer);
|
|
670
|
+
};
|
|
671
|
+
} else if (mode === "ws") {
|
|
672
|
+
const sock = wsConnect(_liveWsUrl(el.getAttribute("data-ws")));
|
|
673
|
+
sock.on("message", function(msg) {
|
|
674
|
+
const h = _liveExtract(msg, name);
|
|
675
|
+
if (h !== null) _liveSwap(el, h);
|
|
676
|
+
});
|
|
677
|
+
el.__frondLiveStop = function() {
|
|
678
|
+
sock.close();
|
|
679
|
+
};
|
|
680
|
+
} else if (mode === "sse") {
|
|
681
|
+
if (typeof console !== "undefined" && console.warn) {
|
|
682
|
+
console.warn("[frond.live] sse transport is not wired yet (v1 supports poll and ws); block '" + name + "' shows first paint only. Use poll or ws.");
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
}
|
|
561
687
|
var frond = {
|
|
562
688
|
/** Core HTTP request. */
|
|
563
689
|
request,
|
|
@@ -573,6 +699,8 @@ var _frondModule = (() => {
|
|
|
573
699
|
ws: wsConnect,
|
|
574
700
|
/** Server-Sent Events with auto-reconnect. */
|
|
575
701
|
sse: sseConnect,
|
|
702
|
+
/** Wire {% live %} blocks (poll/ws) with keyed morph. Auto-runs on DOMContentLoaded. */
|
|
703
|
+
live: liveInit,
|
|
576
704
|
/** Cookie helpers: get, set, remove. */
|
|
577
705
|
cookie,
|
|
578
706
|
/** Display alert message in #message element. */
|
|
@@ -593,8 +721,17 @@ var _frondModule = (() => {
|
|
|
593
721
|
};
|
|
594
722
|
if (typeof window !== "undefined") {
|
|
595
723
|
window.frond = frond;
|
|
724
|
+
if (typeof document !== "undefined") {
|
|
725
|
+
if (document.readyState === "loading") {
|
|
726
|
+
document.addEventListener("DOMContentLoaded", function() {
|
|
727
|
+
liveInit();
|
|
728
|
+
});
|
|
729
|
+
} else {
|
|
730
|
+
liveInit();
|
|
731
|
+
}
|
|
732
|
+
}
|
|
596
733
|
}
|
|
597
734
|
return __toCommonJS(frond_exports);
|
|
598
735
|
})();
|
|
599
|
-
/* Frond v2.
|
|
736
|
+
/* Frond v2.2.0 - tina4.com */
|
|
600
737
|
//# sourceMappingURL=frond.js.map
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
var _frondModule=(()=>{var b=Object.defineProperty;var k=Object.getOwnPropertyDescriptor;var x=Object.getOwnPropertyNames;var C=Object.prototype.hasOwnProperty;var O=(o,s)=>{for(var e in s)b(o,e,{get:s[e],enumerable:!0})},M=(o,s,e,t)=>{if(s&&typeof s=="object"||typeof s=="function")for(let n of x(s))!C.call(o,n)&&n!==e&&b(o,n,{get:()=>s[n],enumerable:!(t=k(s,n))||t.enumerable});return o};var q=o=>M(b({},"__esModule",{value:!0}),o);var j={};O(j,{frond:()=>R});var g=null;function w(o,s){let e;typeof s=="function"?e={onSuccess:s}:e=s||{};let t=(e.method||"GET").toUpperCase(),n=new XMLHttpRequest;if(n.open(t,o,!0),g!==null&&n.setRequestHeader("Authorization","Bearer "+g),e.headers)for(let r in e.headers)Object.prototype.hasOwnProperty.call(e.headers,r)&&n.setRequestHeader(r,e.headers[r]);let i=null;e.body!==void 0&&e.body!==null&&(e.body instanceof FormData?i=e.body:typeof e.body=="object"?(i=JSON.stringify(e.body),n.setRequestHeader("Content-Type","application/json; charset=UTF-8")):typeof e.body=="string"&&(i=e.body,n.setRequestHeader("Content-Type","text/plain; charset=UTF-8"))),n.onload=function(){let r=n.getResponseHeader("FreshToken");r&&r!==""&&(g=r);let u=n.response;try{u=JSON.parse(u)}catch{}if(n.responseURL){let c=new URL(o,window.location.href).href;if(n.responseURL!==c){window.location.href=n.responseURL;return}}n.status>=200&&n.status<400?e.onSuccess&&e.onSuccess(u,n.status,n):e.onError&&e.onError(n.status,n)},n.onerror=function(){e.onError&&e.onError(n.status,n)},n.send(i)}function h(o,s){if(!o)return"";let e=new DOMParser,t=o.includes("<html>")?o:"<body>"+o+"</body></html>",i=e.parseFromString(t,"text/html").querySelector("body"),r=i.querySelectorAll("script");if(r.forEach(function(u){u.remove()}),s!==null){let u=document.getElementById(s);return u&&(i.children.length>0?u.replaceChildren.apply(u,Array.from(i.children)):u.innerHTML=i.innerHTML,r.forEach(function(c){let d=document.createElement("script");d.type="text/javascript",d.async=!0,c.src?d.src=c.src:d.textContent=c.textContent,u.appendChild(d)})),""}return r.forEach(function(u){let c=document.createElement("script");c.type="text/javascript",c.async=!0,c.textContent=u.textContent,document.body.appendChild(c)}),i.innerHTML}function H(o,s,e){let t=s||"content";w(o,{method:"GET",onSuccess:function(n,i){if(document.getElementById(t)){let r=h(n,t);e&&e(r,n)}else e&&e(n)}})}function S(o,s,e,t){let n=e||"content";w(o,{method:"POST",body:s,onSuccess:function(i){let r="";if(i&&i.message!==void 0)r=h(i.message,n);else if(document.getElementById(n))r=h(i,n);else{t&&t(i);return}t&&t(r,i)}})}var T={collect:function(o){let s=new FormData,e=document.querySelectorAll("#"+o+" select, #"+o+" input, #"+o+" textarea");for(let t=0;t<e.length;t++){let n=e[t];if(n.name==="formToken"&&g!==null&&(n.value=g),!!n.name)if(n.type==="file"){let i=n.files;if(i)for(let r=0;r<i.length;r++){let u=i[r];if(u!==void 0){let c=n.name;i.length>1&&!c.includes("[")&&(c=c+"[]"),s.append(c,u,u.name)}}}else n.type==="checkbox"||n.type==="radio"?n.checked?s.append(n.name,n.value):n.type!=="radio"&&s.append(n.name,"0"):s.append(n.name,n.value===""?"":n.value)}return s},submit:function(o,s,e,t){let n=T.collect(o);S(s,n,e||"message",t)},show:function(o,s,e,t){let n=o.toUpperCase();(o==="create"||o==="edit")&&(n="GET"),o==="delete"&&(n="DELETE");let i=e||"form";w(s,{method:n,onSuccess:function(r){let u="";if(r&&r.message!==void 0)u=h(r.message,i);else if(document.getElementById(i))u=h(r,i);else{t&&t(r);return}t&&t(u)}})}};function L(o,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,protocols:[],onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},t=null,n=!1,i=e.reconnectDelay,r=0,u=null,c={message:[],open:[],close:[],error:[]},d={status:"connecting",send:function(l){if(!t||t.readyState!==WebSocket.OPEN)throw new Error("[frond] WebSocket is not connected");t.send(typeof l=="string"?l:JSON.stringify(l))},on:function(l,a){return c[l]||(c[l]=[]),c[l].push(a),function(){let f=c[l],m=f.indexOf(a);m>=0&&f.splice(m,1)}},close:function(l,a){n=!0,u&&(clearTimeout(u),u=null),t&&t.close(l||1e3,a||""),d.status="closed"}};function y(l){if(typeof l!="string")return l;try{return JSON.parse(l)}catch{return l}}function p(){!e.reconnect||r>=e.maxReconnectAttempts||(r++,d.status="reconnecting",u=setTimeout(function(){u=null,v()},i),i=Math.min(i*2,e.maxReconnectDelay))}function v(){d.status=r>0?"reconnecting":"connecting";try{t=new WebSocket(o,e.protocols)}catch{d.status="closed";return}t.onopen=function(){d.status="open",r=0,i=e.reconnectDelay,e.onOpen();for(let l of c.open)l()},t.onmessage=function(l){let a=y(l.data);for(let f of c.message)f(a)},t.onclose=function(l){d.status="closed",e.onClose(l.code,l.reason);for(let a of c.close)a(l.code,l.reason);n||p()},t.onerror=function(l){e.onError(l);for(let a of c.error)a(l)}}return v(),d}function D(o,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,events:[],json:!0,onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},t=null,n=!1,i=e.reconnectDelay,r=0,u=null,c={message:[],open:[],close:[],error:[]},d={status:"connecting",on:function(a,f){return c[a]||(c[a]=[]),c[a].push(f),function(){let m=c[a],E=m.indexOf(f);E>=0&&m.splice(E,1)}},close:function(){n=!0,u&&(clearTimeout(u),u=null),t&&(t.close(),t=null),d.status="closed"}};function y(a){if(!e.json)return a;try{return JSON.parse(a)}catch{return a}}function p(a,f){for(let m of c.message)m(a,f||void 0)}function v(){!e.reconnect||r>=e.maxReconnectAttempts||(r++,d.status="reconnecting",u=setTimeout(function(){u=null,l()},i),i=Math.min(i*2,e.maxReconnectDelay))}function l(){d.status=r>0?"reconnecting":"connecting";try{t=new EventSource(o)}catch{d.status="closed";return}t.onopen=function(){d.status="open",r=0,i=e.reconnectDelay,e.onOpen();for(let a of c.open)a(null)},t.onmessage=function(a){p(y(a.data),null)};for(let a of e.events)t.addEventListener(a,function(f){p(y(f.data),a)});t.onerror=function(a){e.onError(a);for(let f of c.error)f(a);if(t&&t.readyState===2){t=null,d.status="closed",e.onClose();for(let f of c.close)f(null);n||v()}}}return l(),d}var W={set:function(o,s,e){let t="";if(e){let n=new Date;n.setTime(n.getTime()+e*24*60*60*1e3),t="; expires="+n.toUTCString()}document.cookie=o+"="+(s||"")+t+"; path=/"},get:function(o){let s=o+"=",e=document.cookie.split(";");for(let t=0;t<e.length;t++){let n=e[t];for(;n.charAt(0)===" ";)n=n.substring(1);if(n.indexOf(s)===0)return n.substring(s.length)}return null},remove:function(o){document.cookie=o+"=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/"}};function A(o,s){let e=document.getElementById("message");if(!e)return;let t=s||"info";e.innerHTML='<div class="alert alert-'+t+' alert-dismissible">'+o+'<button type="button" class="btn-close" data-t4-dismiss="alert">×</button></div>'}function I(o,s,e,t){let n=window.screenLeft!==void 0?window.screenLeft:window.screenX,i=window.screenTop!==void 0?window.screenTop:window.screenY,r=window.innerWidth||document.documentElement.clientWidth||screen.width,u=window.innerHeight||document.documentElement.clientHeight||screen.height,c=r/window.screen.availWidth,d=(r-e)/2/c+n,y=(u-t)/2/c+i,p=window.open(o,s,"directories=no,toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width="+e/c+",height="+t/c+",top="+y+",left="+d);return window.focus&&p&&p.focus(),p}function N(o){if(o.indexOf("No data available")>=0){window.alert("No data available for this report.");return}window.open(o,"_blank","toolbar=no,scrollbars=yes,resizable=yes,width=800,height=600,top=0,left=0")}function U(o,s,e,t){w(o,{method:"POST",body:{query:s,variables:e||{}},onSuccess:function(n){t&&t(n.data||null,n.errors||void 0)},onError:function(n){t&&t(null,[{message:"GraphQL request failed with status "+n}])}})}var R={request:w,load:H,post:S,inject:h,form:T,ws:L,sse:D,cookie:W,message:A,popup:I,report:N,graphql:U,get token(){return g},set token(o){g=o}};typeof window<"u"&&(window.frond=R);return q(j);})();
|
|
2
|
-
/* Frond v2.
|
|
1
|
+
var _frondModule=(()=>{var E=Object.defineProperty;var O=Object.getOwnPropertyDescriptor;var A=Object.getOwnPropertyNames;var H=Object.prototype.hasOwnProperty;var q=(t,s)=>{for(var e in s)E(t,e,{get:s[e],enumerable:!0})},D=(t,s,e,o)=>{if(s&&typeof s=="object"||typeof s=="function")for(let n of A(s))!H.call(t,n)&&n!==e&&E(t,n,{get:()=>s[n],enumerable:!(o=O(s,n))||o.enumerable});return t};var W=t=>D(E({},"__esModule",{value:!0}),t);var z={};q(z,{frond:()=>L});var y=null;function v(t,s){let e;typeof s=="function"?e={onSuccess:s}:e=s||{};let o=(e.method||"GET").toUpperCase(),n=new XMLHttpRequest;if(n.open(o,t,!0),y!==null&&n.setRequestHeader("Authorization","Bearer "+y),e.headers)for(let u in e.headers)Object.prototype.hasOwnProperty.call(e.headers,u)&&n.setRequestHeader(u,e.headers[u]);let c=null;e.body!==void 0&&e.body!==null&&(e.body instanceof FormData?c=e.body:typeof e.body=="object"?(c=JSON.stringify(e.body),n.setRequestHeader("Content-Type","application/json; charset=UTF-8")):typeof e.body=="string"&&(c=e.body,n.setRequestHeader("Content-Type","text/plain; charset=UTF-8"))),n.onload=function(){let u=n.getResponseHeader("FreshToken");u&&u!==""&&(y=u);let r=n.response;try{r=JSON.parse(r)}catch{}if(n.responseURL){let i=new URL(t,window.location.href).href;if(n.responseURL!==i){window.location.href=n.responseURL;return}}n.status>=200&&n.status<400?e.onSuccess&&e.onSuccess(r,n.status,n):e.onError&&e.onError(n.status,n)},n.onerror=function(){e.onError&&e.onError(n.status,n)},n.send(c)}function h(t,s){if(!t)return"";let e=new DOMParser,o=t.includes("<html>")?t:"<body>"+t+"</body></html>",c=e.parseFromString(o,"text/html").querySelector("body"),u=c.querySelectorAll("script");if(u.forEach(function(r){r.remove()}),s!==null){let r=document.getElementById(s);return r&&(c.children.length>0?r.replaceChildren.apply(r,Array.from(c.children)):r.innerHTML=c.innerHTML,u.forEach(function(i){let l=document.createElement("script");l.type="text/javascript",l.async=!0,i.src?l.src=i.src:l.textContent=i.textContent,r.appendChild(l)})),""}return u.forEach(function(r){let i=document.createElement("script");i.type="text/javascript",i.async=!0,i.textContent=r.textContent,document.body.appendChild(i)}),c.innerHTML}function _(t,s,e){let o=s||"content";v(t,{method:"GET",onSuccess:function(n,c){if(document.getElementById(o)){let u=h(n,o);e&&e(u,n)}else e&&e(n)}})}function R(t,s,e,o){let n=e||"content";v(t,{method:"POST",body:s,onSuccess:function(c){let u="";if(c&&c.message!==void 0)u=h(c.message,n);else if(document.getElementById(n))u=h(c,n);else{o&&o(c);return}o&&o(u,c)}})}var x={collect:function(t){let s=new FormData,e=document.querySelectorAll("#"+t+" select, #"+t+" input, #"+t+" textarea");for(let o=0;o<e.length;o++){let n=e[o];if(n.name==="formToken"&&y!==null&&(n.value=y),!!n.name)if(n.type==="file"){let c=n.files;if(c)for(let u=0;u<c.length;u++){let r=c[u];if(r!==void 0){let i=n.name;c.length>1&&!i.includes("[")&&(i=i+"[]"),s.append(i,r,r.name)}}}else n.type==="checkbox"||n.type==="radio"?n.checked?s.append(n.name,n.value):n.type!=="radio"&&s.append(n.name,"0"):s.append(n.name,n.value===""?"":n.value)}return s},submit:function(t,s,e,o){let n=x.collect(t);R(s,n,e||"message",o)},show:function(t,s,e,o){let n=t.toUpperCase();(t==="create"||t==="edit")&&(n="GET"),t==="delete"&&(n="DELETE");let c=e||"form";v(s,{method:n,onSuccess:function(u){let r="";if(u&&u.message!==void 0)r=h(u.message,c);else if(document.getElementById(c))r=h(u,c);else{o&&o(u);return}o&&o(r)}})}};function M(t,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,protocols:[],onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},o=null,n=!1,c=e.reconnectDelay,u=0,r=null,i={message:[],open:[],close:[],error:[]},l={status:"connecting",send:function(f){if(!o||o.readyState!==WebSocket.OPEN)throw new Error("[frond] WebSocket is not connected");o.send(typeof f=="string"?f:JSON.stringify(f))},on:function(f,a){return i[f]||(i[f]=[]),i[f].push(a),function(){let d=i[f],g=d.indexOf(a);g>=0&&d.splice(g,1)}},close:function(f,a){n=!0,r&&(clearTimeout(r),r=null),o&&o.close(f||1e3,a||""),l.status="closed"}};function p(f){if(typeof f!="string")return f;try{return JSON.parse(f)}catch{return f}}function m(){!e.reconnect||u>=e.maxReconnectAttempts||(u++,l.status="reconnecting",r=setTimeout(function(){r=null,w()},c),c=Math.min(c*2,e.maxReconnectDelay))}function w(){l.status=u>0?"reconnecting":"connecting";try{o=new WebSocket(t,e.protocols)}catch{l.status="closed";return}o.onopen=function(){l.status="open",u=0,c=e.reconnectDelay,e.onOpen();for(let f of i.open)f()},o.onmessage=function(f){let a=p(f.data);for(let d of i.message)d(a)},o.onclose=function(f){l.status="closed",e.onClose(f.code,f.reason);for(let a of i.close)a(f.code,f.reason);n||m()},o.onerror=function(f){e.onError(f);for(let a of i.error)a(f)}}return w(),l}function I(t,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,events:[],json:!0,onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},o=null,n=!1,c=e.reconnectDelay,u=0,r=null,i={message:[],open:[],close:[],error:[]},l={status:"connecting",on:function(a,d){return i[a]||(i[a]=[]),i[a].push(d),function(){let g=i[a],T=g.indexOf(d);T>=0&&g.splice(T,1)}},close:function(){n=!0,r&&(clearTimeout(r),r=null),o&&(o.close(),o=null),l.status="closed"}};function p(a){if(!e.json)return a;try{return JSON.parse(a)}catch{return a}}function m(a,d){for(let g of i.message)g(a,d||void 0)}function w(){!e.reconnect||u>=e.maxReconnectAttempts||(u++,l.status="reconnecting",r=setTimeout(function(){r=null,f()},c),c=Math.min(c*2,e.maxReconnectDelay))}function f(){l.status=u>0?"reconnecting":"connecting";try{o=new EventSource(t)}catch{l.status="closed";return}o.onopen=function(){l.status="open",u=0,c=e.reconnectDelay,e.onOpen();for(let a of i.open)a(null)},o.onmessage=function(a){m(p(a.data),null)};for(let a of e.events)o.addEventListener(a,function(d){m(p(d.data),a)});o.onerror=function(a){e.onError(a);for(let d of i.error)d(a);if(o&&o.readyState===2){o=null,l.status="closed",e.onClose();for(let d of i.close)d(null);n||w()}}}return f(),l}var U={set:function(t,s,e){let o="";if(e){let n=new Date;n.setTime(n.getTime()+e*24*60*60*1e3),o="; expires="+n.toUTCString()}document.cookie=t+"="+(s||"")+o+"; path=/"},get:function(t){let s=t+"=",e=document.cookie.split(";");for(let o=0;o<e.length;o++){let n=e[o];for(;n.charAt(0)===" ";)n=n.substring(1);if(n.indexOf(s)===0)return n.substring(s.length)}return null},remove:function(t){document.cookie=t+"=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/"}};function j(t,s){let e=document.getElementById("message");if(!e)return;let o=s||"info";e.innerHTML='<div class="alert alert-'+o+' alert-dismissible">'+t+'<button type="button" class="btn-close" data-t4-dismiss="alert">×</button></div>'}function B(t,s,e,o){let n=window.screenLeft!==void 0?window.screenLeft:window.screenX,c=window.screenTop!==void 0?window.screenTop:window.screenY,u=window.innerWidth||document.documentElement.clientWidth||screen.width,r=window.innerHeight||document.documentElement.clientHeight||screen.height,i=u/window.screen.availWidth,l=(u-e)/2/i+n,p=(r-o)/2/i+c,m=window.open(t,s,"directories=no,toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width="+e/i+",height="+o/i+",top="+p+",left="+l);return window.focus&&m&&m.focus(),m}function P(t){if(t.indexOf("No data available")>=0){window.alert("No data available for this report.");return}window.open(t,"_blank","toolbar=no,scrollbars=yes,resizable=yes,width=800,height=600,top=0,left=0")}function F(t,s,e,o){v(t,{method:"POST",body:{query:s,variables:e||{}},onSuccess:function(n){o&&o(n.data||null,n.errors||void 0)},onError:function(n){o&&o(null,[{message:"GraphQL request failed with status "+n}])}})}function b(t){let s=t.dataset;return s&&s.key?s.key:null}function X(t,s){let e=s.attributes;for(let n=0;n<e.length;n++){let c=e[n];t.getAttribute(c.name)!==c.value&&t.setAttribute(c.name,c.value)}Array.prototype.slice.call(t.attributes).forEach(function(n){s.hasAttribute(n.name)||t.removeAttribute(n.name)})}function G(t,s){let e=s.tagName;e==="INPUT"||e==="TEXTAREA"||e==="SELECT"||(X(t,s),t.children.length||s.children.length?C(t,s):t.innerHTML!==s.innerHTML&&(t.innerHTML=s.innerHTML))}function C(t,s){let e=Array.prototype.slice.call(t.children),o=Array.prototype.slice.call(s.children),n={};e.forEach(function(r){let i=b(r);i&&(n[i]=r)});let c=[];for(let r=0;r<o.length;r++){let i=o[r],l=b(i),p=null;l&&n[l]?p=n[l]:!l&&e[r]&&!b(e[r])&&e[r].tagName===i.tagName&&(p=e[r]),p&&p.tagName===i.tagName?(G(p,i),reused.push(p),c.push(p)):c.push(i)}let u=t.firstElementChild;for(let r=0;r<c.length;r++){let i=c[r];i===u?u=u.nextElementSibling:t.insertBefore(i,u)}e.forEach(function(r){c.indexOf(r)===-1&&r.parentNode===t&&t.removeChild(r)}),reused}function k(t,s){let e=document.createElement("div");if(e.innerHTML=s,!e.children.length||!t.children.length){t.innerHTML=s;return}C(t,e)}function J(t){return/^wss?:\/\//.test(t)?t:(typeof location<"u"&&location.protocol==="https:"?"wss":"ws")+"://"+location.host+t}function N(t,s){return t&&typeof t=="object"?t.type==="live"?s&&t.name&&t.name!==s?null:t.html!=null?String(t.html):null:null:typeof t=="string"?t:null}function S(t){if(typeof document>"u")return;let e=(t||document).querySelectorAll("[data-frond-live]");Array.prototype.slice.call(e).forEach(function(o){if(o.__frondLive)return;o.__frondLive=!0;let n=o.getAttribute("data-mode"),c=o.getAttribute("data-frond-live");if(n==="poll"){let u=o.getAttribute("data-src"),r=(parseInt(o.getAttribute("data-interval"),10)||5)*1e3,i=setInterval(function(){typeof document<"u"&&document.hidden||v(u,{method:"GET",onSuccess:function(l){k(o,typeof l=="string"?l:String(l))}})},r);o.__frondLiveStop=function(){clearInterval(i)}}else if(n==="ws"){let u=M(J(o.getAttribute("data-ws")));u.on("message",function(r){let i=N(r,c);i!==null&&k(o,i)}),o.__frondLiveStop=function(){u.close()}}else n==="sse"&&typeof console<"u"&&console.warn&&console.warn("[frond.live] sse transport is not wired yet (v1 supports poll and ws); block '"+c+"' shows first paint only. Use poll or ws.")})}var L={request:v,load:_,post:R,inject:h,form:x,ws:M,sse:I,live:S,cookie:U,message:j,popup:B,report:P,graphql:F,get token(){return y},set token(t){y=t}};typeof window<"u"&&(window.frond=L,typeof document<"u"&&(document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){S()}):S()));return W(z);})();
|
|
2
|
+
/* Frond v2.2.0 - tina4.com */
|
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -34,8 +34,11 @@ module Tina4
|
|
|
34
34
|
fallback = Dir.exist?(FRAMEWORK_PUBLIC_DIR) ? [FRAMEWORK_PUBLIC_DIR] : []
|
|
35
35
|
@static_roots = (project_roots + fallback).freeze
|
|
36
36
|
|
|
37
|
-
# Shared WebSocket engine for route-based WS handling
|
|
37
|
+
# Shared WebSocket engine for route-based WS handling. Publish it as the
|
|
38
|
+
# process-wide "current" engine so Frond.push_live (and other framework
|
|
39
|
+
# code) can broadcast live-block updates without a threaded reference.
|
|
38
40
|
@websocket_engine = Tina4::WebSocket.new
|
|
41
|
+
Tina4::WebSocket.current = @websocket_engine
|
|
39
42
|
|
|
40
43
|
# Register the dev-reload WebSocket route (debug mode only) so a browser
|
|
41
44
|
# handshake to /__dev_reload is accepted and held open by the connection
|
data/lib/tina4/scss_compiler.rb
CHANGED
|
@@ -111,6 +111,10 @@ module Tina4
|
|
|
111
111
|
content = content.gsub("$#{name}", value)
|
|
112
112
|
end
|
|
113
113
|
|
|
114
|
+
# Resolve color functions (lighten/darken/rgba/rgb/mix) — after variable
|
|
115
|
+
# substitution so a $colour arg is already a hex literal (issue #124).
|
|
116
|
+
content = resolve_color_functions(content)
|
|
117
|
+
|
|
114
118
|
# Handle nesting (basic single-level)
|
|
115
119
|
content = flatten_nesting(content)
|
|
116
120
|
|
|
@@ -120,6 +124,109 @@ module Tina4
|
|
|
120
124
|
content
|
|
121
125
|
end
|
|
122
126
|
|
|
127
|
+
# Resolve lighten/darken/rgba/rgb/mix color functions. rgba(<hex>, a) is
|
|
128
|
+
# the damaging case (issue #124): the functional rgba() notation cannot
|
|
129
|
+
# take a hex, so rgba(#0f3460, 0.12) is invalid CSS and browsers drop the
|
|
130
|
+
# whole declaration. Convert the hex to its r,g,b components. hex_to_rgb /
|
|
131
|
+
# adjust_lightness are regex-free so they never clobber the match globals.
|
|
132
|
+
def resolve_color_functions(content)
|
|
133
|
+
content = content.gsub(/lighten\(\s*([^,]+)\s*,\s*([^)]+)\s*\)/) do
|
|
134
|
+
adjust_lightness(Regexp.last_match(1).strip, Regexp.last_match(2).strip.chomp("%").to_f / 100)
|
|
135
|
+
end
|
|
136
|
+
content = content.gsub(/darken\(\s*([^,]+)\s*,\s*([^)]+)\s*\)/) do
|
|
137
|
+
adjust_lightness(Regexp.last_match(1).strip, -(Regexp.last_match(2).strip.chomp("%").to_f / 100))
|
|
138
|
+
end
|
|
139
|
+
# rgba(<hex>, <alpha>) — only the two-arg hex form; leave rgba(r,g,b,a).
|
|
140
|
+
content = content.gsub(/rgba\(\s*(#[0-9a-fA-F]{3,8})\s*,\s*([\d.]+)\s*\)/) do
|
|
141
|
+
hex = Regexp.last_match(1)
|
|
142
|
+
alpha = Regexp.last_match(2).strip
|
|
143
|
+
whole = Regexp.last_match(0)
|
|
144
|
+
rgb = hex_to_rgb(hex)
|
|
145
|
+
rgb ? "rgba(#{rgb[0]}, #{rgb[1]}, #{rgb[2]}, #{alpha})" : whole
|
|
146
|
+
end
|
|
147
|
+
content = content.gsub(/rgb\(\s*(#[0-9a-fA-F]{3,8})\s*\)/) do
|
|
148
|
+
hex = Regexp.last_match(1)
|
|
149
|
+
whole = Regexp.last_match(0)
|
|
150
|
+
rgb = hex_to_rgb(hex)
|
|
151
|
+
rgb ? "rgb(#{rgb[0]}, #{rgb[1]}, #{rgb[2]})" : whole
|
|
152
|
+
end
|
|
153
|
+
# mix(<c1>, <c2>[, <weight>]) — Sass weight is c1's proportion (default 50%).
|
|
154
|
+
content.gsub(/mix\(\s*(#[0-9a-fA-F]{3,8})\s*,\s*(#[0-9a-fA-F]{3,8})\s*(?:,\s*([\d.]+%?)\s*)?\)/) do
|
|
155
|
+
hex1 = Regexp.last_match(1)
|
|
156
|
+
hex2 = Regexp.last_match(2)
|
|
157
|
+
weight = Regexp.last_match(3)
|
|
158
|
+
whole = Regexp.last_match(0)
|
|
159
|
+
c1 = hex_to_rgb(hex1)
|
|
160
|
+
c2 = hex_to_rgb(hex2)
|
|
161
|
+
if c1 && c2
|
|
162
|
+
w = weight ? weight.chomp("%").to_f / 100 : 0.5
|
|
163
|
+
mixed = (0..2).map { |i| (c1[i] * w + c2[i] * (1 - w)).round }
|
|
164
|
+
format("#%02x%02x%02x", *mixed)
|
|
165
|
+
else
|
|
166
|
+
whole
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Parse a #rgb / #rrggbb hex string into an [r, g, b] int array, or nil.
|
|
172
|
+
# Regex-free (uses Integer()) so it never touches the match globals.
|
|
173
|
+
def hex_to_rgb(color)
|
|
174
|
+
c = color.strip.delete_prefix("#")
|
|
175
|
+
c = c.chars.map { |ch| ch * 2 }.join if c.length == 3
|
|
176
|
+
return nil unless c.length == 6
|
|
177
|
+
|
|
178
|
+
[Integer(c[0, 2], 16), Integer(c[2, 2], 16), Integer(c[4, 2], 16)]
|
|
179
|
+
rescue ArgumentError
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Adjust the HSL lightness of a hex color by `amount` (-1..1), return hex.
|
|
184
|
+
# Truncates (to_i) to match the Python master's int(x*255) byte-for-byte.
|
|
185
|
+
def adjust_lightness(color, amount)
|
|
186
|
+
rgb = hex_to_rgb(color)
|
|
187
|
+
return color if rgb.nil?
|
|
188
|
+
|
|
189
|
+
r, g, b = rgb.map { |v| v / 255.0 }
|
|
190
|
+
max = [r, g, b].max
|
|
191
|
+
min = [r, g, b].min
|
|
192
|
+
l = (max + min) / 2.0
|
|
193
|
+
d = max - min
|
|
194
|
+
h = 0.0
|
|
195
|
+
s = 0.0
|
|
196
|
+
if d != 0.0
|
|
197
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
|
198
|
+
h = if max == r
|
|
199
|
+
(g - b) / d + (g < b ? 6 : 0)
|
|
200
|
+
elsif max == g
|
|
201
|
+
(b - r) / d + 2
|
|
202
|
+
else
|
|
203
|
+
(r - g) / d + 4
|
|
204
|
+
end
|
|
205
|
+
h /= 6.0
|
|
206
|
+
end
|
|
207
|
+
l = [0.0, [1.0, l + amount].min].max
|
|
208
|
+
if s.zero?
|
|
209
|
+
r = g = b = l
|
|
210
|
+
else
|
|
211
|
+
q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
|
212
|
+
p = 2 * l - q
|
|
213
|
+
r = _hue_to_rgb(p, q, h + 1.0 / 3)
|
|
214
|
+
g = _hue_to_rgb(p, q, h)
|
|
215
|
+
b = _hue_to_rgb(p, q, h - 1.0 / 3)
|
|
216
|
+
end
|
|
217
|
+
format("#%02x%02x%02x", (r * 255).to_i, (g * 255).to_i, (b * 255).to_i)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def _hue_to_rgb(p, q, t)
|
|
221
|
+
t += 1 if t < 0
|
|
222
|
+
t -= 1 if t > 1
|
|
223
|
+
return p + (q - p) * 6 * t if t < 1.0 / 6
|
|
224
|
+
return q if t < 1.0 / 2
|
|
225
|
+
return p + (q - p) * (2.0 / 3 - t) * 6 if t < 2.0 / 3
|
|
226
|
+
|
|
227
|
+
p
|
|
228
|
+
end
|
|
229
|
+
|
|
123
230
|
# Resolve SCSS #{ ... } interpolation. Each #{ expr } is replaced by its
|
|
124
231
|
# resolved inner text: a $variable inside the braces resolves to its value,
|
|
125
232
|
# anything else is inlined verbatim (trimmed). Lets a value carry a
|
data/lib/tina4/version.rb
CHANGED
data/lib/tina4/websocket.rb
CHANGED
|
@@ -135,6 +135,16 @@ module Tina4
|
|
|
135
135
|
class WebSocket
|
|
136
136
|
GUID = WEBSOCKET_GUID
|
|
137
137
|
|
|
138
|
+
# Process-wide handle to the live route-serving WebSocket engine. RackApp
|
|
139
|
+
# sets this on construction so framework code (Frond.push_live, background
|
|
140
|
+
# tasks) can broadcast without threading an engine reference through every
|
|
141
|
+
# call site. Mirrors PHP's \Tina4\Server::getInstance(). Nil until a RackApp
|
|
142
|
+
# is built (e.g. in unit specs) — best-effort callers degrade silently.
|
|
143
|
+
@current = nil
|
|
144
|
+
class << self
|
|
145
|
+
attr_accessor :current
|
|
146
|
+
end
|
|
147
|
+
|
|
138
148
|
attr_reader :connections
|
|
139
149
|
|
|
140
150
|
def initialize
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tina4ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.13.
|
|
4
|
+
version: 3.13.52
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tina4 Team
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|