Class: Tina4::Frond

Inherits:
Object
  • Object
show all
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

{ "&" => "&amp;", "<" => "&lt;", ">" => "&gt;",
'"' => "&quot;", "'" => "&#39;" }.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 subsequent Tina4::Frond.new drains these into its instance-local registries — so hot-reloads (which re-execute frond = 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) or frond.add_filter(...) (updates both the class registry and the instance's live filter map). Parity with tina4-python's _ClassOrInstanceMethod descriptor.

{}
@@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 target

These 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

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

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_idObject

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_dirObject (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_registryObject

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.

Parameters:

  • descriptor (String) (defaults to: "")

    Optional string to enrich the token payload.

    • Empty: payload is => "form"
    • "admin_panel": payload is => "form", "context" => "admin_panel"
    • "checkout|order_123": payload is => "form", "context" => "checkout", "ref" => "order_123"

Returns:

  • (String)

    The raw JWT 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).

Returns:

  • (Boolean)


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("&", "&amp;").gsub('"', "&quot;")
       .gsub("<", "&lt;").gsub(">", "&gt;")
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 '' live fragment and push it to connected clients. Broadcasts a type,name,html envelope over WebSocket to the block's declared data-ws path (else a room named ). Returns the rendered HTML, or nil if the fragment is not registered. Mirrors Python push_live / PHP pushLive. The broadcast is best-effort — a missing/failed WS engine never raises into the caller.



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.message}") 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("&", "&amp;")
    .gsub("<", "&lt;")
    .gsub(">", "&gt;")
    .gsub('"', "&quot;")
  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/ endpoint calls this after resolving the provider data.



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)

Parameters:

  • session_id (String)

    The session ID to bind form tokens to



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_cacheObject

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    = tags    ? tags.map(&:to_s)    : nil
  @allowed_vars    = vars    ? vars.map(&:to_s)    : nil
  self
end

#unsandboxObject

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