static-site-builder 0.1.4 → 1.0.0

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.
@@ -9,23 +9,47 @@ require "json"
9
9
  require "pathname"
10
10
  require "digest"
11
11
 
12
+ # Create minimal ActionController stub for meta-tags gem compatibility
13
+ # meta-tags expects ActionController to be available but we're using ActionView standalone
14
+ unless defined?(ActionController)
15
+ module ActionController
16
+ class Base
17
+ # meta-tags gem calls ActionController::Base.helpers
18
+ # In Rails, this returns an instance that includes ActionView::Helpers
19
+ # We create a simple object that extends ActionView::Helpers modules
20
+ def self.helpers
21
+ @helpers ||= Object.new.tap do |helper_obj|
22
+ helper_obj.extend(ActionView::Helpers::TagHelper)
23
+ helper_obj.extend(ActionView::Helpers::OutputSafetyHelper)
24
+ helper_obj.extend(ActionView::Helpers::TextHelper)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ # Require meta-tags gem (handle Railtie gracefully for non-Rails usage)
12
32
  begin
13
- require "importmap-rails"
14
- rescue LoadError
15
- # importmap-rails is optional
33
+ require "meta_tags"
34
+ rescue NameError => e
35
+ # If Rails::Railtie is not available, require components directly
36
+ if e.message.include?("Railtie")
37
+ require "meta_tags/meta_tags_collection"
38
+ require "meta_tags/view_helper"
39
+ else
40
+ raise
41
+ end
16
42
  end
17
43
 
44
+
18
45
  module StaticSiteBuilder
19
46
  class Builder
20
- def initialize(root: Dir.pwd, template_engine: "erb", js_bundler: "importmap", importmap_config: nil, annotate_template_file_names: nil)
47
+ # Output directory name
48
+ DIST_DIR = "dist"
49
+ # Default JavaScript entry point
50
+ JS_ENTRY_POINT = "application"
51
+ def initialize(root: Dir.pwd, annotate_template_file_names: nil)
21
52
  @root = Pathname.new(root)
22
- @template_engine = template_engine
23
- @js_bundler = js_bundler
24
- @importmap_config_path = if importmap_config
25
- Pathname.new(importmap_config)
26
- else
27
- @root.join("config", "importmap.rb")
28
- end
29
53
 
30
54
  # Auto-enable annotations in development (when LIVE_RELOAD is enabled)
31
55
  @annotate_template_file_names = if annotate_template_file_names.nil?
@@ -33,428 +57,213 @@ module StaticSiteBuilder
33
57
  else
34
58
  annotate_template_file_names
35
59
  end
36
-
37
- @importmap = if defined?(Importmap::Map)
38
- Importmap::Map.new
39
- else
40
- SimpleImportMap.new(root: @root)
41
- end
42
-
43
- load_importmap_config if @js_bundler == "importmap"
44
60
  end
45
61
 
46
- # Build the static site
62
+ # Builds the complete static site.
47
63
  #
48
- # Compiles all templates, copies assets, generates importmap (if needed),
49
- # and outputs everything to the dist/ directory.
64
+ # Compiles ERB templates to HTML, copies JavaScript and CSS assets, and outputs
65
+ # everything to the dist/ directory. In development mode, files are updated in place
66
+ # to prevent 404 errors during live reload. In production mode, the dist directory
67
+ # is cleaned first for a fresh build.
50
68
  #
51
69
  # @return [void]
52
70
  def build
53
71
  puts "Building static site..."
54
72
 
55
- dist_dir = @root.join("dist")
73
+ dist_dir = @root.join(DIST_DIR)
56
74
 
57
- # Only clean dist directory for production/release builds
58
- # In development, update files in place to prevent 404s during rebuilds
75
+ # Clean dist directory only for production/release builds
76
+ # In development, update files in place to prevent 404s during live reload
59
77
  production_build = ENV["PRODUCTION"] == "true" || ENV["RELEASE"] == "true"
60
78
  if production_build
61
79
  if dist_dir.exist?
62
80
  puts "Cleaning dist directory for production build..."
63
81
  FileUtils.rm_rf(dist_dir)
64
- else
65
- puts "Dist directory does not exist, skipping clean"
66
82
  end
67
83
  end
68
84
 
69
85
  # Ensure dist directory exists
70
86
  FileUtils.mkdir_p(dist_dir)
71
87
 
72
- # Copy assets (overwrites existing files)
88
+ # Copy JavaScript and CSS assets to dist
73
89
  copy_assets(dist_dir)
74
90
 
75
- # Generate importmap JSON if using importmap (overwrites existing file)
76
- generate_importmap(dist_dir) if @js_bundler == "importmap"
91
+ # Compile ERB templates to static HTML pages
92
+ compile_erb_pages(dist_dir)
77
93
 
