Class: StaticSiteBuilder::Builder

Inherits:
Object
  • Object
show all
Defined in:
lib/static_site_builder/builder.rb

Constant Summary collapse

DIST_DIR =

Output directory name

"dist"
JS_ENTRY_POINT =

Default JavaScript entry point

"application"

Instance Method Summary collapse

Constructor Details

#initialize(root: Dir.pwd, annotate_template_file_names: nil) ⇒ Builder

Returns a new instance of Builder.



51
52
53
54
55
56
57
58
59
60
# File 'lib/static_site_builder/builder.rb', line 51

def initialize(root: Dir.pwd, annotate_template_file_names: nil)
  @root = Pathname.new(root)

  # Auto-enable annotations in development (when LIVE_RELOAD is enabled)
  @annotate_template_file_names = if annotate_template_file_names.nil?
    ENV["LIVE_RELOAD"] == "true" || ENV["RAILS_ENV"] == "development"
  else
    annotate_template_file_names
  end
end

Instance Method Details

#annotate_template(content, template_path) ⇒ Object



382
383
384
385
386
387
388
389
390
391
392
# File 'lib/static_site_builder/builder.rb', line 382

def annotate_template(content, template_path)
  # Remove any existing annotations to avoid duplicates
  content = content.gsub(/<!-- BEGIN .*? -->\n?/, "")
  content = content.gsub(/\n?<!-- END .*? -->/, "")

  begin_comment = "<!-- BEGIN #{template_path} -->"
  end_comment = "<!-- END #{template_path} -->"

  # Wrap content with template path annotations
  "#{begin_comment}\n#{content}\n#{end_comment}"
end

#build ⇒ void

This method returns an undefined value.

Builds the complete static site.

Compiles ERB templates to HTML, copies JavaScript and CSS assets, and outputs everything to the dist/ directory. In development mode, files are updated in place to prevent 404 errors during live reload. In production mode, the dist directory is cleaned first for a fresh build.



70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/static_site_builder/builder.rb', line 70

def build
  puts "Building static site..."

  dist_dir = @root.join(DIST_DIR)
  
  # Clean dist directory only for production/release builds
  # In development, update files in place to prevent 404s during live reload
  production_build = ENV["PRODUCTION"] == "true" || ENV["RELEASE"] == "true"
  if production_build
    if dist_dir.exist?
      puts "Cleaning dist directory for production build..."
      FileUtils.rm_rf(dist_dir)
    end
  end
  
  # Ensure dist directory exists
  FileUtils.mkdir_p(dist_dir)

  # Copy JavaScript and CSS assets to dist
  copy_assets(dist_dir)

  # Compile ERB templates to static HTML pages
  compile_erb_pages(dist_dir)

  # Copy static files from public/ directory to dist
  copy_static_files(dist_dir)

  # Notify WebSocket server of rebuild for live reload
  # Always update the reload file, even if it doesn't exist yet
  reload_file = @root.join(".reload")
  File.write(reload_file, Time.current.to_f.to_s)

  puts "\n✓ Build complete! Output in #{dist_dir}"
end

#compile_erb_page(erb_file, page_name, dist_dir) ⇒ Object



351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
# File 'lib/static_site_builder/builder.rb', line 351

def compile_erb_page(erb_file, page_name, dist_dir)
  puts "Compiling page: #{page_name}..."

  layout_content, layout_file = load_layout_content
  view = setup_action_view_context
  
  # Render page template first - this sets up meta tags and content_for blocks
  # Pass erb_file directly - render_page_template will use file-based rendering
  # Page templates set their own @title, @description etc. using meta-tags gem
  page_content = render_page_template(view, nil, page_name, erb_file)
  
  # Now render layout with page content available via yield
  rendered = render_layout_template(view, layout_content, layout_file, page_content)
  
  write_page_output(dist_dir, page_name, rendered)
  
  puts "  ✓ Created #{page_name}"
end

#compile_erb_pages(dist_dir) ⇒ Object



338
339
340
341
342
343
344
345
346
347
348
349
# File 'lib/static_site_builder/builder.rb', line 338

def compile_erb_pages(dist_dir)
  pages_dir = @root.join("app", "views", "pages")
  return unless pages_dir.exist?

  # Find all ERB files, including nested directories
  Dir.glob(pages_dir.join("**", "*.html.erb")).each do |erb_file|
    relative_path = Pathname.new(erb_file).relative_path_from(pages_dir)
    page_name = relative_path.to_s.gsub(/\.html\.erb$/, ".html")

    compile_erb_page(erb_file, page_name, dist_dir)
  end
end

#copy_assets(dist_dir) ⇒ Object



310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
# File 'lib/static_site_builder/builder.rb', line 310

