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
BLOCK_OPEN_RE =

Open/close halves of BLOCK_RE, used standalone by extract_blocks' depth counter -- a single non-greedy BLOCK_RE match cannot tell a NESTED endblock from the outer block's own, so it needs its own two-piece scan.

/\{%-?\s*block\s+(\w+)\s*-?%\}/
BLOCK_CLOSE_RE =
/\{%-?\s*endblock\s*-?%\}/
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
LOOSER_THAN_PIPE_OPS =

Operators Twig binds LOOSER than the filter pipe |. When one of these sits at the top level alongside a pipe (e.g. amount|number_format(2) ~ ' EUR'), the whole expression must go through eval_expr (which resolves the pipe at its correct, tighter precedence) instead of being split on the pipe as a plain filter chain. Detection is quote/paren-aware (find_outside_quotes) so operator-like text inside a string or filter args never false-triggers.

[
  "~", "??", " if ",
  " not in ", " in ", " is not ", " is ", "!=", "==", ">=", "<=", ">", "<",
  " and ", " or ", " not ",
  " + ", " - ", " * ", " // ", " / ", " % ", " ** "
].freeze
FUNC_CALL_RE =

A dot is allowed in the callee so import "f" as m % can register its macros under the literal key "m.greet" and m.greet("Andre") } resolves as a call. Without the dot the whole expression was not recognised as a function call at all, so an aliased macro rendered as SILENTLY EMPTY.

