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*\(([^)]*)\)/
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 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.



187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
# File 'lib/tina4/frond.rb', line 187

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.



2140
2141
2142
# File 'lib/tina4/frond.rb', line 2140

def form_token_session_id
  @form_token_session_id
end

Instance Attribute Details

#template_dirObject (readonly)


Public API




185
186
187
# File 'lib/tina4/frond.rb', line 185

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.



296
297
298
# File 'lib/tina4/frond.rb', line 296

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.



312
313
314
# File 'lib/tina4/frond.rb', line 312

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.



304
305
306
# File 'lib/tina4/frond.rb', line 304

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

.clear_registryObject

Clear the class-level globals/filters/tests registries.

Useful in test fixtures to prevent leaking state between tests. Does NOT affect built-in filters or globals — only user-registered ones.



46
47
48
49
50
# File 'lib/tina4/frond.rb', line 46

def self.clear_registry
  @@class_filters = {}
  @@class_globals = {}
  @@class_tests   = {}
end

.escape_html(str) ⇒ Object

Utility: HTML escape



366
367
368
# File 'lib/tina4/frond.rb', line 366

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.



2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
# File 'lib/tina4/frond.rb', line 2159

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



2183
2184
2185
2186
# File 'lib/tina4/frond.rb', line 2183

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 <input> wrapper). Registered as both formTokenValue and form_token_value template globals.



2190
2191
2192
# File 'lib/tina4/frond.rb', line 2190

def self.generate_form_token_value(descriptor = "")
  Tina4::SafeString.new(generate_form_jwt(descriptor))
end

.render_dump(value) ⇒ Object

Render a value as a pre-formatted inspect() wrapped in <pre> 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.



2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
# File 'lib/tina4/frond.rb', line 2115

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

.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



2146
2147
2148
# File 'lib/tina4/frond.rb', line 2146

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).



321
322
323
324
325
# File 'lib/tina4/frond.rb', line 321

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.



341
342
343
344
345
# File 'lib/tina4/frond.rb', line 341

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.



331
332
333
334
335
# File 'lib/tina4/frond.rb', line 331

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.



282
283
284
285
286
287
288
# File 'lib/tina4/frond.rb', line 282

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.


236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
# File 'lib/tina4/frond.rb', line 236

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.



266
267
268
269
270
271
272
273
274
275
276
277
278
279
# File 'lib/tina4/frond.rb', line 266

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.



348
349
350
351
352
353
354
# File 'lib/tina4/frond.rb', line 348

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.



357
358
359
360
361
362
363
# File 'lib/tina4/frond.rb', line 357

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