Skip to content

Commit f24abe0

Browse files
committed
add template digetor that usses an ast
1 parent acadf3f commit f24abe0

File tree

7 files changed

+251
-23
lines changed

7 files changed

+251
-23
lines changed

lib/view_component/cache_digestor.rb

Lines changed: 74 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,85 @@
11
# # frozen_string_literal: true
22

3+
require 'view_component/templat_dependency_extractor'
4+
35
module ViewComponent
46
class CacheDigestor
5-
@@digest_mutex = Mutex.new
7+
def initialize(component:)
8+
@component= component.class
9+
end
610

7-
class << self
8-
def digest(name:, finder:, format: nil, dependencies: nil)
9-
if dependencies.nil? || dependencies.empty?
10-
cache_key = "#{name}.#{format}"
11+
def digest
12+
gather_templates
13+
@templates.map do |template|
14+
if template.type == :file
15+
template_string = template.send(:source)
16+
ViewComponent::TemplateDependencyExtractor.new(template_string, template.extension.to_sym).extract
1117
else
12-
dependencies_suffix = dependencies.flatten.tap(&:compact!).join(".")
13-
cache_key = "#{name}.#{format}.#{dependencies_suffix}"
18+
# A digest cant be built for inline calls as there is no template to parse
19+
[]
1420
end
15-
cache_key
1621
end
1722
end
23+
24+
def gather_templates
25+
@templates ||=
26+
begin
27+
templates = @component.sidecar_files(
28+
ActionView::Template.template_handler_extensions
29+
).map do |path|
30+
# Extract format and variant from template filename
31+
this_format, variant =
32+
File
33+
.basename(path) # "variants_component.html+mini.watch.erb"
34+
.split(".")[1..-2] # ["html+mini", "watch"]
35+
.join(".") # "html+mini.watch"
36+
.split("+") # ["html", "mini.watch"]
37+
.map(&:to_sym) # [:html, :"mini.watch"]
38+
39+
out = Template.new(
40+
component: @component,
41+
type: :file,
42+
path: path,
43+
lineno: 0,
44+
extension: path.split(".").last,
45+
this_format: this_format.to_s.split(".").last&.to_sym, # strip locale from this_format, see #2113
46+
variant: variant
47+
)
48+
49+
out
50+
end
51+
52+
component_instance_methods_on_self = @component.instance_methods(false)
53+
54+
(
55+
@component.ancestors.take_while { |ancestor| ancestor != ViewComponent::Base } - @component.included_modules
56+
).flat_map { |ancestor| ancestor.instance_methods(false).grep(/^call(_|$)/) }
57+
.uniq
58+
.each do |method_name|
59+
templates << Template.new(
60+
component: @component,
61+
type: :inline_call,
62+
this_format: ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT,
63+
variant: method_name.to_s.include?("call_") ? method_name.to_s.sub("call_", "").to_sym : nil,
64+
method_name: method_name,
65+
defined_on_self: component_instance_methods_on_self.include?(method_name)
66+
)
67+
end
68+
69+
if @component.inline_template.present?
70+
templates << Template.new(
71+
component: @component,
72+
type: :inline,
73+
path: @component.inline_template.path,
74+
lineno: @component.inline_template.lineno,
75+
source: @component.inline_template.source.dup,
76+
extension: @component.inline_template.language
77+
)
78+
end
79+
80+
templates
81+
end
82+
end
83+
1884
end
1985
end

lib/view_component/cacheable.rb

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,29 @@ module ViewComponent::Cacheable
88
extend ActiveSupport::Concern
99

1010
included do
11-
class_attribute :__vc_cache_dependencies, default: Set[:format, :__vc_format, :identifier]
11+
12+
class_attribute :__vc_cache_options, default: Set[:identifier]
13+
class_attribute :__vc_cache_dependencies, default: Set.new
1214

1315
# For caching, such as #cache_if
1416
#
1517
# @private
1618
def view_cache_dependencies
17-
return if __vc_cache_dependencies.blank? || __vc_cache_dependencies.none? || __vc_cache_dependencies.nil?
19+
self.class.__vc_cache_dependencies.map { |dep| public_send(dep) }
20+
end
1821

19-
computed_view_cache_dependencies = __vc_cache_dependencies.map { |dep| if respond_to?(dep) then public_send(dep) end }
20-
combined_fragment_cache_key(ActiveSupport::Cache.expand_cache_key(computed_view_cache_dependencies))
22+
def view_cache_options
23+
return if __vc_cache_options.blank?
24+
25+
computed_view_cache_options = __vc_cache_options.map { |opt| if respond_to?(opt) then public_send(opt) end }
26+
combined_fragment_cache_key(ActiveSupport::Cache.expand_cache_key(computed_view_cache_options + component_digest))
2127
end
2228

2329
# Render component from cache if possible
2430
#
2531
# @private
2632
def __vc_render_cacheable(rendered_template)
27-
if __vc_cache_dependencies != [:format, :__vc_format]
33+
if __vc_cache_options.any?
2834
ViewComponent::CachingRegistry.track_caching do
2935
template_fragment(rendered_template)
3036
end
@@ -34,7 +40,7 @@ def __vc_render_cacheable(rendered_template)
3440
end
3541

3642
def template_fragment(rendered_template)
37-
if content = read_fragment(rendered_template)
43+
if content = read_fragment
3844
@view_renderer.cache_hits[@current_template&.virtual_path] = :hit if defined?(@view_renderer)
3945
content
4046
else
@@ -43,13 +49,13 @@ def template_fragment(rendered_template)
4349
end
4450
end
4551

46-
def read_fragment(rendered_template)
47-
Rails.cache.fetch(component_digest)
52+
def read_fragment
53+
Rails.cache.fetch(view_cache_options)
4854
end
4955

5056
def write_fragment(rendered_template)
5157
content = __vc_render_template(rendered_template)
52-
Rails.cache.fetch(component_digest) do
58+
Rails.cache.fetch(view_cache_options) do
5359
content
5460
end
5561
content
@@ -63,20 +69,19 @@ def combined_fragment_cache_key(key)
6369
end
6470

6571
def component_digest
66-
component_name = self.class.name.demodulize.underscore
67-
ViewComponent::CacheDigestor.digest(name: component_name, format: format, finder: @lookup_context, dependencies: view_cache_dependencies)
72+
ViewComponent::CacheDigestor.new(component: self).digest
6873
end
6974
end
7075

7176
class_methods do
7277
# For caching the component
7378
def cache_on(*args)
74-
__vc_cache_dependencies.merge(args)
79+
__vc_cache_options.merge(args)
7580
end
7681

7782
def inherited(child)
78-
child.__vc_cache_dependencies = __vc_cache_dependencies.dup
79-
83+
child.__vc_cache_options = __vc_cache_options.dup
84+
8085
super
8186
end
8287
end
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
2+
# frozen_string_literal: true
3+
4+
require 'prism'
5+
6+
module ViewComponent
7+
class PrismRenderDependencyExtractor
8+
def initialize(code)
9+
@code = code
10+
@dependencies = []
11+
end
12+
13+
def extract
14+
result = Prism.parse(@code)
15+
walk(result.value)
16+
@dependencies
17+
end
18+
19+
private
20+
21+
def walk(node)
22+
return unless node.respond_to?(:child_nodes)
23+
24+
if node.is_a?(Prism::CallNode) && render_call?(node)
25+
extract_render_target(node)
26+
end
27+
28+
node.child_nodes.each { |child| walk(child) if child }
29+
end
30+
31+
def render_call?(node)
32+
node.receiver.nil? && node.name == :render
33+
end
34+
35+
def extract_render_target(node)
36+
args = node.arguments&.arguments
37+
return unless args && !args.empty?
38+
39+
first_arg = args.first
40+
41+
if first_arg.is_a?(Prism::CallNode) &&
42+
first_arg.name == :new &&
43+
first_arg.receiver.is_a?(Prism::ConstantPathNode) || first_arg.receiver.is_a?(Prism::ConstantReadNode)
44+
45+
const = extract_constant_path(first_arg.receiver)
46+
@dependencies << const if const
47+
end
48+
end
49+
50+
def extract_constant_path(const_node)
51+
parts = []
52+
current = const_node
53+
54+
while current
55+
case current
56+
when Prism::ConstantPathNode
57+
parts.unshift(current.child.name)
58+
current = current.parent
59+
when Prism::ConstantReadNode
60+
parts.unshift(current.name)
61+
break
62+
else
63+
break
64+
end
65+
end
66+
67+
parts.join("::")
68+
end
69+
end
70+
end
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
2+
# frozen_string_literal: true
3+
4+
require_relative 'template_ast_builder'
5+
require_relative 'prism_render_dependency_extractor'
6+
7+
module ViewComponent
8+
class TemplateDependencyExtractor
9+
def initialize(template_string, engine)
10+
@template_string = template_string
11+
@engine = engine
12+
@dependencies = []
13+
end
14+
15+
def extract
16+
ast = TemplateAstBuilder.build(@template_string, @engine)
17+
walk(ast.split(';'))
18+
@dependencies.uniq
19+
end
20+
21+
private
22+
23+
def walk(node)
24+
return unless node.is_a?(Array)
25+
26+
node.each { extract_from_ruby(_1) if _1.is_a?(String) }
27+
end
28+
29+
def extract_from_ruby(ruby_code)
30+
return unless ruby_code.include?("render")
31+
32+
@dependencies.concat PrismRenderDependencyExtractor.new(ruby_code).extract
33+
extract_partial_or_layout(ruby_code)
34+
end
35+
36+
def extract_partial_or_layout(code)
37+
partial_match = code.match(/partial:\s*["']([^"']+)["']/)
38+
layout_match = code.match(/layout:\s*["']([^"']+)["']/)
39+
direct_render = code.match(/render\s*\(?\s*["']([^"']+)["']/)
40+
41+
@dependencies << partial_match[1] if partial_match
42+
@dependencies << layout_match[1] if layout_match
43+
@dependencies << direct_render[1] if direct_render
44+
end
45+
end
46+
end

lib/view_component/template.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ class Template
55
DataWithSource = Struct.new(:format, :identifier, :short_identifier, :type, keyword_init: true)
66
DataNoSource = Struct.new(:source, :identifier, :type, keyword_init: true)
77

8-
attr_reader :variant, :this_format, :type
8+
attr_reader :variant, :this_format, :type, :extension
99

1010
def initialize(
1111
component:,
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
2+
# frozen_string_literal: true
3+
4+
require 'temple'
5+
require 'slim'
6+
require 'haml'
7+
require 'erb'
8+
9+
module ViewComponent
10+
class TemplateAstBuilder
11+
class HamlTempleWrapper < Temple::Engine
12+
def call(template)
13+
engine = Haml::Engine.new(template, format: :xhtml)
14+
html = engine.render
15+
[:multi, [:static, html]]
16+
end
17+
end
18+
19+
class ErbTempleWrapper < Temple::Engine
20+
def call(template)
21+
Temple::ERB::Engine.new.call(template)
22+
end
23+
end
24+
25+
ENGINE_MAP = {
26+
slim: -> { Slim::Engine.new },
27+
haml: -> { HamlTempleWrapper.new },
28+
erb: -> { ErbTempleWrapper.new }
29+
}
30+
31+
def self.build(template_string, engine_name)
32+
engine = ENGINE_MAP.fetch(engine_name.to_sym) do
33+
raise ArgumentError, "Unsupported engine: #{engine_name.inspect}"
34+
end.call
35+
36+
engine.call(template_string)
37+
end
38+
end
39+
end
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
<p class='cache-component__cache-key'><%= view_cache_dependencies %></p>
22
<p class='cache-component__cache-message' data-time=data-time="<%= Time.zone.now %>"><%= "#{foo} #{bar}" %></p>
3+
4+
<%= render(ButtonToComponent.new) %>

0 commit comments

Comments
 (0)