tina4ruby 3.13.51 → 3.13.53

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: acbf145f4e91880ca9b0763788134071b914c5923f3cdc84fc459a333097bb11
4
- data.tar.gz: 04e0793ef84e6d044e578d6183526c05c45edd3f68dd04f2c1801458296d130c
3
+ metadata.gz: 79a09ae5dffe2688af3e75a98dcf999929a1c560f3d8afbdc874590d6dfca275
4
+ data.tar.gz: 92d0d0b44f0e103a3f8ac894c620b075446bab88040d865095e7b9c23ea421fe
5
5
  SHA512:
6
- metadata.gz: 6cd653a5682bf5f1257ae0290a36d5b6784afb16c67e5433b56480c25d750003fa3f9d1560aa1246c2ec71c7720ce0073cf9e1f10703b7e031effe3a5d36e29c
7
- data.tar.gz: 17e2ccf01f62cd8b5ce17d7606c50f8e2882e7e6c1ad32fd0021ff980b7850fccda44a818c2370ae832387fae745e22fefbb510f49a0a7c4f187f31d4cc38356
6
+ metadata.gz: 128df90a6f281fefb62fc1e49e3075c52474d2c9f70cddc992e1c68a083b8dff38e9c6d014ffec0019736719f49bc2f80cb93eb2f3dff2b3deb336845db1f0e2
7
+ data.tar.gz: c579de40838336727fd25581e8d4db17bd3c449034e4805ba29a8215be60f5344c7608152c6c21cc7258396525e1bc5aee86b12ec4927de62f1745f39797601d
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
 
@@ -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",
@@ -1688,8 +1688,9 @@ module Tina4
1688
1688
 
1689
1689
  # Native MCP JSON-RPC endpoint (POST /__dev/mcp[/message]). Real MCP
1690
1690
  # clients POST a JSON-RPC 2.0 request; we hand the parsed body straight
1691
- # to the default server's handle_message and echo the response. A
1692
- # notification (no id) yields an empty 204, mirroring Python.
1691
+ # to the default server's dispatch_http and echo the response. A
1692
+ # notification (no id) yields an empty 202 Accepted (the MCP Streamable
1693
+ # HTTP contract), mirroring the Python master, PHP and Node.
1693
1694
  # Streamable HTTP POST /__dev/mcp — the current MCP transport. initialize
1694
1695
  # issues an Mcp-Session-Id; an unknown session on a non-initialize request
1695
1696
  # is a 404; a notification is 202; anything else answers inline (200).
data/lib/tina4/frond.rb CHANGED
@@ -39,7 +39,23 @@ module Tina4
39
39
  @@class_globals = {}
40
40
  @@class_tests = {}
41
41
 
42
- # Clear the class-level globals/filters/tests registries.
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("&", "&amp;").gsub('"', "&quot;")
123
+ .gsub("<", "&lt;").gsub(">", "&gt;")
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/orm.rb CHANGED
@@ -419,6 +419,20 @@ module Tina4
419
419
  else "DATETIME"
420
420
  end
421
421
 
422
+ # Engine-aware JSON column type (parity with the Python master's
423
+ # JSONField DDL). PostgreSQL gets native JSONB (indexable, canonical);
424
+ # MySQL native JSON; MSSQL stores JSON as NVARCHAR(MAX) (its JSON
425
+ # functions read that); Firebird has no TEXT type so it uses a text
426
+ # BLOB; SQLite and everything else store the JSON text in TEXT
427
+ # (queryable via json1).
428
+ json_sql = case engine
429
+ when "postgres", "postgresql" then "JSONB"
430
+ when "mysql" then "JSON"
431
+ when "mssql", "sqlserver" then "NVARCHAR(MAX)"
432
+ when "firebird" then "BLOB SUB_TYPE TEXT"
433
+ else "TEXT"
434
+ end
435
+
422
436
  type_map = {
423
437
  integer: "INTEGER",
424
438
  string: "VARCHAR(255)",
@@ -430,7 +444,7 @@ module Tina4
430
444
  datetime: datetime_sql,
431
445
  timestamp: "TIMESTAMP",
432
446
  blob: "BLOB",
433
- json: "TEXT"
447
+ json: json_sql
434
448
  }
435
449
 
436
450
  col_defs = []
@@ -444,7 +458,11 @@ module Tina4
444
458
  parts << "PRIMARY KEY" if opts[:primary_key]
445
459
  parts << "AUTOINCREMENT" if opts[:auto_increment]
446
460
  parts << "NOT NULL" if !opts[:nullable] && !opts[:primary_key]