def copy_assets(dist_dir)
  puts "Copying assets..."

  # Copy JavaScript files from app/javascript to dist/assets/javascripts
  js_dir = @root.join("app", "javascript")
  if js_dir.exist? && js_dir.directory?
    dist_js = dist_dir.join("assets", "javascripts")
    FileUtils.mkdir_p(dist_js)
    # Copy all files and subdirectories recursively
    Dir.glob(js_dir.join("*")).each do |item|
      FileUtils.cp_r(item, dist_js, preserve: true)
    end
  end

  # Handle CSS files
  # Copy CSS files from app/assets/stylesheets to dist/assets/stylesheets
  css_dir = @root.join("app", "assets", "stylesheets")
  dist_css = dist_dir.join("assets", "stylesheets")
  
  if css_dir.exist? && css_dir.directory?
    FileUtils.mkdir_p(dist_css)
    Dir.glob(css_dir.join("*")).each do |item|
      FileUtils.cp_r(item, dist_css, preserve: true)
    end
  end
end

#copy_static_files(dist_dir) ⇒ Object



371
372
373
374
375
376
377
378
379
380
# File 'lib/static_site_builder/builder.rb', line 371

def copy_static_files(dist_dir)
  public_dir = @root.join("public")
  return unless public_dir.exist? && public_dir.directory?

  puts "Copying static files from public/..."
  # Copy all files and subdirectories from public to dist
  Dir.glob(public_dir.join("*")).each do |item|
    FileUtils.cp_r(item, dist_dir, preserve: true)
  end
end

#default_layout ⇒ Object



394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
# File 'lib/static_site_builder/builder.rb', line 394

def default_layout
  live_reload_script_content = live_reload_script

  <<~HTML
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <%# Set meta tags in your page templates using: %>
      <%# <% set_meta_tags title: 'Page Title', description: 'Description' %> %>
      <%= display_meta_tags %>
      <link rel="stylesheet" href="/assets/stylesheets/application.css">
    </head>
    <body>
      <%= yield %>
      <% if content_for?(:javascript) %>
        <%= yield(:javascript) %>
      <% end %>
      #{live_reload_script_content}
    </body>
    </html>
  HTML
end

#hash_value(hash, *keys) ⇒ Object?

Accesses hash values using either symbol or string keys.

Tries each key in order until a non-nil value is found. This allows compatibility with both symbol and string keys in options hashes.

Parameters:

  • hash (Hash) —

    The hash to search

  • keys (Array<Symbol, String>) —

    Keys to try in order

Returns:

  • (Object, nil) —

    The first non-nil value found, or nil if none found



118
119
120
121
122
123
124
# File 'lib/static_site_builder/builder.rb', line 118

def hash_value(hash, *keys)
  keys.each do |key|
    value = hash[key]
    return value if value.present?
  end
  nil
end

#live_reload_script ⇒ Object

Generate live reload WebSocket script if live reload is enabled



287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
# File 'lib/static_site_builder/builder.rb', line 287

def live_reload_script
  if ENV["LIVE_RELOAD"] == "true"
    ws_port = ENV["WS_PORT"] || StaticSiteBuilder::DEFAULT_WS_PORT
    <<~HTML
      <script>
        (function() {
          function connect() {
            var ws = new WebSocket('ws://localhost:#{ws_port}');
            ws.onmessage = function(e) {
              if (e.data === 'reload') window.location.reload();
            };
            ws.onclose = function() { setTimeout(connect, 1000); };
            ws.onerror = function() {};
          }
          connect();
        })();
      </script>
    HTML
  else
    ""
  end
end

#load_helpers(view_class) ⇒ Object

Automatically load helper modules from app/helpers/ directory



166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
# File 'lib/static_site_builder/builder.rb', line 166

def load_helpers(view_class)
  helpers_dir = @root.join('app', 'helpers')
  return unless helpers_dir.exist? && helpers_dir.directory?

  Dir.glob(helpers_dir.join('**', '*_helper.rb')).each do |helper_file|
    begin
      # Get the module name from the file (e.g., app/helpers/application_helper.rb -> ApplicationHelper)
      relative_path = Pathname.new(helper_file).relative_path_from(helpers_dir)
      module_name = relative_path.to_s.gsub(/\.rb$/, '').split('_').map(&:capitalize).join
      
      # Load the file (use load instead of require for absolute paths)
      load helper_file
      
      # Include the module if it exists
      if Object.const_defined?(module_name)
        helper_module = Object.const_get(module_name)
        view_class.include(helper_module) unless view_class.included_modules.include?(helper_module)
      end
    rescue LoadError, NameError => e
      # Silently skip if helper can't be loaded (e.g., missing dependencies)
      # This allows users to have helpers that require additional gems
    end
  end
end

#load_layout_content ⇒ Object

Load layout content, trying .html.erb first, then .html, or default layout



128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/static_site_builder/builder.rb', line 128

def load_layout_content
  layout = StaticSiteBuilder::DEFAULT_LAYOUT_NAME
  layout_file = @root.join('app', 'views', 'layouts', "#{layout}.html.erb")
  layout_file = @root.join('app', 'views', 'layouts', "#{layout}.html") unless layout_file.exist?
  layout_content = layout_file.exist? ? File.read(layout_file) : default_layout

  # Inject live reload script if enabled and using custom layout
  if ENV['LIVE_RELOAD'] == 'true' && layout_file.exist?
    unless layout_content.include?('live reload') || layout_content.include?('LIVE_RELOAD')
      script = live_reload_script
      layout_content = layout_content.gsub(/<\/body>/, "#{script}</body>") unless script.blank?
    end
  end

  [layout_content, layout_file]
