tina4ruby 3.13.98 → 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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +102 -0
  3. data/lib/tina4/ai.rb +32 -3
  4. data/lib/tina4/api.rb +5 -0
  5. data/lib/tina4/auto_crud.rb +62 -4
  6. data/lib/tina4/background.rb +112 -31
  7. data/lib/tina4/cache.rb +3 -2
  8. data/lib/tina4/cli.rb +55 -67
  9. data/lib/tina4/database.rb +97 -49
  10. data/lib/tina4/database_adapter.rb +169 -15
  11. data/lib/tina4/dev_admin.rb +137 -9
  12. data/lib/tina4/dispatch_pipeline.rb +145 -4
  13. data/lib/tina4/drivers/firebird_driver.rb +59 -12
  14. data/lib/tina4/drivers/mongodb_driver.rb +98 -14
  15. data/lib/tina4/drivers/mssql_driver.rb +39 -2
  16. data/lib/tina4/drivers/mysql_driver.rb +43 -3
  17. data/lib/tina4/drivers/odbc_driver.rb +36 -2
  18. data/lib/tina4/drivers/postgres_driver.rb +5 -0
  19. data/lib/tina4/drivers/sqlite_driver.rb +11 -1
  20. data/lib/tina4/env.rb +1 -1
  21. data/lib/tina4/error_overlay.rb +43 -49
  22. data/lib/tina4/field_types.rb +33 -16
  23. data/lib/tina4/frond.rb +277 -27
  24. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +1 -1
  25. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +1 -1
  26. data/lib/tina4/graphql.rb +2 -2
  27. data/lib/tina4/log.rb +652 -485
  28. data/lib/tina4/mcp.rb +9 -1
  29. data/lib/tina4/messenger.rb +25 -0
  30. data/lib/tina4/middleware.rb +189 -76
  31. data/lib/tina4/migration.rb +47 -15
  32. data/lib/tina4/orm.rb +280 -59
  33. data/lib/tina4/port_takeover.rb +202 -0
  34. data/lib/tina4/public/js/tina4-dev-admin.min.js +23 -19
  35. data/lib/tina4/rack_app.rb +201 -59
  36. data/lib/tina4/realtime.rb +6 -1
  37. data/lib/tina4/request.rb +259 -51
  38. data/lib/tina4/router.rb +20 -2
  39. data/lib/tina4/seeder.rb +68 -19
  40. data/lib/tina4/shutdown.rb +4 -0
  41. data/lib/tina4/sql_translator.rb +115 -86
  42. data/lib/tina4/swagger.rb +19 -3
  43. data/lib/tina4/template.rb +61 -6
  44. data/lib/tina4/test_client.rb +49 -3
  45. data/lib/tina4/testing.rb +16 -11
  46. data/lib/tina4/validator.rb +7 -1
  47. data/lib/tina4/version.rb +1 -1
  48. data/lib/tina4/webserver.rb +28 -40
  49. data/lib/tina4.rb +12 -1
  50. metadata +3 -2
@@ -43,31 +43,45 @@ module Tina4
43
43
  end
44
44
  end
45
45
 
46
- def integer_field(name, primary_key: false, auto_increment: false, nullable: true, default: nil)
46
+ # Feature 19: the constraint options (required:, min:/max: for numbers,
47
+ # min_length:/max_length:/pattern: for strings) are ENFORCED on save() by
48
+ # ORM#validate -- bringing Ruby up to the shared cross-framework richness
49
+ # (Ruby used to be null-only). `length:` stays a DDL VARCHAR sizing hint and
50
+ # is deliberately never validated (parity with PHP's `length`); use
51
+ # max_length: for a value cap.
52
+ def integer_field(name, primary_key: false, auto_increment: false, nullable: true, default: nil,
53
+ required: false, min: nil, max: nil)
47
54
  register_field(name, :integer, primary_key: primary_key, auto_increment: auto_increment,
48
- nullable: nullable, default: default)
55
+ nullable: nullable, default: default, required: required, min: min, max: max)
49
56
  end
50
57
 
51
- def string_field(name, length: 255, primary_key: false, nullable: true, default: nil)
58
+ def string_field(name, length: 255, primary_key: false, nullable: true, default: nil,
59
+ required: false, min_length: nil, max_length: nil, pattern: nil)
52
60
  register_field(name, :string, length: length, primary_key: primary_key,
53
- nullable: nullable, default: default)
61
+ nullable: nullable, default: default, required: required,
62
+ min_length: min_length, max_length: max_length, pattern: pattern)
54
63
  end
55
64
 
56
- def text_field(name, nullable: true, default: nil)
57
- register_field(name, :text, nullable: nullable, default: default)
65
+ def text_field(name, nullable: true, default: nil,
66
+ required: false, min_length: nil, max_length: nil, pattern: nil)
67
+ register_field(name, :text, nullable: nullable, default: default, required: required,
68
+ min_length: min_length, max_length: max_length, pattern: pattern)
58
69
  end
59
70
 
60
- def float_field(name, nullable: true, default: nil)
61
- register_field(name, :float, nullable: nullable, default: default)
71
+ def float_field(name, nullable: true, default: nil, required: false, min: nil, max: nil)
72
+ register_field(name, :float, nullable: nullable, default: default,
73
+ required: required, min: min, max: max)
62
74
  end
63
75
 
64
- def decimal_field(name, precision: 10, scale: 2, nullable: true, default: nil)
76
+ def decimal_field(name, precision: 10, scale: 2, nullable: true, default: nil,
77
+ required: false, min: nil, max: nil)
65
78
  register_field(name, :decimal, precision: precision, scale: scale,
66
- nullable: nullable, default: default)
79
+ nullable: nullable, default: default, required: required, min: min, max: max)
67
80
  end
68
81
 
69
- def numeric_field(name, nullable: true, default: nil)
70
- register_field(name, :float, nullable: nullable, default: default)
82
+ def numeric_field(name, nullable: true, default: nil, required: false, min: nil, max: nil)
83
+ register_field(name, :float, nullable: nullable, default: default,
84
+ required: required, min: min, max: max)
71
85
  end
72
86
 
73
87
  def boolean_field(name, nullable: true, default: nil)
@@ -100,8 +114,11 @@ module Tina4
100
114
  # - Registers an integer field for the column
101
115
  # - Calls belongs_to on this class (strip _id suffix for association name)
102
116
  # - Calls has_many on the referenced class. The accessor name is the
103
- # declaring class name lowercased + "s" (e.g. Post → posts), matching
104
- # Python; override with related_name:. Works whether the referenced
117
+ # declaring class name lowercased and SMART-pluralized via
118
+ # Tina4.pluralize (e.g. Post posts, Category → categories -- the same
119
+ # inflection the hand-written has_many uses, not a naive + "s" that
120
+ # produced "categorys"); override with related_name:. Works whether the
121
+ # referenced
105
122
  # class loaded before OR after this one, including the string form
106
123
  # (references: "Author") for forward references — resolution is
107
124
  # deferred via Tina4::ORM.inherited until the target class exists.
@@ -128,7 +145,7 @@ module Tina4
128
145
 
129
146
  # Wire has_many on referenced class (if already a loaded Class)
130
147
  if references.is_a?(Class) && references.respond_to?(:has_many, true)
131
- hm_name = (related_name || "#{self.name.split("::").last.downcase}s").to_sym
148
+ hm_name = (related_name || Tina4.pluralize(self.name.split("::").last.downcase)).to_sym
132
149
  references.has_many(hm_name, class_name: self.name.split("::").last, foreign_key: name.to_s)
133
150
  end
134
151
 
@@ -136,7 +153,7 @@ module Tina4
136
153
  @@_fk_registry ||= {}
137
154
  ref_name = references.is_a?(Class) ? references.name.split("::").last : references.to_s.split("::").last
138
155
  @@_fk_registry[ref_name] ||= []
