Class: Tina4::Frond
- Inherits:
-
Object
- Object
- Tina4::Frond
- Defined in:
- lib/tina4/frond.rb
Defined Under Namespace
Classes: LoopContext
Constant Summary collapse
- TEXT =
-- Token types ----------------------------------------------------------
:text- VAR =
... }
:var- BLOCK =
... %
:block- COMMENT =
... #
:comment- TOKEN_RE =
Regex to split template source into tokens
/(\{%-?\s*.*?\s*-?%\})|(\{\{-?\s*.*?\s*-?\}\})|(\{#.*?#\})/m- HTML_ESCAPE_MAP =
HTML escape table
{ "&" => "&", "<" => "<", ">" => ">", '"' => """, "'" => "'" }.freeze
- HTML_ESCAPE_RE =
/[&<>"']/- EXTENDS_RE =
-- Compiled regex constants (optimization: avoid re-compiling in methods) --
/\{%-?\s*extends\s+["'](.+?)["']\s*-?%\}/- BLOCK_RE =
/\{%-?\s*block\s+(\w+)\s*-?%\}(.*?)\{%-?\s*endblock\s*-?%\}/m- STRING_LIT_RE =
/\A["'](.*)["']\z/- INTEGER_RE =
/\A-?\d+\z/- FLOAT_RE =
/\A-?\d+\.\d+\z/- ARRAY_LIT_RE =
/\A\[(.+)\]\z/m- HASH_LIT_RE =
/\A\{(.+)\}\z/m- HASH_PAIR_RE =
/\A\s*(?:["']([^"']+)["']|(\w+))\s*:\s*(.+)\z/- RANGE_LIT_RE =
/\A(\d+)\.\.(\d+)\z/- ARITHMETIC_OPS =
[" + ", " - ", " * ", " // ", " / ", " % ", " ** "].freeze
- FUNC_CALL_RE =
/\A(\w+)\s*\((.*)\)\z/m- FILTER_WITH_ARGS_RE =
/\A(\w+)\s*\((.*)\)\z/m- FILTER_CMP_RE =
/\A(\w+)\s*(!=|==|>=|<=|>|<)\s*(.+)\z/- OR_SPLIT_RE =
/\s+or\s+/- AND_SPLIT_RE =
/\s+and\s+/- IS_NOT_RE =
/\A(.+?)\s+is\s+not\s+(\w+)(.*)\z/- IS_RE =
/\A(.+?)\s+is\s+(\w+)(.*)\z/- NOT_IN_RE =
/\A(.+?)\s+not\s+in\s+(.+)\z/- IN_RE =
/\A(.+?)\s+in\s+(.+)\z/- DIVISIBLE_BY_RE =
/\s*by\s*\(\s*(\d+)\s*\)/- RESOLVE_SPLIT_RE =
/\.|\[([^\]]+)\]/- RESOLVE_STRIP_RE =
/\A["']|["']\z/- DIGIT_RE =
/\A\d+\z/- FOR_RE =
/\Afor\s+(\w+)(?:\s*,\s*(\w+))?\s+in\s+(.+)\z/- SET_RE =
/\Aset\s+(\w+)\s*=\s*(.+)\z/m- INCLUDE_RE =
/\Ainclude\s+["'](.+?)["'](?:\s+with\s+(.+))?\z/- MACRO_RE =
/\Amacro\s+(\w+)\s*\(([^)]*)\)/- LIVE_RE =
live "name" poll N | sse | ws "path" [src "url"] %
/\Alive\s+["']([^"']+)["'](.*)\z/m- LIVE_WS_RE =
/ws\s+["']([^"']+)["']/- LIVE_SRC_RE =
/src\s+["']([^"']+)["']/- FROM_IMPORT_RE =
/\Afrom\s+["'](.+?)["']\s+import\s+(.+)/- CACHE_RE =
/\Acache\s+["'](.+?)["']\s*(\d+)?/- SPACELESS_RE =
/>\s+</- AUTOESCAPE_RE =
/\Aautoescape\s+(false|true)/- STRIPTAGS_RE =
/<[^>]+>/- THOUSANDS_RE =
/(\d)(?=(\d{3})+(?!\d))/- SLUG_CLEAN_RE =
/[^a-z0-9]+/- SLUG_TRIM_RE =
/\A-|-\z/- INLINE_FILTERS =
Set of common no-arg filter names that can be inlined for speed
%w[upper lower length trim capitalize title string int escape e].each_with_object({}) { |f, h| h[f] = true }.freeze
- @@class_filters =
-- Class-level registries ------------------------------------------------ Persist globals, filters, and tests across hot-reloads and across module boundaries. When app.rb does
Tina4::Frond.add_filter("money") { ... }at startup before any instance exists, the registration sits here. Every subsequentTina4::Frond.newdrains these into its instance-local registries — so hot-reloads (which re-executefrond = Frond.new) and late-constructed engines automatically inherit prior registrations.The same-name dual-callable (class + instance) methods below let callers write either
Tina4::Frond.add_filter(...)(class-level only) orfrond.add_filter(...)(updates both the class registry and the instance's live filter map). Parity with tina4-python's_ClassOrInstanceMethoddescriptor. {}
- @@class_globals =
{}
- @@class_tests =
{}
- @@class_live_fragments =
-- Live-block registries (server-rendered live % regions) ----------- A live % block registers three things when its page first renders:
* class_live_fragments[name] -> the raw body source, re-rendered on every refresh by the /__frond/live/<name> endpoint or push_live * class_live_sources[name] -> an optional data provider (live_source) that re-runs with the LIVE request each refresh, so auth re-applies (IDOR guard) * class_live_ws_paths[name] -> the ws path a `ws "path"` block declared, used as the push_live broadcast targetThese persist across requests in the long-lived server (parity with the Python master's class-level dicts and PHP's static registries).
{}
- @@class_live_sources =
{}
- @@class_live_ws_paths =
{}
Class Attribute Summary collapse
-
.form_token_session_id ⇒ Object
Returns the value of attribute form_token_session_id.
Instance Attribute Summary collapse
-
#template_dir ⇒ Object
readonly
----------------------------------------------------------------------- Public API -----------------------------------------------------------------------.
Class Method Summary collapse
-
.add_filter(name, &blk) ⇒ Object
Register a custom filter on the class registry only.
-
.add_global(name, value) ⇒ Object
Register a global variable on the class registry only.
-
.add_test(name, &blk) ⇒ Object
Register a custom test on the class registry only.
-
.clear_registry ⇒ Object
Clear the class-level globals/filters/tests/live registries.
-
.escape_html(str) ⇒ Object
Utility: HTML escape.
-
.generate_form_jwt(descriptor = "") ⇒ String
Generate a raw JWT form token string.
- .generate_form_token(descriptor = "") ⇒ Object
-
.generate_form_token_value(descriptor = "") ⇒ Object
Return just the raw JWT form token string (no wrapper).
-
.get_live_source(name) ⇒ Object
The provider registered for a live block, or nil.
-
.get_live_ws_path(name) ⇒ Object
The ws path a live block declared (data-ws), or nil.
-
.has_live_fragment?(name) ⇒ Boolean
Whether a live fragment has been registered (its page rendered).
-
.live_attr(value) ⇒ Object
Escape a value for use inside an HTML attribute on a live marker.
-
.live_source(name, callable = nil, &blk) ⇒ Object
Register a data provider for a live % block.
-
.push_live(name, data = {}) ⇒ Object
Re-render the '
' live fragment and push it to connected clients. -
.register_live_endpoint! ⇒ Object
Register the always-on GET /__frond/live/name endpoint that re-renders a live block on demand.
-
.render_dump(value) ⇒ Object
Render a value as a pre-formatted inspect() wrapped in
tags.
-
.render_live(name, data = {}) ⇒ Object
Re-render a registered live % fragment by name with fresh data.
-
.respond_live(request, response, name) ⇒ Object
Handle GET /__frond/live/name: resolve the provider, run it with the live request (auth re-applies), re-render the fragment, return via the response callable.
-
.set_form_token_session_id(session_id) ⇒ Object
Set the session ID used for CSRF form token binding.
Instance Method Summary collapse
-
#add_filter(name, &blk) ⇒ Object
Register a custom filter.
-
#add_global(name, value) ⇒ Object
Register a global variable available in all templates.
-
#add_test(name, &blk) ⇒ Object
Register a custom test.
-
#clear_cache ⇒ Object
Clear all compiled template caches.
-
#initialize(template_dir: "src/templates") ⇒ Frond
constructor
A new instance of Frond.
-
#render(template, data = {}) ⇒ Object
Render a template file with data.
-
#render_string(source, data = {}) ⇒ Object
Render a template string directly.
-
#sandbox(filters: nil, tags: nil, vars: nil) ⇒ Object
Enable sandbox mode.
-
#unsandbox ⇒ Object
Disable sandbox mode.
Constructor Details
#initialize(template_dir: "src/templates") ⇒ Frond
Returns a new instance of Frond.
218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 |
# File 'lib/tina4/frond.rb', line 218 def initialize(template_dir: "src/templates") @template_dir = template_dir @filters = default_filters @globals = {} @tests = default_tests @auto_escape = true # Sandboxing @sandbox = false @allowed_filters = nil @allowed_tags = nil @allowed_vars = nil # Fragment cache: key => [html, expires_at] @fragment_cache = {} # Token pre-compilation cache @compiled = {} # {template_name => [tokens, mtime]} @compiled_strings = {} # {md5_hash => tokens} # Parsed filter chain cache: expr_string => [variable, filters] @filter_chain_cache = {} # Resolved dotted-path split cache: expr_string => parts_array @resolve_cache = {} # Sandbox root-var split cache: var_name => root_var_string @dotted_split_cache = {} # Built-in global functions register_builtin_globals # Drain class-level registries into this instance. Filters and tests # registered via ``Tina4::Frond.add_filter`` BEFORE this instance was # constructed flow in here. Globals likewise. This is the key to the # static-facade: ``app.rb`` registers once at startup, and every # Frond instance created later (including those born from hot-reloads) # automatically inherits the registration. Parity with tina4-python. @filters.merge!(@@class_filters) @globals.merge!(@@class_globals) @tests.merge!(@@class_tests) end |
Class Attribute Details
.form_token_session_id ⇒ Object
Returns the value of attribute form_token_session_id.
2349 2350 2351 |
# File 'lib/tina4/frond.rb', line 2349 def form_token_session_id @form_token_session_id end |
Instance Attribute Details
#template_dir ⇒ Object (readonly)
Public API
216 217 218 |
# File 'lib/tina4/frond.rb', line 216 def template_dir @template_dir end |
Class Method Details
.add_filter(name, &blk) ⇒ Object
Register a custom filter on the class registry only.
Callable as Tina4::Frond.add_filter("money") { |v| ... } at app
startup BEFORE any instance exists. The registration is remembered at
class level so every later Tina4::Frond.new inherits it. To also
update a live instance's filter map, use the instance method form.
327 328 329 |
# File 'lib/tina4/frond.rb', line 327 def self.add_filter(name, &blk) @@class_filters[name.to_s] = blk end |
.add_global(name, value) ⇒ Object
Register a global variable on the class registry only.
Same dual-callable semantics as add_filter — see that method for
the static-facade pattern.
343 344 345 |
# File 'lib/tina4/frond.rb', line 343 def self.add_global(name, value) @@class_globals[name.to_s] = value end |
.add_test(name, &blk) ⇒ Object
Register a custom test on the class registry only.
Same dual-callable semantics as add_filter — see that method for
the static-facade pattern.
335 336 337 |
# File 'lib/tina4/frond.rb', line 335 def self.add_test(name, &blk) @@class_tests[name.to_s] = blk end |
.clear_registry ⇒ Object
Clear the class-level globals/filters/tests/live registries.
Useful in test fixtures to prevent leaking state between tests. Does NOT affect built-in filters or globals — only user-registered ones.
62 63 64 65 66 67 68 69 |
# File 'lib/tina4/frond.rb', line 62 def self.clear_registry @@class_filters = {} @@class_globals = {} @@class_tests = {} @@class_live_fragments = {} @@class_live_sources = {} @@class_live_ws_paths = {} end |
.escape_html(str) ⇒ Object
Utility: HTML escape
397 398 399 |
# File 'lib/tina4/frond.rb', line 397 def self.escape_html(str) str.to_s.gsub(HTML_ESCAPE_RE, HTML_ESCAPE_MAP) end |
.generate_form_jwt(descriptor = "") ⇒ String
Generate a raw JWT form token string.
2368 2369 2370 2371 2372 2373 2374 2375 2376 2377 2378 2379 2380 2381 2382 2383 2384 2385 2386 2387 2388 2389 2390 |
# File 'lib/tina4/frond.rb', line 2368 def self.generate_form_jwt(descriptor = "") require_relative "log" require_relative "auth" payload = { "type" => "form", "nonce" => SecureRandom.hex(8) } if descriptor && !descriptor.empty? if descriptor.include?("|") parts = descriptor.split("|", 2) payload["context"] = parts[0] payload["ref"] = parts[1] else payload["context"] = descriptor end end # Include session_id for CSRF session binding sid = form_token_session_id.to_s payload["session_id"] = sid unless sid.empty? ttl_minutes = (ENV["TINA4_TOKEN_LIMIT"] || "60").to_i expires_in = ttl_minutes * 60 Tina4::Auth.create_token(payload, expires_in: expires_in) end |
.generate_form_token(descriptor = "") ⇒ Object
2392 2393 2394 2395 |
# File 'lib/tina4/frond.rb', line 2392 def self.generate_form_token(descriptor = "") token = generate_form_jwt(descriptor) Tina4::SafeString.new(%(<input type="hidden" name="formToken" value="#{CGI.escapeHTML(token)}">)) end |
.generate_form_token_value(descriptor = "") ⇒ Object
Return just the raw JWT form token string (no wrapper). Registered as both formTokenValue and form_token_value template globals.
2399 2400 2401 |
# File 'lib/tina4/frond.rb', line 2399 def self.generate_form_token_value(descriptor = "") Tina4::SafeString.new(generate_form_jwt(descriptor)) end |
.get_live_source(name) ⇒ Object
The provider registered for a live block, or nil.
1931 1932 1933 |
# File 'lib/tina4/frond.rb', line 1931 def self.get_live_source(name) @@class_live_sources[name] end |
.get_live_ws_path(name) ⇒ Object
The ws path a live block declared (data-ws), or nil.
1941 1942 1943 |
# File 'lib/tina4/frond.rb', line 1941 def self.get_live_ws_path(name) @@class_live_ws_paths[name] end |
.has_live_fragment?(name) ⇒ Boolean
Whether a live fragment has been registered (its page rendered).
1936 1937 1938 |
# File 'lib/tina4/frond.rb', line 1936 def self.has_live_fragment?(name) @@class_live_fragments.key?(name) end |
.live_attr(value) ⇒ Object
Escape a value for use inside an HTML attribute on a live marker. Byte-identical order to the Python master / PHP liveAttr so the emitted marker element matches across all four frameworks.
121 122 123 124 |
# File 'lib/tina4/frond.rb', line 121 def self.live_attr(value) value.to_s.gsub("&", "&").gsub('"', """) .gsub("<", "<").gsub(">", ">") end |
.live_source(name, callable = nil, &blk) ⇒ Object
Register a data provider for a live % block. Accepts a block OR a callable (proc/lambda); it is invoked with the live request on every refresh so auth re-applies (IDOR guard). Mirrors Python's @live_source.
1926 1927 1928 |
# File 'lib/tina4/frond.rb', line 1926 def self.live_source(name, callable = nil, &blk) @@class_live_sources[name] = callable || blk end |
.push_live(name, data = {}) ⇒ Object
Re-render the '
1973 1974 1975 1976 1977 1978 1979 1980 1981 1982 1983 1984 1985 1986 1987 1988 1989 1990 1991 1992 |
# File 'lib/tina4/frond.rb', line 1973 def self.push_live(name, data = {}) html = render_live(name, data) return nil if html.nil? envelope = { "type" => "live", "name" => name, "html" => html }.to_json engine = (Tina4::WebSocket.current if defined?(Tina4::WebSocket)) if engine begin ws_path = get_live_ws_path(name) if ws_path engine.broadcast(envelope, path: ws_path) else engine.broadcast_to_room(name, envelope) end rescue StandardError => e Tina4::Log.error("push_live(#{name}) broadcast failed: #{e.}") if defined?(Tina4::Log) end end html end |
.register_live_endpoint! ⇒ Object
Register the always-on GET /__frond/live/name endpoint that re-renders a live block on demand. Idempotent — guarded against a re-register after a Router.clear! (specs / hot-reload rescans). Mirrors PHP App::registerLiveEndpoint.
1997 1998 1999 2000 2001 2002 2003 2004 |
# File 'lib/tina4/frond.rb', line 1997 def self.register_live_endpoint! return if Tina4::Router.find_route("GET", "/__frond/live/live-probe") Tina4::Router.add( "GET", "/__frond/live/{name}", lambda { |request, response, name| Tina4::Frond.respond_live(request, response, name) } ) end |
.render_dump(value) ⇒ Object
Render a value as a pre-formatted inspect() wrapped in
tags.Gated on TINA4_DEBUG=true. In production (TINA4_DEBUG unset or false) this returns an empty SafeString to avoid leaking internal state, object shapes, or sensitive values into rendered HTML.
Shared by the {{ value|dump }} filter and the {{ dump(value) }} global function so both produce identical output and obey the same gating.
2324 2325 2326 2327 2328 2329 2330 2331 2332 2333 2334 |
# File 'lib/tina4/frond.rb', line 2324 def self.render_dump(value) return SafeString.new("") unless ENV.fetch("TINA4_DEBUG", "").downcase == "true" dumped = value.inspect escaped = dumped .gsub("&", "&") .gsub("<", "<") .gsub(">", ">") .gsub('"', """) SafeString.new("<pre>#{escaped}</pre>") end |
.render_live(name, data = {}) ⇒ Object
Re-render a registered live % fragment by name with fresh data.
Returns the rendered HTML, or nil if no fragment is registered under that
name yet (its page has not rendered). The /__frond/live/
1916 1917 1918 1919 1920 1921 |
# File 'lib/tina4/frond.rb', line 1916 def self.render_live(name, data = {}) source = @@class_live_fragments[name] return nil if source.nil? new.render_string(source, data || {}) end |
.respond_live(request, response, name) ⇒ Object
Handle GET /__frond/live/name: resolve the provider, run it with the live request (auth re-applies), re-render the fragment, return via the response callable. 404 for unknown name / unrendered fragment. Mirrors Python's live_endpoint and PHP's respondLive.
1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 1960 1961 1962 1963 1964 1965 |
# File 'lib/tina4/frond.rb', line 1949 def self.respond_live(request, response, name) provider = @@class_live_sources[name] if !@@class_live_fragments.key?(name) && provider.nil? return response.call("live block not found: #{name}", 404) end context = {} unless provider.nil? result = provider.call(request) context = result.is_a?(Hash) ? result : {} end html = render_live(name, context) return response.call("live fragment not registered yet: #{name}", 404) if html.nil? response.call(html) end |
.set_form_token_session_id(session_id) ⇒ Object
Set the session ID used for CSRF form token binding. Parity with Python/PHP/Node: Frond.set_form_token_session_id(id)
2355 2356 2357 |
# File 'lib/tina4/frond.rb', line 2355 def set_form_token_session_id(session_id) self.form_token_session_id = session_id end |
Instance Method Details
#add_filter(name, &blk) ⇒ Object
Register a custom filter.
Updates BOTH the class registry (so future Tina4::Frond.new picks
the filter up) AND this instance's live filter map (so the change is
visible to subsequent renders on the current engine).
352 353 354 355 356 |
# File 'lib/tina4/frond.rb', line 352 def add_filter(name, &blk) self.class.add_filter(name, &blk) @filters[name.to_s] = blk self end |
#add_global(name, value) ⇒ Object
Register a global variable available in all templates.
Updates BOTH the class registry and this instance's live globals map.
See add_filter for the dual-write semantics.
372 373 374 375 376 |
# File 'lib/tina4/frond.rb', line 372 def add_global(name, value) self.class.add_global(name, value) @globals[name.to_s] = value self end |
#add_test(name, &blk) ⇒ Object
Register a custom test.
Updates BOTH the class registry and this instance's live tests map.
See add_filter for the dual-write semantics.
362 363 364 365 366 |
# File 'lib/tina4/frond.rb', line 362 def add_test(name, &blk) self.class.add_test(name, &blk) @tests[name.to_s] = blk self end |
#clear_cache ⇒ Object
Clear all compiled template caches.
313 314 315 316 317 318 319 |
# File 'lib/tina4/frond.rb', line 313 def clear_cache @compiled.clear @compiled_strings.clear @filter_chain_cache.clear @resolve_cache.clear @dotted_split_cache.clear end |
#render(template, data = {}) ⇒ Object
Render a template file with data. Uses token caching for performance.
Caching strategy:
* TINA4_DEBUG=true — never cache (always re-read + re-tokenize).
* TINA4_TEMPLATE_CACHE_TTL > 0 — cache entries expire after N seconds.
* TINA4_TEMPLATE_CACHE_TTL == 0 (default in production) — permanent cache.
267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 |
# File 'lib/tina4/frond.rb', line 267 def render(template, data = {}) context = @globals.merge(stringify_keys(data)) path = File.join(@template_dir, template) raise "Template not found: #{path}" unless File.exist?(path) debug_mode = ENV.fetch("TINA4_DEBUG", "").downcase == "true" ttl = (ENV["TINA4_TEMPLATE_CACHE_TTL"] || "0").to_i unless debug_mode cached = @compiled[template] if cached # cached layout: [tokens, mtime, cached_at] tokens, _mtime, cached_at = cached fresh = ttl <= 0 || (Time.now.to_i - cached_at.to_i) < ttl return execute_cached(tokens, context) if fresh end end # Dev mode: skip cache entirely — always re-read and re-tokenize # so edits to partials and extended base templates are detected # Cache miss — load, tokenize, cache source = File.read(path, encoding: "utf-8") mtime = File.mtime(path) tokens = tokenize(source) @compiled[template] = [tokens, mtime, Time.now.to_i] execute_with_tokens(source, tokens, context) end |
#render_string(source, data = {}) ⇒ Object
Render a template string directly. Uses token caching for performance.
297 298 299 300 301 302 303 304 305 306 307 308 309 310 |
# File 'lib/tina4/frond.rb', line 297 def render_string(source, data = {}) context = @globals.merge(stringify_keys(data)) key = Digest::MD5.hexdigest(source) cached_tokens = @compiled_strings[key] if cached_tokens return execute_cached(cached_tokens, context) end tokens = tokenize(source) @compiled_strings[key] = tokens execute_cached(tokens, context) end |
#sandbox(filters: nil, tags: nil, vars: nil) ⇒ Object
Enable sandbox mode.
379 380 381 382 383 384 385 |
# File 'lib/tina4/frond.rb', line 379 def sandbox(filters: nil, tags: nil, vars: nil) @sandbox = true @allowed_filters = filters ? filters.map(&:to_s) : nil @allowed_tags = ? .map(&:to_s) : nil @allowed_vars = vars ? vars.map(&:to_s) : nil self end |
#unsandbox ⇒ Object
Disable sandbox mode.
388 389 390 391 392 393 394 |
# File 'lib/tina4/frond.rb', line 388 def unsandbox @sandbox = false @allowed_filters = nil @allowed_tags = nil @allowed_vars = nil self end |