tina4ruby 3.13.99 → 3.13.100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 11d1350ed25f20cfc7c7ba9757417c0eb33e87292eb45876d3bed30119fefcca
4
- data.tar.gz: 8bd0d33d8ae49492bd8784fb838339951ad92439bf96a615d71ffe2e488dfd7a
3
+ metadata.gz: 850c8661fec2f0bb0ffeaf0c5d7644a6625b59878f5b053aeba49a4fa48cbde7
4
+ data.tar.gz: df0cbd73ccc67d39ac6f2900d8d2e6a9875925fbf85dfd092bdf1579e4748f38
5
5
  SHA512:
6
- metadata.gz: e24bac573c4d4b58642fdc65071857c4a910dac229057f92c9eca2ba0936c1163a6e89bc79452c82f7d4601a4e64b0c10ce5c0af3e35384c16344491c6f61203
7
- data.tar.gz: 0afe568dfd38eee2c0ce7c507b71b8c0110e11be10cce5197100244d30e56ac58e119b25a1e5757df8b26a9bb74172b4739d1e94ef029729e7b3e932e2847032
6
+ metadata.gz: 39994977bdc2437b6abbfdeb88b0f3727e43cb46a4c189aae216e37202bdcd2e34505e396c60b083df3712c9bab1ac160f8f410b629e00a686dd9e9b48a4589b
7
+ data.tar.gz: b4948b71e5ecad37aa7923bc96091d88e243d0f3599e4a3440e94cc38a8452be2d8bb993459487590ef905752424b441376d78983597b94f4ac7059426780eae
data/CHANGELOG.md CHANGED
@@ -6,6 +6,24 @@ number means the same thing everywhere.
6
6
  **The authoritative release notes for every shipped version live in the documentation:**
7
7
  https://tina4.com/ruby/36-releases
8
8
 
9
+ ## 3.13.100
10
+
11
+ ### Breaking: Frond instance extensions stay local
12
+
13
+ Calling `add_filter`, `add_global`, or `add_test` on a Frond instance now changes
14
+ that renderer only. Register on `Frond` itself when every later instance must
15
+ inherit the extension.
16
+
17
+ - Reject a second `{% extends %}` tag instead of replacing the first parent without warning.
18
+ - Resolve multi-level inheritance without recursing through the same child template.
19
+ - Preserve nested root blocks through a depth-aware final substitution pass.
20
+ - Bound template, fragment, and expression caches, with TTL sweeps for stale entries.
21
+ - Retry transient AI skill-download failures.
22
+ - Activate the tina4-js skill for `tina4js` and `Tina4 JS` spellings as well as `tina4-js`.
23
+ - Keep `Tina4::VERSION` and both AI-facing guide markers on one version.
24
+
25
+ ## 3.13.99
26
+
9
27
  ### Breaking: `request.params` is route-params-only, and `path_params` is renamed `params`
10
28
 
11
29
  Ruby had the worst version of the param-pollution bug: `params` used to merge the query
data/lib/tina4/frond.rb CHANGED
@@ -31,9 +31,8 @@ module Tina4
31
31
  # late-constructed engines automatically inherit prior registrations.
32
32
  #
33
33
  # The same-name dual-callable (class + instance) methods below let callers
34
- # write either ``Tina4::Frond.add_filter(...)`` (class-level only) or
35
- # ``frond.add_filter(...)`` (updates both the class registry and the
36
- # instance's live filter map). Parity with tina4-python's
34
+ # write either ``Tina4::Frond.add_filter(...)`` (process-global) or
35
+ # ``frond.add_filter(...)`` (the instance's live filter map only). Parity with tina4-python's
37
36
  # ``_ClassOrInstanceMethod`` descriptor.
38
37
  @@class_filters = {}
39
38
  @@class_globals = {}
@@ -85,6 +84,11 @@ module Tina4
85
84
  # -- Compiled regex constants (optimization: avoid re-compiling in methods) --