end

#render_layout_template(view, layout_content, layout_file, page_content) ⇒ Object

Render layout template using ActionView with proper yield mechanism



235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
# File 'lib/static_site_builder/builder.rb', line 235

def render_layout_template(view, layout_content, layout_file, page_content)
  layout = StaticSiteBuilder::DEFAULT_LAYOUT_NAME
  
  # Make page content safe for HTML output
  safe_page_content = page_content.respond_to?(:html_safe) ? page_content.html_safe : page_content
  
  # Create layout template
  layout_template = ActionView::Template.new(
    layout_content,
    layout_file.exist? ? layout_file.to_s : 'inline:layout',
    ActionView::Template::Handlers::ERB.new,
    virtual_path: "layouts/#{layout}",
    format: :html,
    locals: []
  )
  
  # Render layout with page content available via yield
  # In Rails/ActionView, yield in a layout template returns the rendered page content
  # We achieve this by storing the page content in the view flow before rendering the layout
  # The view flow's :layout key is what yield accesses
  original_flow_content = view.view_flow.get(:layout)
  view.view_flow.set(:layout, safe_page_content)
  
  rendered = view.render(template: layout_template)
  
  # Restore original flow content if it existed
  if original_flow_content
    view.view_flow.set(:layout, original_flow_content)
  else
    view.view_flow.set(:layout, nil)
  end

  if @annotate_template_file_names && layout_file.exist?
    relative_layout_path = Pathname.new(layout_file).relative_path_from(@root)
    begin_comment = "<!-- BEGIN #{relative_layout_path} -->"
    end_comment = "<!-- END #{relative_layout_path} -->"
    rendered = "#{begin_comment}\n#{rendered}\n#{end_comment}"
  end

  rendered
end

#render_page_template(view, content, page_name, erb_file) ⇒ Object

Render page template using ActionView (file-based, not inline)



194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'lib/static_site_builder/builder.rb', line 194

def render_page_template(view, content, page_name, erb_file)
  # Calculate the template path relative to app/views
  # e.g., app/views/pages/index.html.erb -> pages/index
  pages_dir = @root.join("app", "views", "pages")
  template_path = Pathname.new(erb_file).relative_path_from(pages_dir).to_s.gsub(/\.html\.erb$/, '')
  full_template_path = "pages/#{template_path}"

  # Set prefixes on lookup_context so ActionView can resolve partials relative to template directory
  # For pages/index -> prefix is 'pages', for pages/blog/index -> prefix is 'pages/blog'
  lookup_context = view.lookup_context
  original_prefixes = lookup_context.prefixes.dup
  template_dir = File.dirname(full_template_path)
  lookup_context.prefixes = [template_dir]

  begin
    # Set instance variables on view (Rails pattern: controllers set instance variables)
    # Templates set their own @title, @description etc. using meta-tags gem
    
    # Use file-based rendering - ActionView will find the actual file
    # With prefixes set, partials can be resolved relative to the template directory
    template = lookup_context.find_template(full_template_path, [], false, [], {})
    page_content = view.render(template: template)
  rescue ActionView::Template::Error => e
    if e.cause.is_a?(ActionView::MissingTemplate)
      raise "Partial template not found. Searched in: #{e.cause.path}"
    end
    raise
  ensure
    # Restore original prefixes
    lookup_context.prefixes = original_prefixes
  end

  if @annotate_template_file_names
    relative_template_path = Pathname.new(erb_file).relative_path_from(@root)
    page_content = annotate_template(page_content, relative_template_path.to_s)
  end

  page_content
end

#setup_action_view_context ⇒ Object

Setup ActionView context and create view instance

Creates an ActionView::Base instance with helpers included (Rails pattern). Helpers are included on the class before instantiation, which is more Rails-like than extending individual instances.



150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/static_site_builder/builder.rb', line 150

def setup_action_view_context
  view_paths = ActionView::PathSet.new([@root.join('app', 'views').to_s])
  lookup_context = ActionView::LookupContext.new(view_paths)
  view_class = ActionView::Base.with_empty_template_cache
  
  # Include helpers on the class (Rails pattern) rather than extending instances
  view_class.include(MetaTags::ViewHelper) unless view_class.included_modules.include?(MetaTags::ViewHelper)
  
  # Automatically load helpers from app/helpers/
  load_helpers(view_class)
  
  view = view_class.new(lookup_context, {}, self)
  view
end

#write_page_output(dist_dir, page_name, rendered) ⇒ Object

Write final rendered output to file



278
279
280
281
282
# File 'lib/static_site_builder/builder.rb', line 278

def write_page_output(dist_dir, page_name, rendered)
  output_path = dist_dir.join(page_name)
  FileUtils.mkdir_p(output_path.dirname)
  File.write(output_path, rendered)
end