/\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+(.+)/
IMPORT_AS_RE =
/\Aimport\s+["'](.+?)["']\s+as\s+(\w+)/
CACHE_RE =
/\Acache\s+["'](.+?)["']\s*(\d+)?/
SPACELESS_RE =
/>\s+</
KNOWN_TAGS =

Every tag that OPENS a construct. An unknown tag is a typo, and 3.13.89 makes it raise rather than render its body: a mistyped guard -- iff is_admin % instead of if is_admin % -- used to render the gated content UNCONDITIONALLY, so a reviewer saw a guard that was not there. Twig and Jinja2 both raise on an unknown tag; Frond now does too. There is no user-extension point for tags in any of the four frameworks, so an unknown name is always a mistake, never a plugin.

%w[
  autoescape block cache extends for from if import include live macro raw set
  spaceless
].freeze
TERMINATOR_TAGS =

Terminators and branch keywords. These reach the tag dispatch only when stray (their own collector consumes them in the normal case), and a stray one keeps the old render-nothing behaviour -- see the comment at the raise.

%w[
  elif else elseif endautoescape endblock endcache endfor endif endlive
  endmacro endraw endset endspaceless
].freeze
GATEABLE_TAGS =

Author-written tags the sandbox allow-list governs. Mirrors Python's _GATEABLE_TAGS and PHP's GATEABLE_TAGS. A tag absent from this list is structural, not an author capability, and is never gated -- block and extends are template inheritance, and raw is consumed by the tokenizer. Both spellings of set (set x = 1 % and set x %...endset %) dispatch under "set", so one entry covers the pair.

%w[
  autoescape cache for from if import include live macro set spaceless
].freeze
BLOCK_TAG_ENDS =

Gateable tags that OWN A BODY, mapped to the terminator closing it. A denied tag has to consume its body -- see skip_block.

{
  "autoescape" => "endautoescape",
  "cache" => "endcache",
  "for" => "endfor",
  "if" => "endif",
  "live" => "endlive",
  "macro" => "endmacro",
  "set" => "endset",
  "spaceless" => "endspaceless"
}.freeze
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
TEMPLATE_CACHE_MAX =

Hard cap on the template caches — @compiled and @compiled_strings (ADR-0004, parity with PHP/Python/Node TEMPLATE_CACHE_MAX).

An entry here is a whole token list, so the cap sits well below what a per-expression memo would justify. 256 is far above any real application's template count, so a normal app never evicts. The cap exists for the workload that genuinely grows without limit for the life of a worker: render_string keys on md5(source), so an app that builds template strings dynamically adds an entry per distinct string.

Also reused for @fragment_cache (the cache % tag's runtime store): a rendered fragment is a whole HTML string, the same order of magnitude as a compiled template, not a small per-expression descriptor.

256
MEMO_CACHE_MAX =

Hard cap on every per-expression memo cache in this engine (ADR-0004): @filter_chain_cache, @resolve_cache, @dotted_split_cache. Mirrors PHP's MEMO_CACHE_MAX and the Python master's @lru_cache(maxsize=1024) on the equivalent module-level parsers. A template that builds expression strings dynamically would otherwise grow a plain instance Hash without limit for the lifetime of the engine — a memory footgun on a long-lived worker. Deliberately higher than TEMPLATE_CACHE_MAX: one entry here is a small parsed-path array, orders of magnitude smaller than a token list.

1024
JSON_ESCAPE_MAP =
{
  "<" => "\\u003c", ">" => "\\u003e", "&" => "\\u0026", "'" => "\\u0027",
  "\u2028" => "\\u2028", "\u2029" => "\\u2029"
}.freeze
JSON_ESCAPE_RE =
/[<>&'\u2028\u2029]/
@@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(...) (process-global) or frond.add_filter(...) (the instance's live filter map only). 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.



308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
# File 'lib/tina4/frond.rb', line 308

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.



3115
3116
3117
# File 'lib/tina4/frond.rb', line 3115

def form_token_session_id
  @form_token_session_id
end

Instance Attribute Details

#template_dirObject (readonly)


Public API



306
307
308
# File 'lib/tina4/frond.rb', line 306

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.



419
420
421
# File 'lib/tina4/frond.rb', line 419

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.



435
436
437
# File 'lib/tina4/frond.rb', line 435

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.



427
428
429
# File 'lib/tina4/frond.rb', line 427

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.



61
62
63
64
65
66
67
68
# File 'lib/tina4/frond.rb', line 61

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



505
506
507
# File 'lib/tina4/frond.rb', line 505

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.



3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
# File 'lib/tina4/frond.rb', line 3134

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



3158
3159
3160
3161
# File 'lib/tina4/frond.rb', line 3158

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.



3165
3166
3167
# File 'lib/tina4/frond.rb', line 3165

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.



2652
2653
2654
# File 'lib/tina4/frond.rb', line 2652

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.



2662
2663
2664
# File 'lib/tina4/frond.rb', line 2662

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)


2657
2658
2659
# File 'lib/tina4/frond.rb', line 2657

def self.has_live_fragment?(name)
  @@class_live_fragments.key?(name)
end

.json_safe(value) ⇒ Object

Serializes to JSON that is valid JSON, valid JavaScript, and safe in HTML.

THE cross-framework contract for json_encode / to_json / tojson. Keep the four implementations byte-identical; frond_expression_corpus.txt locks it.

Three things this must never do, each of which was a real bug:

  1. Never emit a non-finite literal. JSON.generate raises on Infinity, and the old rescue v.to_s turned that raise into Ruby inspect output -- {"a" => 1.0} -- which no JSON.parse will read. Reported as tina4-php#184 by justin-k-bruce, who hit the same class of bug in PHP.
  2. Never emit nothing, and never emit something that still parses and means something else. "var ROWS = ;" is at least a loud SyntaxError.
  3. Never HTML-escape it. Entity-encoding JSON produces "a":1, a SyntaxError inside cannot terminate the block, and it is safe inside a single-quoted attribute. This is what Jinja2's tojson does, and it is why the result is a SafeString.

U+2028 and U+2029 join that escape set. Both are legal inside a JSON string and both were illegal inside a JavaScript string literal before ES2019.



559
560
561
# File 'lib/tina4/frond.rb', line 559

def self.json_safe(value)
  Tina4::SafeString.new(json_text(value).gsub(JSON_ESCAPE_RE, JSON_ESCAPE_MAP))
end

.json_sanitize(value) ⇒ Object

Replaces anything JSON.generate refuses with a JSON-representable stand-in.



527
528
529
530
531
532
533
534
535
# File 'lib/tina4/frond.rb', line 527

def self.json_sanitize(value)
  case value
  when Float   then value.finite? ? value : nil
  when Hash    then value.transform_values { |item| json_sanitize(item) }
  when Array   then value.map { |item| json_sanitize(item) }
  when String  then value.valid_encoding? ? value : value.scrub
  else value
  end
end

.json_text(value) ⇒ Object

Serializes a value to compact JSON text that is always valid JSON.

Never raises and never returns an empty string: a non-finite float becomes null (the JSON spec has no Infinity or NaN) and malformed UTF-8 is scrubbed, so a payload always arrives, in the worst case as null.



514
515
516
517
518
519
520
521
522
523
524
# File 'lib/tina4/frond.rb', line 514

def self.json_text(value)
  JSON.generate(value)
rescue StandardError
  # Only reached when the happy path raised, so a well-formed payload never
  # pays for the walk.
  begin
    JSON.generate(json_sanitize(value))
  rescue StandardError
    "null"
  end
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.



142
143
144
145
# File 'lib/tina4/frond.rb', line 142

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.



2647
2648
2649
# File 'lib/tina4/frond.rb', line 2647

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.



2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
# File 'lib/tina4/frond.rb', line 2694

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.



2718
2719
2720
2721
2722
2723
2724
2725
# File 'lib/tina4/frond.rb', line 2718

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.



3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
# File 'lib/tina4/frond.rb', line 3090

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.



2637
2638
2639
2640
2641
2642
# File 'lib/tina4/frond.rb', line 2637

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.



2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
# File 'lib/tina4/frond.rb', line 2670

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



3121
3122
3123
# File 'lib/tina4/frond.rb', line 3121

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 this instance's live filter map only. Class calls remain the process-global registration path. tina4: ADR-0052.



443
444
445
446
# File 'lib/tina4/frond.rb', line 443

def 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 this instance's live globals map only.



459
460
461
462
# File 'lib/tina4/frond.rb', line 459

def add_global(name, value)
  @globals[name.to_s] = value
  self
end

#add_test(name, &blk) ⇒ Object

Register a custom test.

Updates this instance's live tests map only.



451
452
453
454
# File 'lib/tina4/frond.rb', line 451

def add_test(name, &blk)
  @tests[name.to_s] = blk
  self
end

#clear_cacheObject

Clear all compiled template caches.



405
406
407
408
409
410
411
# File 'lib/tina4/frond.rb', line 405

def clear_cache
  @compiled.clear
  @compiled_strings.clear
  @filter_chain_cache.clear
  @resolve_cache.clear
  @dotted_split_cache.clear
end

#filter_permitted?(name) ⇒ Boolean

May this filter RUN under the current sandbox?

The escaping decision has to ask this rather than read the filter name out of the source: a denied raw that still marked its value safe made the allow-list entry governing XSS escaping inert.

Returns:

  • (Boolean)


487
488
489
490
491
# File 'lib/tina4/frond.rb', line 487

def filter_permitted?(name)
  return true unless @sandbox && @allowed_filters

  @allowed_filters.include?(name.to_s)
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.


357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
# File 'lib/tina4/frond.rb', line 357

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)
  cap_cache(@compiled, TEMPLATE_CACHE_MAX)
  @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.



388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
# File 'lib/tina4/frond.rb', line 388

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)
  cap_cache(@compiled_strings, TEMPLATE_CACHE_MAX)
  @compiled_strings[key] = tokens
  execute_cached(tokens, context)
end

#sandbox(filters: nil, tags: nil, vars: nil) ⇒ Object

Enable sandbox mode.



465
466
467
468
469
470
471
# File 'lib/tina4/frond.rb', line 465

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

#tag_permitted?(tag) ⇒ Boolean

May this tag run under the current sandbox?

One gate for every tag, so the allow-list governs the whole tag vocabulary instead of whichever names somebody remembered to check individually.

Returns:

  • (Boolean)


497
498
499
500
501
502
# File 'lib/tina4/frond.rb', line 497

def tag_permitted?(tag)
  return true unless @sandbox && @allowed_tags
  return true unless GATEABLE_TAGS.include?(tag)

  @allowed_tags.include?(tag)
end

#unsandboxObject

Disable sandbox mode.



474
475
476
477
478
479
480
# File 'lib/tina4/frond.rb', line 474

def unsandbox
  @sandbox         = false
  @allowed_filters = nil
  @allowed_tags    = nil
  @allowed_vars    = nil
  self
end