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
- 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 --
blockandextendsare template inheritance, andrawis 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_stringkeys on md5(source), so an app that builds template strings dynamically adds an entry per distinct string. 256- 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 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).
-
.json_safe(value) ⇒ Object
Serializes to JSON that is valid JSON, valid JavaScript, and safe in HTML.
-
.json_sanitize(value) ⇒ Object
Replaces anything JSON.generate refuses with a JSON-representable stand-in.
-
.json_text(value) ⇒ Object
Serializes a value to compact JSON text that is always valid JSON.
-
.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.
-
#filter_permitted?(name) ⇒ Boolean
May this filter RUN under the current sandbox?.
-
#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.
-
#tag_permitted?(tag) ⇒ Boolean
May this tag run under the current sandbox?.
-
#unsandbox ⇒ Object
Disable sandbox mode.
Constructor Details
#initialize(template_dir: "src/templates") ⇒ Frond
Returns a new instance of Frond.
290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 |
# File 'lib/tina4/frond.rb', line 290 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.
2865 2866 2867 |
# File 'lib/tina4/frond.rb', line 2865 def form_token_session_id @form_token_session_id end |
Instance Attribute Details
#template_dir ⇒ Object (readonly)
Public API
288 289 290 |
# File 'lib/tina4/frond.rb', line 288 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.
401 402 403 |
# File 'lib/tina4/frond.rb', line 401 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.
417 418 419 |
# File 'lib/tina4/frond.rb', line 417 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.
409 410 411 |
# File 'lib/tina4/frond.rb', line 409 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
493 494 495 |
# File 'lib/tina4/frond.rb', line 493 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.
2884 2885 2886 2887 2888 2889 2890 2891 2892 2893 2894 2895 2896 2897 2898 2899 2900 2901 2902 2903 2904 2905 2906 |
# File 'lib/tina4/frond.rb', line 2884 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
2908 2909 2910 2911 |
# File 'lib/tina4/frond.rb', line 2908 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.
2915 2916 2917 |
# File 'lib/tina4/frond.rb', line 2915 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.
2402 2403 2404 |
# File 'lib/tina4/frond.rb', line 2402 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.
2412 2413 2414 |
# File 'lib/tina4/frond.rb', line 2412 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).
2407 2408 2409 |
# File 'lib/tina4/frond.rb', line 2407 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:
- Never emit a non-finite literal. JSON.generate raises on Infinity, and
the old
rescue v.to_sturned 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. - Never emit nothing, and never emit something that still parses and means something else. "var ROWS = ;" is at least a loud SyntaxError.
- 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.
547 548 549 |
# File 'lib/tina4/frond.rb', line 547 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.
515 516 517 518 519 520 521 522 523 |
# File 'lib/tina4/frond.rb', line 515 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.
502 503 504 505 506 507 508 509 510 511 512 |
# File 'lib/tina4/frond.rb', line 502 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.
138 139 140 141 |
# File 'lib/tina4/frond.rb', line 138 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.
2397 2398 2399 |
# File 'lib/tina4/frond.rb', line 2397 def self.live_source(name, callable = nil, &blk) @@class_live_sources[name] = callable || blk end |
.push_live(name, data = {}) ⇒ Object
Re-render the '
2444 2445 2446 2447 2448 2449 2450 2451 2452 2453 2454 2455 2456 2457 2458 2459 2460 2461 2462 2463 |
# File 'lib/tina4/frond.rb', line 2444 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.
2468 2469 2470 2471 2472 2473 2474 2475 |
# File 'lib/tina4/frond.rb', line 2468 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.
2840 2841 2842 2843 2844 2845 2846 2847 2848 2849 2850 |
# File 'lib/tina4/frond.rb', line 2840 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/
2387 2388 2389 2390 2391 2392 |
# File 'lib/tina4/frond.rb', line 2387 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.
2420 2421 2422 2423 2424 2425 2426 2427 2428 2429 2430 2431 2432 2433 2434 2435 2436 |
# File 'lib/tina4/frond.rb', line 2420 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)
2871 2872 2873 |
# File 'lib/tina4/frond.rb', line 2871 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).
426 427 428 429 430 |
# File 'lib/tina4/frond.rb', line 426 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.
446 447 448 449 450 |
# File 'lib/tina4/frond.rb', line 446 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.
436 437 438 439 440 |
# File 'lib/tina4/frond.rb', line 436 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.
387 388 389 390 391 392 393 |
# File 'lib/tina4/frond.rb', line 387 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.
475 476 477 478 479 |
# File 'lib/tina4/frond.rb', line 475 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.
339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 |
# File 'lib/tina4/frond.rb', line 339 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.
370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 |
# File 'lib/tina4/frond.rb', line 370 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.
453 454 455 456 457 458 459 |
# File 'lib/tina4/frond.rb', line 453 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 |
#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.
485 486 487 488 489 490 |
# File 'lib/tina4/frond.rb', line 485 def tag_permitted?(tag) return true unless @sandbox && @allowed_tags return true unless GATEABLE_TAGS.include?(tag) @allowed_tags.include?(tag) end |
#unsandbox ⇒ Object
Disable sandbox mode.
462 463 464 465 466 467 468 |
# File 'lib/tina4/frond.rb', line 462 def unsandbox @sandbox = false @allowed_filters = nil @allowed_tags = nil @allowed_vars = nil self end |