86
85
  EXTENDS_RE = /\{%-?\s*extends\s+["'](.+?)["']\s*-?%\}/
87
86
  BLOCK_RE = /\{%-?\s*block\s+(\w+)\s*-?%\}(.*?)\{%-?\s*endblock\s*-?%\}/m
87
+ # Open/close halves of BLOCK_RE, used standalone by extract_blocks' depth
88
+ # counter -- a single non-greedy BLOCK_RE match cannot tell a NESTED
89
+ # endblock from the outer block's own, so it needs its own two-piece scan.
90
+ BLOCK_OPEN_RE = /\{%-?\s*block\s+(\w+)\s*-?%\}/
91
+ BLOCK_CLOSE_RE = /\{%-?\s*endblock\s*-?%\}/
88
92
  STRING_LIT_RE = /\A["'](.*)["']\z/
89
93
  INTEGER_RE = /\A-?\d+\z/
90
94
  FLOAT_RE = /\A-?\d+\.\d+\z/
@@ -204,8 +208,22 @@ module Tina4
204
208
  # exists for the workload that genuinely grows without limit for the life
205
209
  # of a worker: +render_string+ keys on md5(source), so an app that builds
206
210
  # template strings dynamically adds an entry per distinct string.
211
+ #
212
+ # Also reused for @fragment_cache (the {% cache %} tag's runtime store):
213
+ # a rendered fragment is a whole HTML string, the same order of magnitude
214
+ # as a compiled template, not a small per-expression descriptor.
207
215
  TEMPLATE_CACHE_MAX = 256
208
216
 
217
+ # Hard cap on every per-expression memo cache in this engine (ADR-0004):
218
+ # @filter_chain_cache, @resolve_cache, @dotted_split_cache. Mirrors PHP's
219
+ # MEMO_CACHE_MAX and the Python master's `@lru_cache(maxsize=1024)` on the
220
+ # equivalent module-level parsers. A template that builds expression
221
+ # strings dynamically would otherwise grow a plain instance Hash without
222
+ # limit for the lifetime of the engine — a memory footgun on a long-lived
223
+ # worker. Deliberately higher than TEMPLATE_CACHE_MAX: one entry here is a
224
+ # small parsed-path array, orders of magnitude smaller than a token list.
225
+ MEMO_CACHE_MAX = 1024
226
+
209
227
  # -- Lazy context overlay for for-loops (avoids full Hash#dup) --
210
228
  class LoopContext
211
229
  def initialize(parent)
@@ -420,31 +438,25 @@ module Tina4
420
438
 
421
439
  # Register a custom filter.
422
440
  #
423
- # Updates BOTH the class registry (so future ``Tina4::Frond.new`` picks
424
- # the filter up) AND this instance's live filter map (so the change is
425
- # visible to subsequent renders on the current engine).
441
+ # Updates this instance's live filter map only. Class calls remain the
442
+ # process-global registration path. tina4: ADR-0052.
426
443
  def add_filter(name, &blk)
427
- self.class.add_filter(name, &blk)
428
444
  @filters[name.to_s] = blk
429
445
  self
430
446
  end
431
447
 
432
448
  # Register a custom test.
433
449
  #
434
- # Updates BOTH the class registry and this instance's live tests map.
435
- # See ``add_filter`` for the dual-write semantics.
450
+ # Updates this instance's live tests map only.
436
451
  def add_test(name, &blk)
437
- self.class.add_test(name, &blk)
438
452
  @tests[name.to_s] = blk
439
453
  self
440
454
  end
441
455
 
442
456
  # Register a global variable available in all templates.
443
457
  #
444
- # Updates BOTH the class registry and this instance's live globals map.
445
- # See ``add_filter`` for the dual-write semantics.
458
+ # Updates this instance's live globals map only.
446
459
  def add_global(name, value)
447
- self.class.add_global(name, value)
448
460
  @globals[name.to_s] = value
449
461
  self
450
462
  end
@@ -577,6 +589,29 @@ module Tina4
577
589
  cache.keys.first(max_entries / 2).each { |key| cache.delete(key) }
578
590
  end
579
591
 
592
+ # Drop every TTL-expired entry from the {% cache %} fragment store.
593
+ #
594
+ # cap_cache bounds @fragment_cache by SIZE (insertion order, oldest
595
+ # first) but says nothing about STALENESS: a key that expired and is
596
+ # never visited again would otherwise sit in the Hash, still counted
597
+ # against the cap, until something else finally evicts it. An app
598
+ # keying fragments on a dynamic value (a page id, a user id) can churn
599
+ # through many such keys, so staleness has to be swept on its own
600
+ # schedule, not just bounded by count.
601
+ #
602
+ # Called on every {% cache %} render (cheap: bounded by TEMPLATE_CACHE_MAX
603
+ # entries, so at most 256 comparisons) rather than only for the key being
604
+ # read, so an unrelated key's expiry is cleaned up as a side effect of
605
+ # ANY fragment-cache render, not just a future hit on that same key.
606
+ #
607
+ # @param cache [Hash] fragment cache to sweep, mutated in place —
608
+ # key => [html, expires_at_unix_float]
609
+ # @return [void]
610
+ def sweep_expired_cache(cache)
611
+ now = Time.now.to_f
612
+ cache.delete_if { |_key, (_html, expires_at)| expires_at <= now }
613
+ end
614
+
580
615
  # -----------------------------------------------------------------------
581
616
  # Tokenizer
582
617
  # -----------------------------------------------------------------------
@@ -701,10 +736,29 @@ module Tina4
701
736
  render_tokens(tokens, context)
702
737
  end
703
738
 
739
+ # Return this template's OWN {% extends %} parent name, or nil.
740
+ #
741
+ # A template may extend at most one parent. Before 3.13.100 a SECOND
742
+ # {% extends %} tag anywhere in the source was silently invisible: only
743
+ # the first occurrence was ever matched, and the rest of the child's
744
+ # non-block content -- including the second extends tag -- was already
745
+ # discarded the same way ordinary non-block child content always is
746
+ # during inheritance. That hid what is almost always a mistake (a
747
+ # copy-paste, a bad merge) with zero signal. Raise clearly instead, the
748
+ # same policy 3.13.89 applied to an unknown tag.
749
+ def extends_target(source)
750
+ matches = source.scan(EXTENDS_RE)
751
+ if matches.length > 1
752
+ raise "Frond: template has #{matches.length} \"{% extends %}\" tags -- " \
753
+ "a template can extend only one parent"
754
+ end
755
+ source =~ EXTENDS_RE ? Regexp.last_match(1) : nil
756
+ end
757
+
704
758
  def execute_with_tokens(source, tokens, context)
705
759
  # Handle extends first
706
- if source =~ EXTENDS_RE
707
- parent_name = Regexp.last_match(1)
760
+ parent_name = extends_target(source)
761
+ if parent_name
708
762
  parent_source = load_template(parent_name)
709
763
  child_blocks = extract_blocks(source)
710
764
  return render_with_blocks(parent_source, context, child_blocks)
@@ -715,8 +769,8 @@ module Tina4
715
769
 
716
770
  def execute(source, context)
717
771
  # Handle extends first
718
- if source =~ EXTENDS_RE
719
- parent_name = Regexp.last_match(1)
772
+ parent_name = extends_target(source)
773
+ if parent_name
720
774
  parent_source = load_template(parent_name)
721
775
  child_blocks = extract_blocks(source)
722
776
  return render_with_blocks(parent_source, context, child_blocks)
@@ -725,20 +779,135 @@ module Tina4
725
779
  render_tokens(tokenize(source), context)
726
780
  end
727
781
 
782
+ # Extract {% block name %}...{% endblock %} from source, TOP-LEVEL only.
783
+ #
784
+ # Counts depth rather than relying on a single non-greedy BLOCK_RE scan:
785
+ # a plain scan cannot tell a NESTED block's own {% endblock %} from the
786
+ # outer block's real one, so it pairs the outer open with whichever
787
+ # {% endblock %} happens to come first -- silently truncating the outer
788
+ # block's captured content at the wrong tag. A nested block's own markup
789
+ # stays embedded, VERBATIM, inside its outer block's captured content
790
+ # here; resolving it is render_with_blocks' fixed-point substitution
791
+ # loop's job, not this method's. Matches the Python master's
792
+ # _extract_blocks.
728
793
  def extract_blocks(source)
729
794
  blocks = {}
730
- source.scan(BLOCK_RE) do
731
- blocks[Regexp.last_match(1)] = Regexp.last_match(2)
795
+ pos = 0
796
+ len = source.length
797
+
798
+ while pos < len
799
+ m_open = BLOCK_OPEN_RE.match(source, pos)
800
+ break unless m_open
801
+
802
+ name = m_open[1]
803
+ content_start = m_open.end(0)
804
+ depth = 1
805
+ scan = content_start
806
+ matched = false
807
+
808
+ while depth.positive? && scan < len
809
+ next_open = BLOCK_OPEN_RE.match(source, scan)
810
+ next_close = BLOCK_CLOSE_RE.match(source, scan)
811
+ break if next_close.nil? # malformed -- no matching endblock
812
+
813
+ if next_open && next_open.begin(0) < next_close.begin(0)
814
+ depth += 1
815
+ scan = next_open.end(0)
816
+ else
817
+ depth -= 1
818
+ if depth.zero?
819
+ blocks[name] = source[content_start...next_close.begin(0)]
820
+ pos = next_close.end(0)
821
+ matched = true
822
+ break
823
+ end
824
+ scan = next_close.end(0)
825
+ end
826
+ end
827
+
828
+ pos = content_start unless matched # malformed -- skip forward
732
829
  end
830
+
733
831
  blocks
734
832
  end
735
833
 
736
- def render_with_blocks(parent_source, context, child_blocks)
834
+ # Depth-aware block substitution against `source` (typically the
835
+ # fully-resolved root template).
836
+ #
837
+ # A single regex #gsub pass with BLOCK_RE (non-greedy) pairs an OUTER
838
+ # block's open tag with the FIRST {% endblock %} found -- which, when
839
+ # the outer block wraps a NESTED {% block %}, is the nested block's
840
+ # own close tag, not the outer's. That silently truncates the outer
841
+ # block's captured content and drops everything after the inner
842
+ # endblock (the root-nested-block content-loss bug: {% block body
843
+ # %}<section>{% block inner %}{% endblock %}</section>{% endblock %}
844
+ # rendered "<section></section>", the leaf's "inner" override AND
845
+ # root's own "body" wrapper both silently lost). This scans with an
846
+ # open/close depth counter instead (mirroring extract_blocks), so an
847
+ # outer block always captures its FULL body, nested child blocks
848
+ # included.
849
+ #
850
+ # The content chosen for each block -- the child override in `blocks`
851
+ # if present, else the block's own default body -- is then
852
+ # recursively substituted against the SAME `blocks` map before being
853
+ # tokenized and rendered, so a block nested inside another block
854
+ # resolves correctly regardless of which template in the inheritance
855
+ # chain declared the nesting (the root, an intermediate, however many
856
+ # levels deep).
857
+ #
858
+ # {{ parent() }} / {{ super() }} inside a block still render that
859
+ # block's OWN default content at this level (lazy, on first call).
860
+ def substitute_blocks(source, blocks, context)
737
861
  engine = self
738
- result = parent_source.gsub(BLOCK_RE) do
739
- name = Regexp.last_match(1)
740
- parent_content = Regexp.last_match(2)
741
- block_source = child_blocks.fetch(name, parent_content)
862
+ len = source.length
863
+ pieces = []
864
+ pos = 0
865
+
866
+ while pos < len
867
+ m_open = BLOCK_OPEN_RE.match(source, pos)
868
+ unless m_open
869
+ pieces << source[pos..]
870
+ break
871
+ end
872
+
873
+ pieces << source[pos...m_open.begin(0)] # untouched text before the tag
874
+
875
+ name = m_open[1]
876
+ content_start = m_open.end(0)
877
+ depth = 1
878
+ scan = content_start
879
+ close_match = nil
880
+
881
+ while depth.positive? && scan < len
882
+ next_open = BLOCK_OPEN_RE.match(source, scan)
883
+ next_close = BLOCK_CLOSE_RE.match(source, scan)
884
+ break if next_close.nil? # malformed -- no matching endblock
885
+
886
+ if next_open && next_open.begin(0) < next_close.begin(0)
887
+ depth += 1
888
+ scan = next_open.end(0)
889
+ else
890
+ depth -= 1
891
+ if depth.zero?
892
+ close_match = next_close
893
+ else
894
+ scan = next_close.end(0)
895
+ end
896
+ end
897
+ end
898
+
899
+ if close_match.nil?
900
+ # Malformed template (no matching endblock) -- keep the rest
901
+ # verbatim rather than lose it, the same leniency extract_blocks
902
+ # applies to this case.
903
+ pieces << source[m_open.begin(0)..]
904
+ pos = len
905
+ break
906
+ end
907
+
908
+ parent_content = source[content_start...close_match.begin(0)]
909
+ block_source = blocks.fetch(name, parent_content)
910
+ resolved_source = substitute_blocks(block_source, blocks, context)
742
911
 
743
912
  # Make parent() and super() available inside child blocks
744
913
  rendered_parent = nil
@@ -750,8 +919,61 @@ module Tina4
750
919
  end
751
920
 
752
921
  block_ctx = context.merge("parent" => get_parent, "super" => get_parent)
753
- render_tokens(tokenize(block_source), block_ctx)
922
+ pieces << render_tokens(tokenize(resolved_source), block_ctx)
923
+ pos = close_match.end(0)
924
+ end
925
+
926
+ pieces.join
927
+ end
928
+
929
+ def render_with_blocks(parent_source, context, child_blocks)
930
+ # Multi-level extends: when this parent ITSELF has its own {% extends %},
931
+ # merge its own block defaults under the child's overrides (the nearer
932
+ # descendant always wins) and recurse until a root template with no
933
+ # {% extends %} is reached. Before 3.13.100 this recursion never
934
+ # happened: a mid-level parent's own {% extends %} tag reached
935
+ # render_tokens' "block/endblock/extends" case, which is a silent
936
+ # no-op, so a 3+ level chain lost the root's wrapping entirely and any
937
+ # of the mid template's own non-block text leaked through unwrapped
938
+ # instead. Matches the Python/PHP/Node masters, which already recurse
939
+ # the parent -> grandparent chain the same way.
940
+ grandparent_name = extends_target(parent_source)
941
+ if grandparent_name
942
+ parent_blocks = extract_blocks(parent_source)
943
+ merged_blocks = parent_blocks.merge(child_blocks)
944
+
945
+ # Resolve NESTED blocks: a block value that itself contains a
946
+ # {% block inner %}...{% endblock %} tag (this level's own markup,
947
+ # not yet substituted) has that inner tag replaced with the merged
948
+ # dict's value for that name, or the inner tag's own default when
949
+ # nothing overrides it. Fixed-point because resolving one level can
950
+ # reveal another (a block three levels deep). Matches Python/Node's
951
+ # multi-level extends, and is what lets a grandchild override a
952
+ # block nested INSIDE a block the middle template redeclares.
953
+ changed = true
954
+ while changed
955
+ changed = false
956
+ merged_blocks.keys.each do |name|
957
+ resolved = merged_blocks[name].gsub(BLOCK_RE) do
958
+ inner_name = Regexp.last_match(1)
959
+ inner_default = Regexp.last_match(2)
960
+ merged_blocks.fetch(inner_name, inner_default)
961
+ end
962
+ if resolved != merged_blocks[name]
963
+ merged_blocks[name] = resolved
964
+ changed = true
965
+ end
966
+ end
967
+ end
968
+
969
+ grandparent_source = load_template(grandparent_name)
970
+ return render_with_blocks(grandparent_source, context, merged_blocks)
754
971
  end
972
+
973
+ # Depth-aware block substitution (handles a block nested inside
974
+ # another block at ANY level of the chain, including the root
975
+ # itself -- see substitute_blocks).
976
+ result = substitute_blocks(parent_source, child_blocks, context)
755
977
  render_tokens(tokenize(result), context)
756
978
  end
757
979
 
@@ -1015,6 +1237,7 @@ module Tina4
1015
1237
  root_var = @dotted_split_cache[var_name]
1016
1238
  unless root_var
1017
1239
  root_var = var_name.split(".")[0].split("[")[0].strip
1240
+ cap_cache(@dotted_split_cache, MEMO_CACHE_MAX)
1018
1241
  @dotted_split_cache[var_name] = root_var
1019
1242
  end
1020
1243
  return "" if !root_var.empty? && !@allowed_vars.include?(root_var) && root_var != "loop"
@@ -1260,6 +1483,7 @@ module Tina4
1260
1483
  end
1261
1484
 
1262
1485
  result = [variable, filters].freeze
1486
+ cap_cache(@filter_chain_cache, MEMO_CACHE_MAX)
1263
1487
  @filter_chain_cache[expr] = result
1264
1488
  result
1265
1489
  end
@@ -1784,6 +2008,7 @@ module Tina4
1784
2008
  parts = @resolve_cache[expr]
1785
2009
  unless parts
1786
2010
  parts = expr.split(RESOLVE_SPLIT_RE).reject(&:empty?)
2011
+ cap_cache(@resolve_cache, MEMO_CACHE_MAX)
1787
2012
  @resolve_cache[expr] = parts
1788
2013
  end
1789
2014
 
@@ -2265,6 +2490,8 @@ module Tina4
2265
2490
  cache_key = m ? m[1] : "default"
2266
2491
  ttl = m && m[2] ? m[2].to_i : 60
2267
2492
 
2493
+ sweep_expired_cache(@fragment_cache)
2494
+
2268
2495
  # Check cache
2269
2496
  cached = @fragment_cache[cache_key]
2270
2497
  if cached
@@ -2317,6 +2544,7 @@ module Tina4
2317
2544
  end
2318
2545
 
2319
2546
  rendered = render_tokens(body_tokens.dup, context)
2547
+ cap_cache(@fragment_cache, TEMPLATE_CACHE_MAX)
2320
2548
  @fragment_cache[cache_key] = [rendered, Time.now.to_f + ttl]
2321
2549
  [rendered, i]
2322
2550
  end
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.99"
4
+ VERSION = "3.13.100"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.99
4
+ version: 3.13.100
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-13 00:00:00.000000000 Z
11
+ date: 2026-08-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack