serialbench 0.1.0 → 0.1.2

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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/benchmark.yml +181 -30
  3. data/.github/workflows/ci.yml +3 -3
  4. data/.github/workflows/docker.yml +272 -0
  5. data/.github/workflows/rake.yml +15 -0
  6. data/.github/workflows/release.yml +25 -0
  7. data/Gemfile +6 -30
  8. data/README.adoc +381 -415
  9. data/Rakefile +0 -55
  10. data/config/benchmarks/full.yml +29 -0
  11. data/config/benchmarks/short.yml +26 -0
  12. data/config/environments/asdf-ruby-3.2.yml +8 -0
  13. data/config/environments/asdf-ruby-3.3.yml +8 -0
  14. data/config/environments/docker-ruby-3.0.yml +9 -0
  15. data/config/environments/docker-ruby-3.1.yml +9 -0
  16. data/config/environments/docker-ruby-3.2.yml +9 -0
  17. data/config/environments/docker-ruby-3.3.yml +9 -0
  18. data/config/environments/docker-ruby-3.4.yml +9 -0
  19. data/docker/Dockerfile.alpine +33 -0
  20. data/docker/Dockerfile.ubuntu +32 -0
  21. data/docker/README.md +214 -0
  22. data/exe/serialbench +1 -1
  23. data/lib/serialbench/benchmark_runner.rb +270 -350
  24. data/lib/serialbench/cli/base_cli.rb +51 -0
  25. data/lib/serialbench/cli/benchmark_cli.rb +380 -0
  26. data/lib/serialbench/cli/environment_cli.rb +181 -0
  27. data/lib/serialbench/cli/resultset_cli.rb +215 -0
  28. data/lib/serialbench/cli/ruby_build_cli.rb +238 -0
  29. data/lib/serialbench/cli.rb +59 -410
  30. data/lib/serialbench/config_manager.rb +140 -0
  31. data/lib/serialbench/models/benchmark_config.rb +63 -0
  32. data/lib/serialbench/models/benchmark_result.rb +45 -0
  33. data/lib/serialbench/models/environment_config.rb +71 -0
  34. data/lib/serialbench/models/platform.rb +59 -0
  35. data/lib/serialbench/models/result.rb +53 -0
  36. data/lib/serialbench/models/result_set.rb +71 -0
  37. data/lib/serialbench/models/result_store.rb +108 -0
  38. data/lib/serialbench/models.rb +54 -0
  39. data/lib/serialbench/ruby_build_manager.rb +153 -0
  40. data/lib/serialbench/runners/asdf_runner.rb +296 -0
  41. data/lib/serialbench/runners/base.rb +32 -0
  42. data/lib/serialbench/runners/docker_runner.rb +142 -0
  43. data/lib/serialbench/serializers/base_serializer.rb +8 -16
  44. data/lib/serialbench/serializers/json/base_json_serializer.rb +4 -4
  45. data/lib/serialbench/serializers/json/json_serializer.rb +0 -2
  46. data/lib/serialbench/serializers/json/oj_serializer.rb +0 -2
  47. data/lib/serialbench/serializers/json/rapidjson_serializer.rb +50 -0
  48. data/lib/serialbench/serializers/json/yajl_serializer.rb +6 -4
  49. data/lib/serialbench/serializers/toml/base_toml_serializer.rb +5 -3
  50. data/lib/serialbench/serializers/toml/toml_rb_serializer.rb +0 -2
  51. data/lib/serialbench/serializers/toml/tomlib_serializer.rb +0 -2
  52. data/lib/serialbench/serializers/toml/tomlrb_serializer.rb +56 -0
  53. data/lib/serialbench/serializers/xml/base_xml_serializer.rb +4 -9
  54. data/lib/serialbench/serializers/xml/libxml_serializer.rb +0 -2
  55. data/lib/serialbench/serializers/xml/nokogiri_serializer.rb +21 -5
  56. data/lib/serialbench/serializers/xml/oga_serializer.rb +0 -2
  57. data/lib/serialbench/serializers/xml/ox_serializer.rb +0 -2
  58. data/lib/serialbench/serializers/xml/rexml_serializer.rb +32 -4
  59. data/lib/serialbench/serializers/yaml/base_yaml_serializer.rb +59 -0
  60. data/lib/serialbench/serializers/yaml/psych_serializer.rb +54 -0
  61. data/lib/serialbench/serializers/yaml/syck_serializer.rb +102 -0
  62. data/lib/serialbench/serializers.rb +34 -6
  63. data/lib/serialbench/site_generator.rb +105 -0
  64. data/lib/serialbench/templates/assets/css/benchmark_report.css +535 -0
  65. data/lib/serialbench/templates/assets/css/format_based.css +526 -0
  66. data/lib/serialbench/templates/assets/css/themes.css +588 -0
  67. data/lib/serialbench/templates/assets/js/chart_helpers.js +381 -0
  68. data/lib/serialbench/templates/assets/js/dashboard.js +796 -0
  69. data/lib/serialbench/templates/assets/js/navigation.js +142 -0
  70. data/lib/serialbench/templates/base.liquid +49 -0
  71. data/lib/serialbench/templates/format_based.liquid +279 -0
  72. data/lib/serialbench/templates/partials/chart_section.liquid +4 -0
  73. data/lib/serialbench/version.rb +1 -1
  74. data/lib/serialbench.rb +2 -31
  75. data/serialbench.gemspec +28 -17
  76. metadata +192 -55
  77. data/lib/serialbench/chart_generator.rb +0 -821
  78. data/lib/serialbench/result_formatter.rb +0 -182
  79. data/lib/serialbench/result_merger.rb +0 -1201
  80. data/lib/serialbench/serializers/xml/base_parser.rb +0 -69
  81. data/lib/serialbench/serializers/xml/libxml_parser.rb +0 -98
  82. data/lib/serialbench/serializers/xml/nokogiri_parser.rb +0 -111
  83. data/lib/serialbench/serializers/xml/oga_parser.rb +0 -85
  84. data/lib/serialbench/serializers/xml/ox_parser.rb +0 -64
  85. data/lib/serialbench/serializers/xml/rexml_parser.rb +0 -129
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_toml_serializer'
4
+
5
+ module Serialbench
6
+ module Serializers
7
+ module Toml
8
+ class TomlrbSerializer < BaseTomlSerializer
9
+ def available?
10
+ require_library('tomlrb')
11
+ end
12
+
13
+ def name
14
+ 'tomlrb'
15
+ end
16
+
17
+ def version
18
+ require 'tomlrb'
19
+ # tomlrb doesn't expose a VERSION constant, so we'll use gem version
20
+ Gem.loaded_specs['tomlrb']&.version&.to_s || 'unknown'
21
+ rescue LoadError, NameError
22
+ 'unknown'
23
+ end
24
+
25
+ def parse(toml_string)
26
+ require 'tomlrb'
27
+ Tomlrb.parse(toml_string)
28
+ end
29
+
30
+ def generate(object, options = {})
31
+ raise NotImplementedError, 'tomlrb gem does not support TOML generation/dumping'
32
+ end
33
+
34
+ def supports_generation?
35
+ false
36
+ end
37
+
38
+ def supports_comments?
39
+ false
40
+ end
41
+
42
+ def supports_arrays_of_tables?
43
+ true
44
+ end
45
+
46
+ def supports_inline_tables?
47
+ true
48
+ end
49
+
50
+ def supports_multiline_strings?
51
+ true
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -6,7 +6,7 @@ module Serialbench
6
6
  module Serializers
7
7
  module Xml
8
8
  class BaseXmlSerializer < BaseSerializer
9
- def format
9
+ def self.format
10
10
  :xml
11
11
  end
12
12
 
@@ -33,7 +33,9 @@ module Serialbench
33
33
  }
34
34
  end
35
35
 
36
- protected
36
+ def supports_generation?
37
+ true
38
+ end
37
39
 
38
40
  def supports_xpath?
39
41
  false
@@ -47,13 +49,6 @@ module Serialbench
47
49
  false
48
50
  end
49
51
 
50
- # Subclasses should override this to specify their library name
51
- def library_require_name
52
- raise NotImplementedError, 'Subclasses must implement #library_require_name'
53
- end
54
-
55
- public
56
-
57
52
  # Check if the XML library is available
58
53
  def available?
59
54
  return @available if defined?(@available)
@@ -49,8 +49,6 @@ module Serialbench
49
49
  LibXML::XML::VERSION
50
50
  end
51
51
 
52
- protected
53
-
54
52
  def library_require_name
55
53
  'libxml'
56
54
  end
@@ -49,8 +49,6 @@ module Serialbench
49
49
  Nokogiri::VERSION
50
50
  end
51
51
 
52
- protected
53
-
54
52
  def library_require_name
55
53
  'nokogiri'
56
54
  end
@@ -60,9 +58,9 @@ module Serialbench
60
58
  def build_xml_from_data(xml, data, root_name = 'root')
61
59
  case data
62
60
  when Hash
63
- xml.send(root_name) do
61
+ xml.send(sanitize_element_name(root_name)) do
64
62
  data.each do |key, value|
65
- build_xml_from_data(xml, value, key)
63
+ build_xml_from_data(xml, value, sanitize_element_name(key.to_s))
66
64
  end
67
65
  end
68
66
  when Array
@@ -70,10 +68,28 @@ module Serialbench
70
68
  build_xml_from_data(xml, item, "item_#{index}")
71
69
  end
72
70
  else
73
- xml.send(root_name, data.to_s)
71
+ # Use a safe method that always works
72
+ element_name = sanitize_element_name(root_name)
73
+ if xml.respond_to?(element_name)
74
+ xml.send(element_name, data.to_s)
75
+ else
76
+ # Fallback: create element manually
77
+ xml.tag!(element_name, data.to_s)
78
+ end
74
79
  end
75
80
  end
76
81
 
82
+ def sanitize_element_name(name)
83
+ # Ensure element name is valid XML and safe to use as method name
84
+ sanitized = name.to_s.gsub(/[^a-zA-Z0-9_]/, '_')
85
+ # Ensure it starts with a letter
86
+ sanitized = "element_#{sanitized}" if sanitized.empty? || sanitized =~ /\A\d/
87
+ # Avoid conflicts with common Nokogiri methods
88
+ reserved_words = %w[text comment cdata parent children attributes namespace]
89
+ sanitized = "data_#{sanitized}" if reserved_words.include?(sanitized)
90
+ sanitized
91
+ end
92
+
77
93
  # SAX handler for streaming
78
94
  class StreamingHandler
79
95
  attr_reader :elements_processed
@@ -49,8 +49,6 @@ module Serialbench
49
49
  Oga::VERSION
50
50
  end
51
51
 
52
- protected
53
-
54
52
  def library_require_name
55
53
  'oga'
56
54
  end
@@ -40,8 +40,6 @@ module Serialbench
40
40
  Ox::VERSION
41
41
  end
42
42
 
43
- protected
44
-
45
43
  def library_require_name
46
44
  'ox'
47
45
  end
@@ -26,8 +26,19 @@ module Serialbench
26
26
  REXML::Document.new(xml_string)
27
27
  end
28
28
 
29
- def generate(document, options = {})
29
+ def generate(data, options = {})
30
30
  require 'rexml/document'
31
+
32
+ # If data is already a REXML::Document, use it directly
33
+ if data.is_a?(REXML::Document)
34
+ document = data
35
+ else
36
+ # Convert Hash/other data to XML document
37
+ document = REXML::Document.new
38
+ root = document.add_element('root')
39
+ hash_to_xml(data, root)
40
+ end
41
+
31
42
  indent = options.fetch(:indent, 0)
32
43
  output = String.new
33
44
  if indent > 0
@@ -63,11 +74,9 @@ module Serialbench
63
74
  end
64
75
 
65
76
  def supports_streaming?
66
- true
77
+ false
67
78
  end
68
79
 
69
- protected
70
-
71
80
  def supports_xpath?
72
81
  true
73
82
  end
@@ -79,6 +88,25 @@ module Serialbench
79
88
  def supports_validation?
80
89
  false
81
90
  end
91
+
92
+ private
93
+
94
+ def hash_to_xml(data, parent)
95
+ case data
96
+ when Hash
97
+ data.each do |key, value|
98
+ element = parent.add_element(key.to_s)
99
+ hash_to_xml(value, element)
100
+ end
101
+ when Array
102
+ data.each_with_index do |item, index|
103
+ element = parent.add_element("item_#{index}")
104
+ hash_to_xml(item, element)
105
+ end
106
+ else
107
+ parent.text = data.to_s
108
+ end
109
+ end
82
110
  end
83
111
 
84
112
  # SAX handler for REXML streaming
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../base_serializer'
4
+
5
+ module Serialbench
6
+ module Serializers
7
+ module Yaml
8
+ # Base class for YAML serializers
9
+ class BaseYamlSerializer < BaseSerializer
10
+ def self.format
11
+ :yaml
12
+ end
13
+
14
+ def supports_streaming?
15
+ false # Most YAML parsers don't support streaming
16
+ end
17
+
18
+ def features
19
+ features = %w[parsing generation]
20
+ features << 'streaming' if supports_streaming?
21
+ features
22
+ end
23
+
24
+ # Default YAML generation options
25
+ def default_generation_options
26
+ {}
27
+ end
28
+
29
+ # Parse YAML string into Ruby object
30
+ def parse(yaml_string)
31
+ raise NotImplementedError, 'Subclasses must implement parse method'
32
+ end
33
+
34
+ # Generate YAML string from Ruby object
35
+ def generate(object, options = {})
36
+ raise NotImplementedError, 'Subclasses must implement generate method'
37
+ end
38
+
39
+ # Stream parse YAML (if supported)
40
+ def stream_parse(yaml_string, &block)
41
+ raise NotImplementedError, 'Streaming not supported by this YAML serializer'
42
+ end
43
+
44
+ def supports_generation?
45
+ true
46
+ end
47
+
48
+ private
49
+
50
+ def require_library(library_name)
51
+ require library_name
52
+ true
53
+ rescue LoadError
54
+ false
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_yaml_serializer'
4
+
5
+ module Serialbench
6
+ module Serializers
7
+ module Yaml
8
+ # Psych YAML serializer - Ruby's built-in YAML parser
9
+ class PsychSerializer < BaseYamlSerializer
10
+ def available?
11
+ require_library('psych')
12
+ end
13
+
14
+ def name
15
+ 'psych'
16
+ end
17
+
18
+ def version
19
+ require 'psych'
20
+ Psych::VERSION
21
+ end
22
+
23
+ def parse(yaml_string)
24
+ require 'psych'
25
+ # Handle Ruby version compatibility for permitted_classes parameter
26
+ if RUBY_VERSION >= '3.1.0'
27
+ Psych.load(yaml_string, permitted_classes: [Date, Time, Symbol])
28
+ else
29
+ # For older Ruby versions, use the old API
30
+ Psych.load(yaml_string)
31
+ end
32
+ end
33
+
34
+ def generate(object, options = {})
35
+ require 'psych'
36
+ Psych.dump(object)
37
+ end
38
+
39
+ def features
40
+ %w[parsing generation built-in]
41
+ end
42
+
43
+ private
44
+
45
+ def require_library(library_name)
46
+ require library_name
47
+ true
48
+ rescue LoadError
49
+ false
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_yaml_serializer'
4
+
5
+ module Serialbench
6
+ module Serializers
7
+ module Yaml
8
+ class SyckSerializer < BaseYamlSerializer
9
+ def name
10
+ 'syck'
11
+ end
12
+
13
+ def version
14
+ require 'syck'
15
+ # Try to get version from gem specification
16
+ spec = Gem.loaded_specs['syck']
17
+ return spec.version.to_s if spec
18
+
19
+ # Fallback to a default version if no gem spec found
20
+ '1.0.0'
21
+ rescue StandardError
22
+ 'N/A'
23
+ end
24
+
25
+ def available?
26
+ require 'syck'
27
+ # Verify that Syck module and methods are actually available
28
+ return false unless defined?(Syck) && Syck.respond_to?(:dump) && Syck.respond_to?(:load)
29
+
30
+ # Check for known problematic configurations
31
+ if problematic_environment?
32
+ warn_about_segfault_issue
33
+ return false
34
+ end
35
+
36
+ true
37
+ rescue LoadError
38
+ false
39
+ end
40
+
41
+ def parse(yaml_string)
42
+ return nil unless available?
43
+
44
+ begin
45
+ require 'syck'
46
+ Syck.load(yaml_string)
47
+ rescue StandardError => e
48
+ if e.message.include?('Segmentation fault') || e.is_a?(SystemExit)
49
+ handle_segfault_error
50
+ return nil
51
+ end
52
+ raise e
53
+ end
54
+ end
55
+
56
+ def generate(object, options = {})
57
+ return nil unless available?
58
+
59
+ begin
60
+ require 'syck'
61
+ Syck.dump(object)
62
+ rescue StandardError => e
63
+ if e.message.include?('Segmentation fault') || e.is_a?(SystemExit)
64
+ handle_segfault_error
65
+ return nil
66
+ end
67
+ raise e
68
+ end
69
+ end
70
+
71
+ def supports_streaming?
72
+ false
73
+ end
74
+
75
+ def features
76
+ %w[parsing generation legacy]
77
+ end
78
+
79
+ def description
80
+ 'Legacy YAML parser (Ruby < 1.9.3)'
81
+ end
82
+
83
+ def problematic_environment?
84
+ # Ruby 3.1+ has issues with Syck
85
+ (Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.1.0')) &&
86
+ (Gem::Version.new(version) >= Gem::Version.new('1.4.0'))
87
+ end
88
+
89
+ def warn_about_segfault_issue
90
+ puts "⚠️ WARNING: The Syck YAML serializer is known to cause segmentation faults with Ruby #{RUBY_VERSION}"
91
+ puts ' This is a known issue with the syck gem on newer Ruby versions systems.'
92
+ puts ' Skipping Syck benchmarks to prevent crashes. See README for more details.'
93
+ end
94
+
95
+ def handle_segfault_error
96
+ puts '❌ ERROR: Syck YAML serializer encountered a segmentation fault'
97
+ puts " This is a known issue on Ruby #{RUBY_VERSION}. Skipping remaining Syck tests."
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'serializers/base_serializer'
4
+ require_relative 'models/benchmark_result'
4
5
 
5
6
  # XML Serializers
6
7
  require_relative 'serializers/xml/base_xml_serializer'
@@ -15,16 +16,23 @@ require_relative 'serializers/json/base_json_serializer'
15
16
  require_relative 'serializers/json/json_serializer'
16
17
  require_relative 'serializers/json/oj_serializer'
17
18
  require_relative 'serializers/json/yajl_serializer'
19
+ require_relative 'serializers/json/rapidjson_serializer'
20
+
21
+ # YAML Serializers
22
+ require_relative 'serializers/yaml/base_yaml_serializer'
23
+ require_relative 'serializers/yaml/psych_serializer'
24
+ require_relative 'serializers/yaml/syck_serializer'
18
25
 
19
26
  # TOML Serializers
20
27
  require_relative 'serializers/toml/base_toml_serializer'
21
28
  require_relative 'serializers/toml/toml_rb_serializer'
22
29
  require_relative 'serializers/toml/tomlib_serializer'
30
+ require_relative 'serializers/toml/tomlrb_serializer'
23
31
 
24
32
  module Serialbench
25
33
  module Serializers
26
34
  # Registry of all available serializers
27
- SERIALIZERS = {
35
+ REGISTER = {
28
36
  xml: [
29
37
  Xml::RexmlSerializer,
30
38
  Xml::OxSerializer,
@@ -35,28 +43,48 @@ module Serialbench
35
43
  json: [
36
44
  Json::JsonSerializer,
37
45
  Json::OjSerializer,
46
+ Json::RapidjsonSerializer,
38
47
  Json::YajlSerializer
39
48
  ],
49
+ yaml: [
50
+ Yaml::PsychSerializer,
51
+ Yaml::SyckSerializer
52
+ ],
40
53
  toml: [
41
54
  Toml::TomlRbSerializer,
42
- Toml::TomlibSerializer
55
+ Toml::TomlibSerializer,
56
+ Toml::TomlrbSerializer
43
57
  ]
44
58
  }.freeze
45
59
 
46
60
  def self.all
47
- SERIALIZERS.values.flatten
61
+ REGISTER.values.flatten.map(&:instance)
48
62
  end
49
63
 
50
64
  def self.for_format(format)
51
- SERIALIZERS[format.to_sym] || []
65
+ REGISTER[format.to_sym]&.map(&:instance) || []
66
+ end
67
+
68
+ def self.information
69
+ return @information if @information
70
+
71
+ @information = available.map do |serializer_singleton|
72
+ Models::SerializerInformation.new(
73
+ name: serializer_singleton.name,
74
+ format: serializer_singleton.format.to_s,
75
+ version: serializer_singleton.version
76
+ )
77
+ end
78
+
79
+ @information
52
80
  end
53
81
 
54
82
  def self.available_for_format(format)
55
- for_format(format).select { |serializer_class| serializer_class.new.available? }
83
+ for_format(format).select { |serializer_singleton| serializer_singleton.available? }
56
84
  end
57
85
 
58
86
  def self.available
59
- all.select { |serializer_class| serializer_class.new.available? }
87
+ all.select { |serializer_singleton| serializer_singleton.available? }
60
88
  end
61
89
  end
62
90
  end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'erb'
5
+ require 'json'
6
+ require 'liquid'
7
+ require 'yaml'
8
+
9
+ module Serialbench
10
+ # Unified site generator for creating static HTML sites from benchmark results
11
+ class SiteGenerator
12
+ TEMPLATE_DIR = File.join(__dir__, 'templates')
13
+
14
+ attr_reader :output_path, :result, :resultset
15
+
16
+ def initialize(output_path:, result: nil, resultset: nil)
17
+ @output_path = File.expand_path(output_path)
18
+ @result = result if result
19
+ @resultset = resultset if resultset
20
+ setup_liquid_environment
21
+ end
22
+
23
+ def self.generate_for_result(result, output_path)
24
+ generator = new(output_path: output_path, result: result)
25
+ generator.generate_site
26
+ end
27
+
28
+ def self.generate_for_resultset(resultset, output_path)
29
+ generator = new(output_path: output_path, resultset: resultset)
30
+ generator.generate_site
31
+ end
32
+
33
+ def generate_site
34
+ target_name = @result ? @result.environment_config.name : @resultset.name
35
+ data = @result ? @result.to_json : @resultset.to_json
36
+
37
+ puts "🏗️ Generating HTML site for #{@result ? 'run' : 'resultset'}: #{target_name}"
38
+ puts "Output: #{@output_path}"
39
+
40
+ prepare_output_directory
41
+ render_site(
42
+ {
43
+ 'data' => data,
44
+ 'kind' => @result ? 'run' : 'resultset'
45
+ },
46
+ 'format_based.liquid'
47
+ )
48
+
49
+ puts "✅ Site generated successfully at: #{@output_path}"
50
+ @output_path
51
+ end
52
+
53
+ private
54
+
55
+ def setup_liquid_environment
56
+ @liquid_env = Liquid::Environment.new
57
+ @liquid_env.file_system = Liquid::LocalFileSystem.new(TEMPLATE_DIR)
58
+ end
59
+
60
+ def prepare_output_directory
61
+ if Dir.exist?(@output_path)
62
+ puts 'Cleaning existing output directory...'
63
+ FileUtils.rm_rf(Dir.glob(File.join(@output_path, '*')))
64
+ else
65
+ FileUtils.mkdir_p(@output_path)
66
+ end
67
+ end
68
+
69
+ def render_site(template_data, template_name)
70
+ # Load and render content template
71
+ content_template = load_template(template_name)
72
+ content = content_template.render(template_data)
73
+
74
+ # Load and render base template
75
+ base_template = load_template('base.liquid')
76
+ html = base_template.render(template_data.merge('content' => content))
77
+
78
+ # Write HTML file
79
+ write_file(html, 'index.html')
80
+
81
+ # Copy assets
82
+ copy_assets
83
+ end
84
+
85
+ def load_template(template_name)
86
+ template_path = File.join(TEMPLATE_DIR, template_name)
87
+ template_content = File.read(template_path)
88
+ Liquid::Template.parse(template_content)
89
+ end
90
+
91
+ def write_file(content, filename)
92
+ FileUtils.mkdir_p(@output_path)
93
+ File.write(File.join(@output_path, filename), content)
94
+ end
95
+
96
+ def copy_assets
97
+ assets_source = File.join(TEMPLATE_DIR, 'assets')
98
+ assets_dest = File.join(@output_path, 'assets')
99
+
100
+ return unless Dir.exist?(assets_source)
101
+
102
+ FileUtils.cp_r(assets_source, assets_dest)
103
+ end
104
+ end
105
+ end