78
- # Compile pages based on template engine (overwrites existing files)
79
- case @template_engine
80
- when "erb"
81
- compile_erb_pages(dist_dir)
82
- when "phlex"
83
- compile_phlex_pages(dist_dir)
84
- end
85
-
86
- # Copy static files (overwrites existing files)
94
+ # Copy static files from public/ directory to dist
87
95
  copy_static_files(dist_dir)
88
96
 
89
- # Notify WebSocket server (always update, even if file doesn't exist yet)
97
+ # Notify WebSocket server of rebuild for live reload
98
+ # Always update the reload file, even if it doesn't exist yet
90
99
  reload_file = @root.join(".reload")
91
- File.write(reload_file, Time.now.to_f.to_s)
100
+ File.write(reload_file, Time.current.to_f.to_s)
92
101
 
93
102
  puts "\n✓ Build complete! Output in #{dist_dir}"
94
103
  end
95
104
 
96
- # Public method to render partials - no longer needed as ActionView handles this directly
97
- # ActionView's render method automatically finds and renders partials from app/views
98
- # This method is kept for backwards compatibility but should not be called
99
- def render_partial(partial_path, view_context, locals = {})
100
- # ActionView handles partial rendering automatically through its render method
101
- # When templates call render 'shared/header', ActionView finds _header.html.erb automatically
102
- begin
103
- view_context.render(partial: partial_path, locals: locals)
104
- rescue ActionView::MissingTemplate => e
105
- # Convert ActionView's error to our format for backwards compatibility
106
- raise "Partial not found: #{partial_path} (looked for #{e.path})"
107
- end
108
- end
109
-
110
- private
111
-
112
- def load_importmap_config
113
- return unless @importmap_config_path.exist?
114
-
115
- config_content = File.read(@importmap_config_path)
116
- # Replace relative paths with absolute paths
117
- config_content = config_content.gsub(/"app\//, %("#{@root.join("app").to_s}/))
118
- config_content = config_content.gsub(/"vendor\//, %("#{@root.join("vendor").to_s}/))
119
-
120
- # Replace File.expand_path calls with actual paths
121
- config_content = config_content.gsub(/File\.expand_path\(["'](.*?)["'], __dir__\)/) do |match|
122
- path = $1
123
- @root.join("config", path).expand_path.to_s.inspect
124
- end
125
-
126
- @importmap.instance_eval(config_content, @importmap_config_path.to_s)
127
- end
128
-
129
- def copy_assets(dist_dir)
130
- puts "Copying assets..."
131
-
132
- # Copy JavaScript files
133
- js_dir = @root.join("app", "javascript")
134
- if js_dir.exist? && js_dir.directory?
135
- dist_js = dist_dir.join("assets", "javascripts")
136
- FileUtils.mkdir_p(dist_js)
137
- # Copy all files and subdirectories from js_dir to dist_js
138
- Dir.glob(js_dir.join("*")).each do |item|
139
- FileUtils.cp_r(item, dist_js, preserve: true)
140
- end
141
- end
142
-
143
- # Copy vendor JavaScript files directly from node_modules to dist (for importmap)
144
- if @js_bundler == "importmap"
145
- copy_vendor_files_from_node_modules(dist_dir)
146
- end
147
-
148
- # Copy CSS (skip if Tailwind is handling it - check for tailwind.config.js)
149
- # Tailwind outputs directly to dist, so we don't want to overwrite with raw files
150
- # But we still need to ensure the directory exists for Tailwind to write to
151
- tailwind_config = @root.join("tailwind.config.js")
152
- css_dir = @root.join("app", "assets", "stylesheets")
153
- dist_css = dist_dir.join("assets", "stylesheets")
154
-
155
- if tailwind_config.exist?
156
- # Tailwind is handling CSS - ensure directory exists but don't copy raw files
157
- FileUtils.mkdir_p(dist_css)
158
- elsif css_dir.exist? && css_dir.directory?
159
- # No Tailwind - copy CSS files normally
160
- FileUtils.mkdir_p(dist_css)
161
- Dir.glob(css_dir.join("*")).each do |item|
162
- FileUtils.cp_r(item, dist_css, preserve: true)
163
- end
164
- end
165
- end
166
-
167
- def copy_vendor_files_from_node_modules(dist_dir)
168
- return unless @importmap_config_path && @importmap_config_path.exist?
169
-
170
- config_content = File.read(@importmap_config_path.to_s)
171
- dist_js = dist_dir.join("assets", "javascripts")
172
- FileUtils.mkdir_p(dist_js)
173
-
174
- # Extract vendor file references from importmap config
175
- # Look for patterns like: pin "@hotwired/stimulus", to: "stimulus.min.js"
176
- # Only process pins that have 'to:' specified (vendor files), not local app files
177
- config_content.scan(/pin\s+["']([^"']+)["'],\s*to:\s*["']([^"']+)["']/) do |package_name, file_name|
178
- # Skip if this is a local app file
179
- next if file_name.start_with?("app/") || file_name.start_with?("./app/")
180
-
181
- # Only try to copy npm packages (scoped packages like @hotwired/stimulus or known packages)
182
- # Skip simple names like "application" that are local files
183
- next unless package_name.include?("/") || package_name.start_with?("@")
184
-
185
- dest_file = dist_js.join(file_name)
186
- next if dest_file.exist?
187
-
188
- # Try to copy directly from node_modules to dist
189
- if copy_vendor_file_from_node_modules(package_name, file_name, dest_file)
190
- puts " ✓ Copied #{file_name} from #{package_name}"
191
- else
192
- puts " ⚠️ Warning: Could not find #{file_name} for #{package_name} in node_modules"
193
- puts " Ensure 'npm install' has been run and the package is installed."
194
- end
195
- end
196
- end
197
-
198
- def copy_vendor_file_from_node_modules(package_name, file_name, dest_file)
199
- node_modules = @root.join("node_modules")
200
- return false unless node_modules.exist?
201
-
202
- package_dir = node_modules.join(*package_name.split("/"))
203
- return false unless package_dir.exist?
204
-
205
- # Try common source paths for the package
206
- # Extract base name from file_name (e.g., "stimulus.min.js" -> "stimulus")
207
- base_name = file_name.gsub(/\.(min\.)?js$/, "")
208
- source_paths = [
209
- "dist/#{base_name}.js",
210
- "dist/#{base_name}.min.js",
211
- "dist/index.js",
212
- "#{base_name}.js",
213
- "index.js",
214
- "dist/#{file_name}",
215
- file_name
216
- ]
217
-
218
- source_paths.each do |source_path|
219
- source_file = package_dir.join(*source_path.split("/"))
220
- if source_file.exist? && source_file.file?
221
- FileUtils.cp(source_file, dest_file)
222
- return true
223
- end
224
- end
225
-
226
- false
227
- end
228
-
229
- def generate_importmap(dist_dir)
230
- return unless defined?(@importmap) && @importmap
231
-
232
- puts "Generating importmap..."
233
-
234
- # Create a simple resolver for asset paths
235
- resolver = AssetResolver.new(@root, dist_dir)
236
-
237
- importmap_json = @importmap.to_json(resolver: resolver)
238
-
239
- FileUtils.mkdir_p(dist_dir.join("assets"))
240
- File.write(dist_dir.join("assets", "importmap.json"), JSON.pretty_generate(JSON.parse(importmap_json)))
241
- end
242
-
243
- def compile_erb_pages(dist_dir)
244
- pages_dir = @root.join("app", "views", "pages")
245
- return unless pages_dir.exist?
246
-
247
- # Generate importmap JSON once for all pages
248
- resolver = AssetResolver.new(@root, dist_dir)
249
- importmap_json_str = @importmap.to_json(resolver: resolver) if defined?(@importmap) && @importmap
250
-
251
- # Find all ERB files, including nested directories
252
- Dir.glob(pages_dir.join("**", "*.html.erb")).each do |erb_file|
253
- relative_path = Pathname.new(erb_file).relative_path_from(pages_dir)
254
- page_name = relative_path.to_s.gsub(/\.html\.erb$/, ".html")
105
+ # Helper methods that need to be accessible from closures.
106
+ #
107
+ # These methods are public to allow access from ActionView template closures,
108
+ # but they are considered internal API and should not be called directly.
255
109
 
256
- compile_erb_page(erb_file, page_name, dist_dir, importmap_json_str)
110
+ # Accesses hash values using either symbol or string keys.
111
+ #
112
+ # Tries each key in order until a non-nil value is found. This allows
113
+ # compatibility with both symbol and string keys in options hashes.
114
+ #
115
+ # @param hash [Hash] The hash to search
116
+ # @param keys [Array<Symbol, String>] Keys to try in order
117
+ # @return [Object, nil] The first non-nil value found, or nil if none found
118
+ def hash_value(hash, *keys)
119
+ keys.each do |key|
120
+ value = hash[key]
121
+ return value if value.present?
257
122
  end
123
+ nil
258
124
  end
259
125
 
260
- def compile_erb_page(erb_file, page_name, dist_dir, importmap_json_str)
261
- puts "Compiling #{page_name}..."
262
-
263
- # Read ERB content - ActionView will process it directly
264
- content = File.read(erb_file)
265
-
266
- # Default layout
267
- layout = "application"
268
126
 
269
- # Load layout - try .html.erb first, then .html
270
- layout_file = @root.join("app", "views", "layouts", "#{layout}.html.erb")
271
- layout_file = @root.join("app", "views", "layouts", "#{layout}.html") unless layout_file.exist?
127
+ # Load layout content, trying .html.erb first, then .html, or default layout
128
+ def load_layout_content
129
+ layout = StaticSiteBuilder::DEFAULT_LAYOUT_NAME
130
+ layout_file = @root.join('app', 'views', 'layouts', "#{layout}.html.erb")
131
+ layout_file = @root.join('app', 'views', 'layouts', "#{layout}.html") unless layout_file.exist?
272
132
  layout_content = layout_file.exist? ? File.read(layout_file) : default_layout
273
133
 
274
134
  # Inject live reload script if enabled and using custom layout
275
- if ENV["LIVE_RELOAD"] == "true" && layout_file.exist?
276
- # Inject live reload into custom layouts too
277
- unless layout_content.include?("live reload") || layout_content.include?("LIVE_RELOAD")
278
- ws_port = ENV["WS_PORT"] || 3001
279
- live_reload_script = <<~HTML
280
- <script>
281
- (function() {
282
- function connect() {
283
- var ws = new WebSocket('ws://localhost:#{ws_port}');
284
- ws.onmessage = function(e) {
285
- if (e.data === 'reload') window.location.reload();
286
- };
287
- ws.onclose = function() { setTimeout(connect, 1000); };
288
- ws.onerror = function() {};
289
- }
290
- connect();
291
- })();
292
- </script>
293
- HTML
294
- layout_content = layout_content.gsub(/<\/body>/, "#{live_reload_script}</body>")
135
+ if ENV['LIVE_RELOAD'] == 'true' && layout_file.exist?
136
+ unless layout_content.include?('live reload') || layout_content.include?('LIVE_RELOAD')
137
+ script = live_reload_script
138
+ layout_content = layout_content.gsub(/<\/body>/, "#{script}</body>") unless script.blank?
295
139
  end
296
140
  end
297
141
 
298
- # Set current_page based on the file being compiled
299
- current_page_path = if page_name == 'index.html'
300
- '/'
301
- else
302
- "/#{page_name.gsub(/\.html$/, '')}"
303
- end
142
+ [layout_content, layout_file]
143
+ end
304
144
 
305
- # Create ActionView lookup context with view paths
306
- view_paths = ActionView::PathSet.new([@root.join("app", "views").to_s])
145
+ # Setup ActionView context and create view instance
146
+ #
147
+ # Creates an ActionView::Base instance with helpers included (Rails pattern).
148
+ # Helpers are included on the class before instantiation, which is more
149
+ # Rails-like than extending individual instances.
150
+ def setup_action_view_context
151
+ view_paths = ActionView::PathSet.new([@root.join('app', 'views').to_s])
307
152
  lookup_context = ActionView::LookupContext.new(view_paths)
308
-
309
- # Create ActionView::Base instance for rendering using with_empty_template_cache
310
- # This is the recommended way for standalone ActionView usage
311
153
  view_class = ActionView::Base.with_empty_template_cache
312
- view = view_class.new(lookup_context, {}, self)
313
-
314
- # Include PageHelpers if available (look in project root)
315
- # Only require once (first time)
316
- unless defined?(@page_helpers_loaded)
317
- begin
318
- page_helpers_path = @root.join('lib', 'page_helpers.rb')
319
- if page_helpers_path.exist?
320
- require page_helpers_path.to_s
321
- @page_helpers_loaded = true
322
- end
323
- rescue LoadError
324
- # PageHelpers not available, continue without it
325
- end
326
- end
327
154
 
328
- # Extend view with PageHelpers if available
329
- if defined?(PageHelpers)
330
- view.extend(PageHelpers) unless view.singleton_class.included_modules.include?(PageHelpers)
331
- end
332
-
333
- # Set instance variables that will be available in templates
334
- # Pages can set @title, @js_modules, etc. via ERB at the top
335
- view.instance_variable_set(:@js_modules, [])
336
- view.instance_variable_set(:@importmap_json, importmap_json_str) if importmap_json_str
337
- view.instance_variable_set(:@current_page, current_page_path)
338
- view.instance_variable_set(:@page_content, nil)
155
+ # Include helpers on the class (Rails pattern) rather than extending instances
156
+ view_class.include(MetaTags::ViewHelper) unless view_class.included_modules.include?(MetaTags::ViewHelper)
339
157
 
340
- # Set title and metadata from PageHelpers BEFORE rendering page content
341
- # This ensures partials rendered within the page have access to metadata
342
- page_helpers_path = @root.join('lib', 'page_helpers.rb')
343
- begin
344
- if page_helpers_path.exist?
345
- require page_helpers_path.to_s
346
- pages = ::PageHelpers::PAGES rescue nil
347
- if pages && pages.is_a?(Hash) && pages.key?(current_page_path)
348
- metadata = pages[current_page_path]
349
- view.instance_variable_set(:@title, metadata[:title])
350
- view.instance_variable_set(:@description, metadata[:description])
351
- view.instance_variable_set(:@url, metadata[:url])
352
- view.instance_variable_set(:@image, metadata[:image])
353
- end
354
- end
355
- rescue => e
356
- # Silently continue if PageHelpers can't be loaded
357
- end
158
+ # Automatically load helpers from app/helpers/
159
+ load_helpers(view_class)
358
160
 
359
- # Override render to handle 'footer' -> 'shared/footer' conversion
360
- # and ensure locals are passed to partials
361
- view.define_singleton_method(:render) do |options = {}, locals = {}, &block|
161
+ view = view_class.new(lookup_context, {}, self)
162
+ view
163
+ end
164
+
165
+ # Automatically load helper modules from app/helpers/ directory
166
+ def load_helpers(view_class)
167
+ helpers_dir = @root.join('app', 'helpers')
168
+ return unless helpers_dir.exist? && helpers_dir.directory?
169
+
170
+ Dir.glob(helpers_dir.join('**', '*_helper.rb')).each do |helper_file|
362
171
  begin
363
- # Handle string/symbol partial names: render 'footer' -> render 'shared/footer'
364
- if options.is_a?(String) || options.is_a?(Symbol)
365
- partial_name = options.to_s
366
- # If no path separator, assume it's in shared/
367
- unless partial_name.include?('/')
368
- partial_name = "shared/#{partial_name}"
369
- end
370
- # Merge page locals with any provided locals
371
- merged_locals = {
372
- importmap_json: importmap_json_str,
373
- current_page: current_page_path
374
- }.merge(locals.is_a?(Hash) ? locals : {})
375
- super(partial: partial_name, locals: merged_locals, &block)
376
- elsif options.is_a?(Hash)
377
- # Handle hash options: render partial: 'footer', locals: {}
378
- partial_path = options[:partial] || options['partial']
379
- if partial_path
380
- # Convert 'footer' to 'shared/footer' if no path
381
- unless partial_path.to_s.include?('/')
382
- partial_path = "shared/#{partial_path}"
383
- end
384
- # Merge page locals with provided locals
385
- provided_locals = options[:locals] || options['locals'] || {}
386
- merged_locals = {
387
- importmap_json: importmap_json_str,
388
- current_page: current_page_path
389
- }.merge(provided_locals)
390
- super(partial: partial_path, locals: merged_locals, &block)
391
- else
392
- # Other render options (template, etc.)
393
- super(options, locals, &block)
394
- end
395
- else
396
- super(options, locals, &block)
172
+ # Get the module name from the file (e.g., app/helpers/application_helper.rb -> ApplicationHelper)
173
+ relative_path = Pathname.new(helper_file).relative_path_from(helpers_dir)
174
+ module_name = relative_path.to_s.gsub(/\.rb$/, '').split('_').map(&:capitalize).join
175
+
176
+ # Load the file (use load instead of require for absolute paths)
177
+ load helper_file
178
+
179
+ # Include the module if it exists
180
+ if Object.const_defined?(module_name)
181
+ helper_module = Object.const_get(module_name)
182
+ view_class.include(helper_module) unless view_class.included_modules.include?(helper_module)
397
183
  end
398
- rescue ActionView::MissingTemplate => e
399
- # Convert ActionView's error to our format for backwards compatibility
400
- raise "Partial not found: #{partial_path || partial_name || 'unknown'} (looked for #{e.path})"
184
+ rescue LoadError, NameError => e
185
+ # Silently skip if helper can't be loaded (e.g., missing dependencies)
186
+ # This allows users to have helpers that require additional gems
401
187
  end
402
188
  end
189
+ end
190
+
191
+
192
+
193
+ # Render page template using ActionView (file-based, not inline)
194
+ def render_page_template(view, content, page_name, erb_file)
195
+ # Calculate the template path relative to app/views
196
+ # e.g., app/views/pages/index.html.erb -> pages/index
197
+ pages_dir = @root.join("app", "views", "pages")
198
+ template_path = Pathname.new(erb_file).relative_path_from(pages_dir).to_s.gsub(/\.html\.erb$/, '')
199
+ full_template_path = "pages/#{template_path}"
200
+
201
+ # Set prefixes on lookup_context so ActionView can resolve partials relative to template directory
202
+ # For pages/index -> prefix is 'pages', for pages/blog/index -> prefix is 'pages/blog'
203
+ lookup_context = view.lookup_context
204
+ original_prefixes = lookup_context.prefixes.dup
205
+ template_dir = File.dirname(full_template_path)
206
+ lookup_context.prefixes = [template_dir]
403
207
 
404
- # Render page content using ActionView
405
- # Pages can set instance variables via ERB (e.g., <% @title = '...' %>)
406
- page_template = ActionView::Template.new(
407
- content,
408
- "inline:page",
409
- ActionView::Template::Handlers::ERB.new,
410
- virtual_path: "pages/#{page_name.gsub(/\.html$/, '')}",
411
- format: :html,
412
- locals: [:importmap_json, :current_page]
413
- )
414
-
415
208
  begin
416
- page_content = view.render(template: page_template, locals: {
417
- importmap_json: importmap_json_str,
418
- current_page: current_page_path
419
- })
209
+ # Set instance variables on view (Rails pattern: controllers set instance variables)
210
+ # Templates set their own @title, @description etc. using meta-tags gem
211
+
212
+ # Use file-based rendering - ActionView will find the actual file
213
+ # With prefixes set, partials can be resolved relative to the template directory
214
+ template = lookup_context.find_template(full_template_path, [], false, [], {})
215
+ page_content = view.render(template: template)
420
216
  rescue ActionView::Template::Error => e
421
- # Convert ActionView errors to our format
422
217
  if e.cause.is_a?(ActionView::MissingTemplate)
423
- raise "Partial not found: #{e.cause.path} (looked for #{e.cause.path})"
218
+ raise "Partial template not found. Searched in: #{e.cause.path}"
424
219
  end
425
220
  raise
221
+ ensure
222
+ # Restore original prefixes
223
+ lookup_context.prefixes = original_prefixes
426
224
  end
427
225
 
428
- # Annotate page content if enabled
429
226
  if @annotate_template_file_names
430
227
  relative_template_path = Pathname.new(erb_file).relative_path_from(@root)
431
228
  page_content = annotate_template(page_content, relative_template_path.to_s)
432
229
  end
433
230
 
434
- # Render layout using ActionView
435
- # Instance variables set in the page template are available in the layout
231
+ page_content
232
+ end
233
+
234
+ # Render layout template using ActionView with proper yield mechanism
235
+ def render_layout_template(view, layout_content, layout_file, page_content)
236
+ layout = StaticSiteBuilder::DEFAULT_LAYOUT_NAME
237
+
238
+ # Make page content safe for HTML output
239
+ safe_page_content = page_content.respond_to?(:html_safe) ? page_content.html_safe : page_content
240
+
241
+ # Create layout template
436
242
  layout_template = ActionView::Template.new(
437
243
  layout_content,
438
- "inline:layout",
244
+ layout_file.exist? ? layout_file.to_s : 'inline:layout',
439
245
  ActionView::Template::Handlers::ERB.new,
440
246
  virtual_path: "layouts/#{layout}",
441
247
  format: :html,
442
- locals: [:page_content, :importmap_json, :current_page]
248
+ locals: []
443
249
  )
444
-
445
- # Mark page_content as HTML safe to prevent escaping
446
- # ActionView will escape strings by default in ERB, so we mark it as safe
447
- safe_page_content = page_content.respond_to?(:html_safe) ? page_content.html_safe : page_content
448
250
 
449
- rendered = view.render(template: layout_template, locals: {
450
- page_content: safe_page_content,
451
- importmap_json: importmap_json_str,
452
- current_page: current_page_path
453
- })
454
-
455
- # Annotate layout if enabled
456
- # Note: We wrap the rendered content without removing existing annotations
457
- # This preserves page annotations that are already in the content
251
+ # Render layout with page content available via yield
252
+ # In Rails/ActionView, yield in a layout template returns the rendered page content
253
+ # We achieve this by storing the page content in the view flow before rendering the layout
254
+ # The view flow's :layout key is what yield accesses
255
+ original_flow_content = view.view_flow.get(:layout)
256
+ view.view_flow.set(:layout, safe_page_content)
257
+
258
+ rendered = view.render(template: layout_template)
259
+
260
+ # Restore original flow content if it existed
261
+ if original_flow_content
262
+ view.view_flow.set(:layout, original_flow_content)
263
+ else
264
+ view.view_flow.set(:layout, nil)
265
+ end
266
+
458
267
  if @annotate_template_file_names && layout_file.exist?
459
268
  relative_layout_path = Pathname.new(layout_file).relative_path_from(@root)
460
269
  begin_comment = "<!-- BEGIN #{relative_layout_path} -->"
@@ -462,28 +271,108 @@ module StaticSiteBuilder
462
271
  rendered = "#{begin_comment}\n#{rendered}\n#{end_comment}"
463
272
  end
464
273
 
274
+ rendered
275
+ end
276
+
277
+ # Write final rendered output to file
278
+ def write_page_output(dist_dir, page_name, rendered)
465
279
  output_path = dist_dir.join(page_name)
466
280
  FileUtils.mkdir_p(output_path.dirname)
467
- puts " Debug: Writing #{rendered.length} chars to #{output_path}"
468
281
  File.write(output_path, rendered)
282
+ end
469
283
 
470
- puts " ✓ Created #{page_name}"
284
+
285
+
286
+ # Generate live reload WebSocket script if live reload is enabled
287
+ def live_reload_script
288
+ if ENV["LIVE_RELOAD"] == "true"
289
+ ws_port = ENV["WS_PORT"] || StaticSiteBuilder::DEFAULT_WS_PORT
290
+ <<~HTML
291
+ <script>
292
+ (function() {
293
+ function connect() {
294
+ var ws = new WebSocket('ws://localhost:#{ws_port}');
295
+ ws.onmessage = function(e) {
296
+ if (e.data === 'reload') window.location.reload();
297
+ };
298
+ ws.onclose = function() { setTimeout(connect, 1000); };
299
+ ws.onerror = function() {};
300
+ }
301
+ connect();
302
+ })();
303
+ </script>
304
+ HTML
305
+ else
306
+ ""
307
+ end
308
+ end
309
+
310
+ def copy_assets(dist_dir)
311
+ puts "Copying assets..."
312
+
313
+ # Copy JavaScript files from app/javascript to dist/assets/javascripts
314
+ js_dir = @root.join("app", "javascript")
315
+ if js_dir.exist? && js_dir.directory?
316
+ dist_js = dist_dir.join("assets", "javascripts")
317
+ FileUtils.mkdir_p(dist_js)
318
+ # Copy all files and subdirectories recursively
319
+ Dir.glob(js_dir.join("*")).each do |item|
320
+ FileUtils.cp_r(item, dist_js, preserve: true)
321
+ end
322
+ end
323
+
324
+ # Handle CSS files
325
+ # Copy CSS files from app/assets/stylesheets to dist/assets/stylesheets
326
+ css_dir = @root.join("app", "assets", "stylesheets")
327
+ dist_css = dist_dir.join("assets", "stylesheets")
328
+
329
+ if css_dir.exist? && css_dir.directory?
330
+ FileUtils.mkdir_p(dist_css)
331
+ Dir.glob(css_dir.join("*")).each do |item|
332
+ FileUtils.cp_r(item, dist_css, preserve: true)
333
+ end
334
+ end
471
335
  end
472
336
 
473
- def compile_phlex_pages(dist_dir)
474
- # Phlex compilation will be implemented when phlex-rails is available
337
+
338
+ def compile_erb_pages(dist_dir)
475
339
  pages_dir = @root.join("app", "views", "pages")
476
340
  return unless pages_dir.exist?
477
341
 
478
- puts "Phlex compilation not yet implemented"
479
- # TODO: Implement Phlex page compilation
342
+ # Find all ERB files, including nested directories
343
+ Dir.glob(pages_dir.join("**", "*.html.erb")).each do |erb_file|
344
+ relative_path = Pathname.new(erb_file).relative_path_from(pages_dir)
345
+ page_name = relative_path.to_s.gsub(/\.html\.erb$/, ".html")
346
+
347
+ compile_erb_page(erb_file, page_name, dist_dir)
348
+ end
349
+ end
350
+
351
+ def compile_erb_page(erb_file, page_name, dist_dir)
352
+ puts "Compiling page: #{page_name}..."
353
+
354
+ layout_content, layout_file = load_layout_content
355
+ view = setup_action_view_context
356
+
357
+ # Render page template first - this sets up meta tags and content_for blocks
358
+ # Pass erb_file directly - render_page_template will use file-based rendering
359
+ # Page templates set their own @title, @description etc. using meta-tags gem
360
+ page_content = render_page_template(view, nil, page_name, erb_file)
361
+
362
+ # Now render layout with page content available via yield
363
+ rendered = render_layout_template(view, layout_content, layout_file, page_content)
364
+
365
+ write_page_output(dist_dir, page_name, rendered)
366
+
367
+ puts " ✓ Created #{page_name}"
480
368
  end
481
369
 
370
+
482
371
  def copy_static_files(dist_dir)
483
372
  public_dir = @root.join("public")
484
373
  return unless public_dir.exist? && public_dir.directory?
485
374
 
486
- puts "Copying public files..."
375
+ puts "Copying static files from public/..."
487
376
  # Copy all files and subdirectories from public to dist
488
377
  Dir.glob(public_dir.join("*")).each do |item|
489
378
  FileUtils.cp_r(item, dist_dir, preserve: true)
@@ -503,26 +392,7 @@ module StaticSiteBuilder
503
392
  end
504
393
 
505
394
  def default_layout
506
- live_reload_script = if ENV["LIVE_RELOAD"] == "true"
507
- ws_port = ENV["WS_PORT"] || 3001
508
- <<~HTML
509
- <script>
510
- (function() {
511
- function connect() {
512
- var ws = new WebSocket('ws://localhost:#{ws_port}');
513
- ws.onmessage = function(e) {
514
- if (e.data === 'reload') window.location.reload();
515
- };
516
- ws.onclose = function() { setTimeout(connect, 1000); };
517
- ws.onerror = function() {};
518
- }
519
- connect();
520
- })();
521
- </script>
522
- HTML
523
- else
524
- ""
525
- end
395
+ live_reload_script_content = live_reload_script
526
396
 
527
397
  <<~HTML
528
398
  <!DOCTYPE html>
@@ -530,86 +400,21 @@ module StaticSiteBuilder
530
400
  <head>
531
401
  <meta charset="UTF-8">
532
402
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
533
- <title><%= @title || 'Site' %></title>
403
+ <%# Set meta tags in your page templates using: %>
404
+ <%# <% set_meta_tags title: 'Page Title', description: 'Description' %> %>
405
+ <%= display_meta_tags %>
534
406
  <link rel="stylesheet" href="/assets/stylesheets/application.css">
535
407
  </head>
536
408
  <body>
537
- <%= page_content %>
538
- <% if defined?(@importmap_json) && @importmap_json %>
539
- <script type="importmap"><%= @importmap_json %></script>
409
+ <%= yield %>
410
+ <% if content_for?(:javascript) %>
411
+ <%= yield(:javascript) %>
540
412
  <% end %>
541
- <% if @js_modules && !@js_modules.empty? %>
542
- <% @js_modules.each do |module_name| %>
543
- <script type="module">import "<%= module_name %>";</script>
544
- <% end %>
545
- <% else %>
546
- <script type="module">import "application";</script>
547
- <% end %>
548
- #{live_reload_script}
413
+ #{live_reload_script_content}
549
414
  </body>
550
415
  </html>
551
416
  HTML
552
417
  end
553
418
 
554
- # Simple asset resolver for importmap
555
- class AssetResolver
556
- def initialize(root, dist_dir)
557
- @root = root
558
- @dist_dir = dist_dir
559
- end
560
-
561
- def javascript_path(path)
562
- "/assets/javascripts/#{path}"
563
- end
564
-
565
- def asset_path(path)
566
- "/assets/#{path}"
567
- end
568
- end
569
-
570
- # Simple importmap implementation if importmap-rails is not available
571
- class SimpleImportMap
572
- def initialize(root: nil)
573
- @pins = {}
574
- @root = root || Pathname.new(Dir.pwd)
575
- end
576
-
577
- def pin(name, to: nil, preload: true)
578
- # If 'to' is provided, use it as-is. Otherwise, default to name.js
579
- to_path = to || "#{name}.js"
580
- @pins[name] = { to: to_path, preload: preload }
581
- end
582
-
583
- def pin_all_from(directory, under: nil)
584
- dir_path = Pathname.new(directory)
585
- # Resolve to absolute path
586
- full_dir_path = dir_path.absolute? ? dir_path : @root.join(dir_path)
587
- return unless full_dir_path.exist?
588
-
589
- # Calculate base path from app/javascript for proper resolution
590
- app_js_path = @root.join("app", "javascript")
591
-
592
- Dir.glob(full_dir_path.join("**", "*.js")).each do |file|
593
- relative_path = Pathname.new(file).relative_path_from(full_dir_path)
594
- name = relative_path.to_s.gsub(/\.js$/, "")
595
- name = name.gsub(/_controller$/, "")
596
- # If under is specified, prepend it to the name
597
- name = under ? "#{under}/#{name}" : name
598
- # Store full path from app/javascript, preserving directory structure
599
- to_path = Pathname.new(file).relative_path_from(app_js_path).to_s
600
- pin(name, to: to_path, preload: true)
601
- end
602
- end
603
-
604
- def to_json(resolver:)
605
- imports = {}
606
- @pins.each do |name, config|
607
- path = resolver.javascript_path(config[:to])
608
- imports[name] = path
609
- end
610
-
611
- JSON.generate({ imports: imports })
612
- end
613
- end
614
419
  end
615
420
  end