Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions lib/herb/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,17 @@ def initialize(input, properties = {})

action_view_helpers = @optimize && source_may_contain_action_view_helpers?(input)
transform_conditionals = @optimize && action_view_helpers
parse_result = ::Herb.parse(input, **@parser_options, track_whitespace: true,
action_view_helpers: action_view_helpers,
transform_conditionals: transform_conditionals)
render_nodes = @optimize && input.include?("render")

parse_result = ::Herb.parse(
input,
**@parser_options,
track_whitespace: true,
action_view_helpers: action_view_helpers,
transform_conditionals: transform_conditionals,
render_nodes: render_nodes
)

ast = parse_result.value
parser_errors = parse_result.errors

Expand All @@ -159,7 +167,19 @@ def initialize(input, properties = {})
ast.accept(visitor)
end

compiler = Compiler.new(self, properties)
compiler_options = properties.dup

if @optimize && render_nodes && properties.fetch(:inline_render, false)
require_relative "engine/render_inliner"

compiler_options[:render_inliner] = RenderInliner.new(
self,
project_path: @project_path,
filename: @relative_file_path
)
end

compiler = Compiler.new(self, compiler_options)

ast.accept(compiler)

Expand Down
58 changes: 58 additions & 0 deletions lib/herb/engine/compiler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ def initialize(engine, options = {})

@engine = engine
@escape = options.fetch(:escape) { options.fetch(:escape_html, false) }
@render_inliner = options[:render_inliner]
@tokens = [] #: Array[untyped]
@element_stack = [] #: Array[String]
@context_stack = [:html_content]
Expand Down Expand Up @@ -215,6 +216,18 @@ def visit_erb_content_node(node)
process_erb_tag(node)
end

def visit_erb_render_node(node)
if @render_inliner&.can_inline?(node)
if @render_inliner.collection?(node)
inline_collection(node)
else
inline_partial(node)
end
else
process_erb_tag(node)
end
end

def visit_erb_control_node(node, &)
if node.content
apply_trim(node, node.content.value.strip)
Expand Down Expand Up @@ -358,6 +371,51 @@ def visit_erb_control_with_parts(node, *parts)

private

def inline_partial(node)
resolved_path = @render_inliner.resolve_path(node)
return process_erb_tag(node) unless resolved_path

source = File.read(resolved_path)
locals = @render_inliner.local_assignments(node)

@render_inliner.push(resolved_path)

add_code("begin")
locals.each { |name, value| add_code("; #{name} = (#{value})") }
add_code(";")

partial_ast = @render_inliner.parse(source)
partial_ast&.accept(self)

add_code("; end;")

@render_inliner.pop(resolved_path)
end

def inline_collection(node)
resolved_path = @render_inliner.resolve_path(node)
return process_erb_tag(node) unless resolved_path

source = File.read(resolved_path)
collection_expr = @render_inliner.collection_expression(node)
item_name = @render_inliner.collection_item_name(node)
counter_name = "#{item_name}_counter"
locals = @render_inliner.local_assignments(node)

@render_inliner.push(resolved_path)

add_code("; __herb_collection = (#{collection_expr}); __herb_collection.each_with_index do |#{item_name}, #{counter_name}|")
locals.each { |name, value| add_code("; #{name} = (#{value})") }
add_code(";")

partial_ast = @render_inliner.parse(source)
partial_ast&.accept(self)

add_code("; end;")

@render_inliner.pop(resolved_path)
end

def check_for_escaped_erb_tag!(opening)
return unless opening.start_with?("<%%")

Expand Down
166 changes: 166 additions & 0 deletions lib/herb/engine/render_inliner.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# frozen_string_literal: true

module Herb
class Engine
class RenderInliner
def initialize(engine, options = {})
@engine = engine
@project_path = options[:project_path] || Pathname.new(Dir.pwd)
@filename = options[:filename]
@view_root = find_view_root
@source_directory = find_source_directory
@inlining_stack = Set.new
end

def can_inline?(node)
return false unless @view_root
return false unless node.static_partial?
return false if node.body&.any?

return can_inline_collection?(node) if node.keywords&.collection

resolved = resolve_path(node)
return false unless resolved
return false if @inlining_stack.include?(resolved.to_s)
return false unless safe_to_inline?(resolved)

true
end

def can_inline_collection?(node)
return false unless node.static_partial?
return false if node.keywords&.spacer_template

resolved = resolve_path(node)
return false unless resolved
return false if @inlining_stack.include?(resolved.to_s)
return false unless safe_to_inline?(resolved)

true
end

def collection?(node)
!!node.keywords&.collection
end

def collection_expression(node)
node.keywords&.collection&.value
end

def collection_item_name(node)
as_name = node.keywords&.as_name&.value
return as_name if as_name

partial = node.partial_path
return nil unless partial

File.basename(partial)
end

def resolve_path(node)
node.resolve(view_root: @view_root, source_directory: @source_directory)
end

def optimizable_renders(ast)
collector = OptimizableRenderCollector.new(self)
ast.accept(collector)
collector.results
end

def local_assignments(node)
locals = {} #: Hash[String, String]

node.keywords&.locals&.each do |local|
name = local.name&.value
value = local.value&.content

next unless name && value

value = name if value == "#{name}:" # Shorthand hash syntax

locals[name] = value
end

locals
end

def parse(source)
result = ::Herb.parse(
source,
render_nodes: true,
track_whitespace: true,
action_view_helpers: true,
transform_conditionals: true
)

result.value
end

def push(path)
@inlining_stack.add(path.to_s)
end

def pop(path)
@inlining_stack.delete(path.to_s)
end

private

def safe_to_inline?(file_path)
source = File.read(file_path)

return false if source.include?("content_for")
return false if source.match?(/\byield\b/)
return false if source.include?("local_assigns")

true
end

def find_view_root
candidates = [
@project_path.join("app", "views")
]

candidates.find(&:directory?)
end

def find_source_directory
return nil unless @filename && @view_root

dir = Pathname.new(@filename).dirname
@view_root.join(dir)
end
end

class OptimizableRenderCollector < ::Herb::Visitor
attr_reader :results

def initialize(inliner)
super()
@inliner = inliner
@results = [] #: Array[Hash[Symbol, untyped]]
end

def visit_erb_render_node(node)
if @inliner.can_inline?(node)
entry = {
node: node,
partial_path: node.partial_path,
resolved_path: @inliner.resolve_path(node),
locals: @inliner.local_assignments(node),
collection: @inliner.collection?(node),
}

if entry[:collection]
entry[:collection_expression] = @inliner.collection_expression(node)
entry[:item_name] = @inliner.collection_item_name(node)
end

@results << entry
end

visit_child_nodes(node)
end
end
end
end
6 changes: 6 additions & 0 deletions sig/herb/engine/compiler.rbs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 47 additions & 0 deletions sig/herb/engine/render_inliner.rbs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading