diff --git a/lib/rdoc/generator/aliki.rb b/lib/rdoc/generator/aliki.rb
index 5157283b3a..ae040f90ad 100644
--- a/lib/rdoc/generator/aliki.rb
+++ b/lib/rdoc/generator/aliki.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
require 'uri'
+require_relative 'compiled_template'
module RDoc
module Generator
@@ -13,12 +14,37 @@ module Generator
class Aliki < Generator::Darkfish
DESCRIPTION = 'HTML generator, written by Stan Lo'
+ TEMPLATE_DIR = (Pathname.new(__dir__) + 'template' + 'aliki').freeze # :nodoc:
+ # Pages include the assembled head's inputs; absent page objects are nil.
+ PAGE_INPUTS = %i[io rel_prefix asset_rel_prefix current klass file].freeze # :nodoc:
+ TEMPLATE_INPUTS = { # :nodoc:
+ 'class.rhtml' => PAGE_INPUTS + [:breadcrumb],
+ 'index.rhtml' => PAGE_INPUTS,
+ 'page.rhtml' => PAGE_INPUTS,
+ 'servlet_not_found.rhtml' => PAGE_INPUTS + [:message],
+ 'servlet_root.rhtml' => PAGE_INPUTS + [:installed],
+ '_head.rhtml' => %i[rel_prefix asset_rel_prefix current klass file],
+ '_aside_toc.rhtml' => [],
+ '_footer.rhtml' => [:rel_prefix],
+ '_header.rhtml' => [:rel_prefix],
+ '_icons.rhtml' => [],
+ '_sidebar_ancestors.rhtml' => [:klass],
+ '_sidebar_classes.rhtml' => [:rel_prefix],
+ '_sidebar_extends.rhtml' => [:klass],
+ '_sidebar_includes.rhtml' => [:klass],
+ '_sidebar_installed.rhtml' => [:installed],
+ '_sidebar_methods.rhtml' => [:klass],
+ '_sidebar_pages.rhtml' => %i[rel_prefix current],
+ '_sidebar_search.rhtml' => [],
+ '_sidebar_sections.rhtml' => [:klass],
+ '_sidebar_toggle.rhtml' => [],
+ }.transform_values(&:freeze).freeze
+
RDoc.add_generator self
def initialize(store, options)
super
- aliki_template_dir = File.expand_path(File.join(__dir__, 'template', 'aliki'))
- @template_dir = Pathname.new(aliki_template_dir)
+ @template_dir = TEMPLATE_DIR
end
##
@@ -151,8 +177,51 @@ def resolve_url(rel_prefix, url)
"#{rel_prefix}/#{url}"
end
+ #: (Pathname, ?bool, ?Class[ERB]) -> ERB
+ def template_for(file, page = true, klass = ERB)
+ cached = @template_cache[file]
+ # String and streaming ERB compile different output operations. Replace
+ # only owned compiled templates; custom ERB caches keep legacy behavior.
+ if cached.is_a?(CompiledTemplate) && cached.class != klass
+ @template_cache.delete(file)
+ end
+
+ template = super
+ return template if template.is_a?(CompiledTemplate)
+ return template unless [ERB, ERBIO, ERBPartial].include?(template.class)
+
+ if inputs = builtin_template_inputs(file)
+ template.extend CompiledTemplate
+ template.generator = self
+ template.render_inputs = inputs
+ end
+ template
+ end
+
private
+ #: (Pathname, ?Pathname?, **untyped) -> untyped
+ def render_page_template(template_file, out_file = nil, **inputs, &block)
+ return super unless builtin_template_inputs(template_file)
+
+ erb_class = erb_class_for out_file
+ template = template_for template_file, true, erb_class
+ return super unless template.is_a?(CompiledTemplate)
+
+ render_template template_file, out_file do |io|
+ CompiledTemplate::Inputs.new(io: io, **inputs)
+ end
+ end
+
+ #: (Pathname) -> Array[Symbol]?
+ def builtin_template_inputs(file)
+ # Custom generators and templates retain their shared-binding behavior.
+ return unless self.class == Aliki && @template_dir.expand_path == TEMPLATE_DIR
+ return unless file.dirname.expand_path == TEMPLATE_DIR
+
+ TEMPLATE_INPUTS[file.basename.to_s]
+ end
+
def template_encoding
::Encoding::UTF_8
end
diff --git a/lib/rdoc/generator/compiled_template.rb b/lib/rdoc/generator/compiled_template.rb
new file mode 100644
index 0000000000..e8f372cde3
--- /dev/null
+++ b/lib/rdoc/generator/compiled_template.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module RDoc
+ module Generator
+ # Caches a renderer with declared inputs for an Aliki template. Ordinary
+ # bindings still use ERB's original evaluation and shared-local semantics.
+ module CompiledTemplate # :nodoc:
+
+ Inputs = Struct.new(
+ :io, :rel_prefix, :asset_rel_prefix, :current, :klass, :file,
+ :breadcrumb, :message, :installed, keyword_init: true
+ )
+
+ attr_writer :generator, :render_inputs
+
+ #: (?(Binding | Inputs)?) -> untyped
+ def result(context = nil)
+ return super unless context.is_a?(Inputs)
+
+ @compiled_renderer ||= def_module("render(#{@render_inputs.join(', ')})").instance_method(:render).bind(@generator)
+ @compiled_renderer.call(*@render_inputs.map { |name| context[name] })
+ end
+ end
+ end
+end
diff --git a/lib/rdoc/generator/darkfish.rb b/lib/rdoc/generator/darkfish.rb
index c097bab70c..5ce314b1d4 100644
--- a/lib/rdoc/generator/darkfish.rb
+++ b/lib/rdoc/generator/darkfish.rb
@@ -293,7 +293,8 @@ def generate_index
@title = @options.title
@main_page = @files.find { |f| f.full_name == @options.main_page }
- render_template template_file, out_file do |io|
+ inputs = { rel_prefix: rel_prefix, asset_rel_prefix: asset_rel_prefix, current: @main_page }
+ render_page_template template_file, out_file, **inputs do |io|
here = binding
# suppress 1.9.3 warning
here.local_variable_set(:asset_rel_prefix, asset_rel_prefix)
@@ -333,7 +334,11 @@ def generate_class(klass, template_file = nil)
@title = "#{klass.type} #{klass.full_name} - #{@options.title}"
debug_msg " rendering #{out_file}"
- render_template template_file, out_file do |io|
+ inputs = {
+ rel_prefix: rel_prefix, asset_rel_prefix: asset_rel_prefix,
+ current: current, klass: klass, breadcrumb: breadcrumb,
+ }
+ render_page_template template_file, out_file, **inputs do |io|
here = binding
# suppress 1.9.3 warning
here.local_variable_set(:asset_rel_prefix, asset_rel_prefix)
@@ -457,7 +462,11 @@ def generate_page(file)
@title = "#{file.page_name} - #{@options.title}"
debug_msg " rendering #{out_file}"
- render_template template_file, out_file do |io|
+ inputs = {
+ rel_prefix: rel_prefix, asset_rel_prefix: asset_rel_prefix,
+ current: current, file: file,
+ }
+ render_page_template template_file, out_file, **inputs do |io|
here = binding
# suppress 1.9.3 warning
here.local_variable_set(:current, current)
@@ -483,7 +492,8 @@ def generate_servlet_not_found(message)
@title = 'Not Found'
- render_template template_file do |io|
+ inputs = { rel_prefix: rel_prefix, asset_rel_prefix: asset_rel_prefix, message: message }
+ render_page_template template_file, **inputs do |io|
here = binding
# suppress 1.9.3 warning
here.local_variable_set(:asset_rel_prefix, asset_rel_prefix)
@@ -513,7 +523,8 @@ def generate_servlet_root(installed)
@title = 'Local RDoc Documentation'
- render_template template_file do |io| binding end
+ inputs = { rel_prefix: rel_prefix, asset_rel_prefix: asset_rel_prefix, installed: installed }
+ render_page_template template_file, **inputs do |io| binding end
rescue => e
error = Error.new \
"error generating servlet_root: #{e.message} (#{e.class})"
@@ -642,12 +653,11 @@ def render(file_name)
# An io will be yielded which must be captured by binding in the caller.
def render_template(template_file, out_file = nil) # :yield: io
- io_output = out_file && !@dry_run && @file_output
- erb_klass = io_output ? ERBIO : ERB
+ erb_klass = erb_class_for out_file
template = template_for template_file, true, erb_klass
- if io_output
+ if erb_klass == ERBIO
debug_msg "Outputting to %s" % [out_file.expand_path]
out_file.dirname.mkpath
@@ -809,6 +819,18 @@ def group_classes_by_namespace_for_sidebar(classes)
private
+ #: (Pathname?) -> Class[ERB]
+ def erb_class_for(out_file)
+ out_file && !@dry_run && @file_output ? ERBIO : ERB
+ end
+
+ # Themes can render with explicit inputs without changing the legacy
+ # render_template API or restricting the binding supplied by its caller.
+ #: (Pathname, ?Pathname?, **untyped) -> untyped
+ def render_page_template(template_file, out_file = nil, **inputs, &block)
+ render_template(template_file, out_file, &block)
+ end
+
def template_encoding
nil
end
diff --git a/lib/rdoc/generator/template/aliki/_footer.rhtml b/lib/rdoc/generator/template/aliki/_footer.rhtml
index ae2f831443..d64c3797d3 100644
--- a/lib/rdoc/generator/template/aliki/_footer.rhtml
+++ b/lib/rdoc/generator/template/aliki/_footer.rhtml
@@ -16,7 +16,7 @@
diff --git a/lib/rdoc/generator/template/aliki/_head.rhtml b/lib/rdoc/generator/template/aliki/_head.rhtml
index d1ca4b7ad0..6957bf749c 100644
--- a/lib/rdoc/generator/template/aliki/_head.rhtml
+++ b/lib/rdoc/generator/template/aliki/_head.rhtml
@@ -3,7 +3,7 @@
<%= h @title %>
-<%- if defined?(klass) %>
+<%- if defined?(klass) && klass %>
"
@@ -19,7 +19,7 @@
content="<%= h "#{klass.type} #{klass.full_name}: #{excerpt(klass.comment)}" %>"
>
<%- end %>
-<%- elsif defined?(file) %>
+<%- elsif defined?(file) && file %>
<%- if canonical_url = @options.canonical_root %>
- <% canonical_url = current.canonical_url if defined?(current) %>
+ <% canonical_url = current.canonical_url if defined?(current) && current %>
<%- end %>
@@ -47,7 +47,7 @@
-<%- if defined?(klass) %>
+<%- if defined?(klass) && klass %>
<%- if klass.comment.empty? %>
<%- end %>
-<%- elsif defined?(file) %>
+<%- elsif defined?(file) && file %>
<%- if file.comment.empty? %>
<%- if canonical_url = @options.canonical_root %>
- <% canonical_url = current.canonical_url if defined?(current) %>
+ <% canonical_url = current.canonical_url if defined?(current) && current %>
<%- end %>
@@ -81,7 +81,7 @@
-<%- if defined?(klass) %>
+<%- if defined?(klass) && klass %>
<%- if klass.comment.empty? %>
<%- end %>
-<%- elsif defined?(file) %>
+<%- elsif defined?(file) && file %>
<%- if file.comment.empty? %>
<%- @options.template_stylesheets.each do |stylesheet| %>
<%- end %>
diff --git a/lib/rdoc/generator/template/darkfish/_footer.rhtml b/lib/rdoc/generator/template/darkfish/_footer.rhtml
index 3ccaa2d836..db483866ba 100644
--- a/lib/rdoc/generator/template/darkfish/_footer.rhtml
+++ b/lib/rdoc/generator/template/darkfish/_footer.rhtml
@@ -1,5 +1,5 @@
diff --git a/test/rdoc/generator/template_test.rb b/test/rdoc/generator/template_test.rb
new file mode 100644
index 0000000000..3fdf65a959
--- /dev/null
+++ b/test/rdoc/generator/template_test.rb
@@ -0,0 +1,386 @@
+# frozen_string_literal: true
+require_relative '../helper'
+
+class RDocGeneratorTemplateTest < RDoc::TestCase
+
+ def setup
+ super
+
+ @options.op_dir = @test_home
+ @top_level = @store.add_file 'example.rb'
+ @top_level.parser = RDoc::Parser::Ruby
+ @alpha = @top_level.add_class RDoc::NormalClass, 'Alpha'
+ @beta = @top_level.add_class RDoc::NormalClass, 'Beta'
+ @alpha.add_comment 'First page documentation: café.', @top_level
+ @beta.add_comment 'Second page documentation.', @top_level
+ @store.complete :private
+ end
+
+ def test_builtin_pages_and_partials_are_evaluated_only_once
+ generator = generator_for 'aliki'
+
+ first_evaluations = count_evaluations { generator.generate_class @alpha }
+ second_evaluations = count_evaluations { generator.generate_class @beta }
+
+ assert_operator first_evaluations, :>, 0
+ assert_equal 0, second_evaluations
+ end
+
+ def test_darkfish_keeps_binding_based_rendering
+ generator = generator_for 'darkfish'
+ generator.generate_class @alpha
+
+ assert_operator count_evaluations { generator.generate_class @beta }, :>, 0
+ assert_kind_of Binding, generator.instance_variable_get(:@context)
+ generator.instance_variable_get(:@template_cache).each_value do |template|
+ assert_not_kind_of RDoc::Generator::CompiledTemplate, template
+ end
+ end
+
+ def test_cached_renderers_are_methods_bound_to_the_generator
+ generator = generator_for 'aliki'
+ generator.generate_class @alpha
+
+ templates = generator.instance_variable_get(:@template_cache).values
+ renderers = templates.filter_map { |template| template.instance_variable_get(:@compiled_renderer) }
+
+ assert_not_empty renderers
+ renderers.each do |renderer|
+ assert_kind_of Method, renderer
+ assert_same generator, renderer.receiver
+ end
+ end
+
+ def test_compiled_renderers_only_accept_declared_inputs
+ generator = generator_for 'aliki'
+ generator.generate_class @alpha
+
+ page = generator.template_for generator.template_dir + 'class.rhtml'
+ renderer = page.instance_variable_get :@compiled_renderer
+ assert_kind_of Method, renderer
+ assert_equal %i[io rel_prefix asset_rel_prefix current klass file breadcrumb],
+ renderer.parameters.map(&:last)
+
+ partial = generator.template_for generator.template_dir + '_sidebar_sections.rhtml', false, RDoc::ERBPartial
+ renderer = partial.instance_variable_get :@compiled_renderer
+ assert_kind_of Method, renderer
+ assert_equal [:klass], renderer.parameters.map(&:last)
+ end
+
+ def test_aliki_contracts_match_its_owned_templates
+ generator = generator_for 'aliki'
+ templates = generator.template_dir.children.select { |file| file.extname == '.rhtml' }.map { |file| file.basename.to_s }
+
+ assert_equal templates.sort, RDoc::Generator::Aliki::TEMPLATE_INPUTS.keys.sort
+ end
+
+ def test_builtin_rendering_does_not_inspect_bindings
+ generator = generator_for 'aliki'
+ binding_reads = []
+ trace = TracePoint.new(:call, :c_call) do |event|
+ if event.self.is_a?(Binding) && %i[local_variables local_variable_get].include?(event.method_id)
+ binding_reads << event.method_id
+ end
+ end
+
+ trace.enable do
+ generator.generate_class @alpha
+ generator.generate_class @beta
+ end
+
+ assert_empty binding_reads
+ end
+
+ def test_clearing_the_template_cache_discards_its_compiled_renderers
+ generator = generator_for 'aliki'
+ generator.generate_class @alpha
+ file = generator.template_dir + 'class.rhtml'
+ first_template = generator.template_for file
+
+ generator.instance_variable_get(:@template_cache).clear
+ evaluations = count_evaluations { generator.generate_class @beta }
+
+ assert_operator evaluations, :>, 0
+ assert_not_same first_template, generator.template_for(file)
+ assert_equal 0, count_evaluations { generator.generate_class @alpha }
+ end
+
+ def test_template_result_still_accepts_an_implicit_binding
+ generator = generator_for 'aliki'
+ template = generator.template_for generator.template_dir + '_sidebar_toggle.rhtml', false, RDoc::ERBPartial
+
+ assert_include template.result, 'sidebar-navigation-toggle'
+ end
+
+ def test_template_result_preserves_a_foreign_binding
+ generator = generator_for 'aliki'
+ template = generator.template_for generator.template_dir + '_sidebar_toggle.rhtml', false, RDoc::ERBPartial
+
+ assert_include template.result(binding), 'sidebar-navigation-toggle'
+ assert_nil template.instance_variable_get(:@compiled_renderer)
+ end
+
+ def test_cached_templates_use_current_class_and_path
+ %w[aliki darkfish].each do |theme|
+ generator = generator_for theme
+ alpha_html = generator.generate_class @alpha
+ beta_html = generator.generate_class @beta
+
+ assert_include alpha_html, 'class Alpha -'
+ assert_include alpha_html, 'First page documentation: café.'
+ assert_include beta_html, 'class Beta -'
+ assert_include beta_html, 'Second page documentation.'
+ assert_not_include beta_html, 'First page documentation: café.'
+ assert_include beta_html, RDoc::VERSION
+ end
+ end
+
+ def test_cached_templates_write_to_current_output_stream
+ generator = generator_for 'aliki'
+ generator.file_output = true
+ generator.generate_class @alpha
+ generator.generate_class @beta
+
+ alpha_html = File.read File.join(@test_home, 'Alpha.html')
+ beta_html = File.read File.join(@test_home, 'Beta.html')
+ assert_include alpha_html, 'First page documentation: café.'
+ assert_include beta_html, 'Second page documentation.'
+ assert_not_include beta_html, 'First page documentation: café.'
+ end
+
+ def test_cached_templates_follow_output_mode_changes
+ generator = generator_for 'aliki'
+ generator.generate_class @alpha
+
+ generator.file_output = true
+ generator.generate_class @beta
+ beta_html = File.read File.join(@test_home, 'Beta.html')
+ assert_include beta_html, 'Second page documentation.'
+
+ generator.file_output = false
+ assert_include generator.generate_class(@alpha), 'First page documentation: café.'
+ assert_equal 0, count_evaluations { generator.generate_class @beta }
+ end
+
+ def test_cached_streaming_templates_can_switch_to_dry_run
+ generator = generator_for 'aliki'
+ generator.file_output = true
+ generator.generate_class @alpha
+
+ generator.dry_run = true
+ html = generator.generate_class @beta
+
+ assert_include html, 'Second page documentation.'
+ refute_file File.join(@test_home, 'Beta.html')
+ assert_equal 0, count_evaluations { generator.generate_class @alpha }
+ end
+
+ def test_index_reuses_its_renderer_when_the_main_page_changes
+ generator = generator_for 'aliki'
+ html = generator.generate_index
+ assert_include html, 'This is the API documentation'
+
+ template = generator.template_for generator.template_dir + 'index.rhtml'
+ renderer = template.instance_variable_get :@compiled_renderer
+ assert_kind_of Method, renderer
+
+ readme = @store.add_file 'README.rdoc', parser: RDoc::Parser::Simple
+ readme.comment = "= Main page heading\n\n== Second heading"
+ @options.main_page = readme.full_name
+ generator.refresh_store_data
+ html = generator.generate_index
+ assert_include html, 'Main page heading'
+ assert_same renderer, template.instance_variable_get(:@compiled_renderer)
+
+ @options.main_page = nil
+ html = generator.generate_index
+ assert_include html, 'This is the API documentation'
+ assert_not_include html, '<% page_value = "changed" %><%= render "_custom.rhtml" %>