139
- hm_key = (related_name || "#{self.name.split("::").last.downcase}s").to_s
156
+ hm_key = (related_name || Tina4.pluralize(self.name.split("::").last.downcase)).to_s
140
157
  @@_fk_registry[ref_name] << {
141
158
  declaring_class: self,
142
159
  has_many_name: hm_key.to_sym,
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
  # -----------------------------------------------------------------------
@@ -651,11 +686,33 @@ module Tina4
651
686
  # Template loading
652
687
  # -----------------------------------------------------------------------
653
688
 
689
+ # Load a template's source, CONFINED under the templates directory.
690
+ #
691
+ # Every path-taking tag ({% include %}, {% extends %}, {% import %},
692
+ # {% from ... import %}) funnels through this one loader, so this single guard
693
+ # confines them all (TAG-DEC-01): a name that is absolute, climbs out with a
694
+ # +..+ up-level segment, or resolves through a symlink to a location OUTSIDE
695
+ # the templates root is REFUSED -- the outside file is never read. Template
696
+ # -side analogue of the static-asset confinement (feature 41 / ADR-0050).
654
697
  def load_template(name)
698
+ # Lexical belt: refuse an absolute path or a `..` up-level segment before
699
+ # touching the filesystem (defense in depth in front of the realpath check).
700
+ if name.start_with?("/", "\\") || name =~ %r{\A[A-Za-z]:} ||
701
+ name.split(%r{[\\/]}).include?("..")
702
+ raise "Template path escapes the templates directory: #{name}"
703
+ end
655
704
  path = File.join(@template_dir, name)
656
- raise "Template not found: #{path}" unless File.exist?(path)
705
+ raise "Template not found: #{path}" unless File.file?(path)
657
706
 
658
- File.read(path, encoding: "utf-8")
707
+ # Realpath containment: a symlink INSIDE the templates dir whose target
708
+ # resolves OUTSIDE it is refused (the lexical belt cannot see a symlink).
709
+ root = File.realpath(@template_dir)
710
+ real = File.realpath(path)
711
+ unless real == root || real.start_with?(root + File::SEPARATOR)
712
+ raise "Template path escapes the templates directory: #{name}"
713
+ end
714
+
715
+ File.read(real, encoding: "utf-8")
659
716
  end
660
717
 
661
718
  # -----------------------------------------------------------------------
@@ -679,10 +736,29 @@ module Tina4
679
736
  render_tokens(tokens, context)
680
737
  end
681
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
+
682
758
  def execute_with_tokens(source, tokens, context)
683
759
  # Handle extends first
684
- if source =~ EXTENDS_RE
685
- parent_name = Regexp.last_match(1)
760
+ parent_name = extends_target(source)
761
+ if parent_name
686
762
  parent_source = load_template(parent_name)
687
763
  child_blocks = extract_blocks(source)
688
764
  return render_with_blocks(parent_source, context, child_blocks)
@@ -693,8 +769,8 @@ module Tina4
693
769
 
694
770
  def execute(source, context)
695
771
  # Handle extends first
696
- if source =~ EXTENDS_RE
697
- parent_name = Regexp.last_match(1)
772
+ parent_name = extends_target(source)
773
+ if parent_name
698
774
  parent_source = load_template(parent_name)
699
775
  child_blocks = extract_blocks(source)
700
776
  return render_with_blocks(parent_source, context, child_blocks)
@@ -703,20 +779,135 @@ module Tina4
703
779
  render_tokens(tokenize(source), context)
704
780
  end
705
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.
706
793
  def extract_blocks(source)
707
794
  blocks = {}
708
- source.scan(BLOCK_RE) do
709
- 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
710
829
  end
830
+
711
831
  blocks
712
832
  end
713
833
 
714
- 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)
715
861
  engine = self
716
- result = parent_source.gsub(BLOCK_RE) do
717
- name = Regexp.last_match(1)
718
- parent_content = Regexp.last_match(2)
719
- 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)
720
911
 
721
912
  # Make parent() and super() available inside child blocks
722
913
  rendered_parent = nil
@@ -728,8 +919,61 @@ module Tina4
728
919
  end
729
920
 
730
921
  block_ctx = context.merge("parent" => get_parent, "super" => get_parent)
731
- render_tokens(tokenize(block_source), block_ctx)
922
+ pieces << render_tokens(tokenize(resolved_source), block_ctx)
923
+ pos = close_match.end(0)
732
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)
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)
733
977
  render_tokens(tokenize(result), context)
734
978
  end
735
979
 
@@ -993,6 +1237,7 @@ module Tina4
993
1237
  root_var = @dotted_split_cache[var_name]
994
1238
  unless root_var
995
1239
  root_var = var_name.split(".")[0].split("[")[0].strip
1240
+ cap_cache(@dotted_split_cache, MEMO_CACHE_MAX)
996
1241
  @dotted_split_cache[var_name] = root_var
997
1242
  end
998
1243
  return "" if !root_var.empty? && !@allowed_vars.include?(root_var) && root_var != "loop"
@@ -1238,6 +1483,7 @@ module Tina4
1238
1483
  end
1239
1484
 
1240
1485
  result = [variable, filters].freeze
1486
+ cap_cache(@filter_chain_cache, MEMO_CACHE_MAX)
1241
1487
  @filter_chain_cache[expr] = result
1242
1488
  result
1243
1489
  end
@@ -1762,6 +2008,7 @@ module Tina4
1762
2008
  parts = @resolve_cache[expr]
1763
2009
  unless parts
1764
2010
  parts = expr.split(RESOLVE_SPLIT_RE).reject(&:empty?)
2011
+ cap_cache(@resolve_cache, MEMO_CACHE_MAX)
1765
2012
  @resolve_cache[expr] = parts
1766
2013
  end
1767
2014
 
@@ -2243,6 +2490,8 @@ module Tina4
2243
2490
  cache_key = m ? m[1] : "default"
2244
2491
  ttl = m && m[2] ? m[2].to_i : 60
2245
2492
 
2493
+ sweep_expired_cache(@fragment_cache)
2494
+
2246
2495
  # Check cache
2247
2496
  cached = @fragment_cache[cache_key]
2248
2497
  if cached
@@ -2295,6 +2544,7 @@ module Tina4
2295
2544
  end
2296
2545
 
2297
2546
  rendered = render_tokens(body_tokens.dup, context)
2547
+ cap_cache(@fragment_cache, TEMPLATE_CACHE_MAX)
2298
2548
  @fragment_cache[cache_key] = [rendered, Time.now.to_f + ttl]
2299
2549
  [rendered, i]
2300
2550
  end
@@ -108,7 +108,7 @@ Tina4::Router.post("/api/gallery/auth/login") do |request, response|
108
108
  end
109
109
 
110
110
  Tina4::Router.get("/api/gallery/auth/verify") do |request, response|
111
- token = request.params["token"].to_s
111
+ token = request.query["token"].to_s
112
112
  result = Tina4::Auth.validate_token(token)
113
113
  response.json({ valid: result[:valid] })
114
114
  end
@@ -176,7 +176,7 @@
176
176
  </thead>
177
177
  <tbody>
178
178
  <tr><td>1</td><td>tina4-python</td><td>7145</td><td>Python 3.12+</td><td><span class="badge bg-success">Stable</span></td></tr>
179
- <tr><td>2</td><td>tina4-php</td><td>7146</td><td>PHP 8.2+</td><td><span class="badge bg-success">Stable</span></td></tr>
179
+ <tr><td>2</td><td>tina4-php</td><td>7145</td><td>PHP 8.2+</td><td><span class="badge bg-success">Stable</span></td></tr>
180
180
  <tr><td>3</td><td>tina4-ruby</td><td>7147</td><td>Ruby 3.1+</td><td><span class="badge bg-success">Stable</span></td></tr>
181
181
  <tr><td>4</td><td>tina4-nodejs</td><td>7148</td><td>Node 20+</td><td><span class="badge bg-success">Stable</span></td></tr>
182
182
  </tbody>
data/lib/tina4/graphql.rb CHANGED
@@ -1091,9 +1091,9 @@ module Tina4
1091
1091
 
1092
1092
  # Optional: GET for GraphiQL/introspection
1093
1093
  Tina4.get path, auth: false do |request, response|
1094
- query = request.params["query"]
1094
+ query = request.query["query"]
1095
1095
  if query
1096
- variables = request.params["variables"]
1096
+ variables = request.query["variables"]
1097
1097
  variables = JSON.parse(variables) if variables.is_a?(String) && !variables.empty?
1098
1098
  result = graphql.execute(query, variables: variables || {}, context: { request: request })
1099
1099
  response.json(result)