447
- if opts[:default] && !opts[:auto_increment]
461
+ # A JSON column carries no DDL DEFAULT (parity with the Python master):
462
+ # a dict/list default is an application-level concern, applied per
463
+ # instance, not a portable SQL literal (PG needs a ::jsonb cast, MySQL
464
+ # an expression default). The instance still gets its default at new.
465
+ if opts[:default] && !opts[:auto_increment] && opts[:type] != :json
448
466
  parts << "DEFAULT #{default_literal(opts[:default], opts[:type], bool_sql)}"
449
467
  end
450
468
  col_defs << parts.join(" ")
@@ -486,6 +504,21 @@ module Tina4
486
504
  hash.each do |key, value|
487
505
  # Apply field mapping (db_col => ruby_attr)
488
506
  attr_name = mapping_reverse[key.to_s] || key
507
+ # A JSON column comes back from the driver as a JSON string (SQLite
508
+ # TEXT, MySQL JSON, PostgreSQL JSONB via the text protocol, MSSQL
509
+ # NVARCHAR). Decode it to the Hash/Array the attribute expects
510
+ # (parity with the Python master's JSONField parse-on-read). A value
511
+ # already a Hash/Array is left untouched; nil stays nil; a
512
+ # non-decodable string keeps its raw form rather than crashing a
513
+ # normal read.
514
+ fdef = field_definitions[attr_name.to_sym]
515
+ if fdef && fdef[:type] == :json && value.is_a?(String)
516
+ begin
517
+ value = JSON.parse(value)
518
+ rescue JSON::ParserError
519
+ # leave the raw string in place
520
+ end
521
+ end
489
522
  setter = "#{attr_name}="
490
523
  instance.__send__(setter, value) if instance.respond_to?(setter)
491
524
  end
@@ -610,6 +643,11 @@ module Tina4
610
643
  if __send__(name).nil? && opts[:default]
611
644
  d = opts[:default]
612
645
  d = d.call if d.respond_to?(:call) && !d.is_a?(Class)
646
+ # Deep-copy a mutable Hash/Array default so two instances never alias
647
+ # the same object (e.g. `json_field :meta, default: {}` — mutating
648
+ # a.meta must not leak into b.meta). Parity with the Python master,
649
+ # which deepcopies a JSONField's dict/list default per instance.
650
+ d = Marshal.load(Marshal.dump(d)) if d.is_a?(Hash) || d.is_a?(Array)
613
651
  __send__("#{name}=", d)
614
652
  end
615
653
  end
@@ -667,7 +705,6 @@ module Tina4
667
705
  return false
668
706
  end
669
707
 
670
- data = to_db_hash(exclude_nil: true)
671
708
  pk = self.class.primary_key_field || :id
672
709
  pk_value = __send__(pk)
673
710
  pk_opts = self.class.field_definitions[pk] || {}
@@ -694,6 +731,10 @@ module Tina4
694
731
  end
695
732
 
696
733
  begin
734
+ # Built here (inside the rescue's reach) so a JSON column that can't be
735
+ # serialised (to_db_hash raises JSON::GeneratorError) fails loud through
736
+ # the same path as a driver error — rolled back, false, cause recorded.
737
+ data = to_db_hash(exclude_nil: true)
697
738
  self.class.db.transaction do |db|
698
739
  if is_update
699
740
  filter = { pk => pk_value }
@@ -941,9 +982,19 @@ module Tina4
941
982
  def to_db_hash(exclude_nil: false)
942
983
  hash = {}
943
984
  mapping = self.class.field_mapping
944
- self.class.field_definitions.each_key do |name|
985
+ self.class.field_definitions.each do |name, opts|
945
986
  value = __send__(name)
946
987
  next if exclude_nil && value.nil?
988
+ # A JSON column serialises its Hash/Array to a JSON string for the
989
+ # driver (parity with the Python master's JSONField.to_db). A value
990
+ # that can't be encoded (e.g. Float::NAN) raises JSON::GeneratorError;
991
+ # save() computes this hash inside its rescue, so it fails loud —
992
+ # rolls back, returns false, records the cause. A value already a
993
+ # String is left as-is (a caller who pre-encoded stays in control);
994
+ # nil passes through untouched.
995
+ if opts[:type] == :json && !value.nil? && !value.is_a?(String)
996
+ value = JSON.generate(value)
997
+ end
947
998
  db_col = mapping[name.to_s] || name
948
999
  hash[db_col.to_sym] = value
949
1000
  end
@@ -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.1.3 tina4.com */
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">&times;</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.3 tina4.com */
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">&times;</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 */
@@ -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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.51"
4
+ VERSION = "3.13.53"
5
5
  end
@@ -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.51
4
+ version: 3.13.53
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-03 00:00:00.000000000 Z
11
+ date: 2026-07-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack