static-site-builder 0.1.3 → 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,435 +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
 
158
+ # Automatically load helpers from app/helpers/
159
+ load_helpers(view_class)
340
160
 
341
- # Override render to handle 'footer' -> 'shared/footer' conversion
342
- # and ensure locals are passed to partials
343
- 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|
344
171
  begin
345
- # Handle string/symbol partial names: render 'footer' -> render 'shared/footer'
346
- if options.is_a?(String) || options.is_a?(Symbol)
347
- partial_name = options.to_s
348
- # If no path separator, assume it's in shared/
349
- unless partial_name.include?('/')
350
- partial_name = "shared/#{partial_name}"
351
- end
352
- # Merge page locals with any provided locals
353
- merged_locals = {
354
- importmap_json: importmap_json_str,
355
- current_page: current_page_path
356
- }.merge(locals.is_a?(Hash) ? locals : {})
357
- super(partial: partial_name, locals: merged_locals, &block)
358
- elsif options.is_a?(Hash)
359
- # Handle hash options: render partial: 'footer', locals: {}
360
- partial_path = options[:partial] || options['partial']
361
- if partial_path
362
- # Convert 'footer' to 'shared/footer' if no path
363
- unless partial_path.to_s.include?('/')
364
- partial_path = "shared/#{partial_path}"
365
- end
366
- # Merge page locals with provided locals
367
- provided_locals = options[:locals] || options['locals'] || {}
368
- merged_locals = {
369
- importmap_json: importmap_json_str,
370
- current_page: current_page_path
371
- }.merge(provided_locals)
372
- super(partial: partial_path, locals: merged_locals, &block)
373
- else
374
- # Other render options (template, etc.)
375
- super(options, locals, &block)
376
- end
377
- else
378
- 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)
379
183
  end
380
- rescue ActionView::MissingTemplate => e
381
- # Convert ActionView's error to our format for backwards compatibility
382
- 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
383
187
  end
384
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]
385
207
 
386
- # Render page content using ActionView
387
- # Pages can set instance variables via ERB (e.g., <% @title = '...' %>)
388
- page_template = ActionView::Template.new(
389
- content,
390
- "inline:page",
391
- ActionView::Template::Handlers::ERB.new,
392
- virtual_path: "pages/#{page_name.gsub(/\.html$/, '')}",
393
- format: :html,
394
- locals: [:importmap_json, :current_page]
395
- )
396
-
397
208
  begin
398
- page_content = view.render(template: page_template, locals: {
399
- importmap_json: importmap_json_str,
400
- current_page: current_page_path
401
- })
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)
402
216
  rescue ActionView::Template::Error => e
403
- # Convert ActionView errors to our format
404
217
  if e.cause.is_a?(ActionView::MissingTemplate)
405
- raise "Partial not found: #{e.cause.path} (looked for #{e.cause.path})"
218
+ raise "Partial template not found. Searched in: #{e.cause.path}"
406
219
  end
407
220
  raise
221
+ ensure
222
+ # Restore original prefixes
223
+ lookup_context.prefixes = original_prefixes
408
224
  end
409
225
 
410
- # Annotate page content if enabled
411
226
  if @annotate_template_file_names
412
227
  relative_template_path = Pathname.new(erb_file).relative_path_from(@root)
413
228
  page_content = annotate_template(page_content, relative_template_path.to_s)
414
229
  end
415
230
 
416
- # Set title and metadata from PageHelpers before rendering layout
417
- puts " DEBUG: About to set metadata for #{current_page_path}"
418
- page_helpers_path = @root.join('lib', 'page_helpers.rb')
419
- puts " DEBUG: Checking PageHelpers at: #{page_helpers_path} (exists: #{page_helpers_path.exist?})"
420
- begin
421
- if page_helpers_path.exist?
422
- require page_helpers_path.to_s
423
- pages = ::PageHelpers::PAGES rescue nil
424
- puts " Pages loaded: #{pages ? 'yes' : 'no'}, keys: #{pages&.keys&.inspect}"
425
- if pages && pages.is_a?(Hash) && pages.key?(current_page_path)
426
- metadata = pages[current_page_path]
427
- view.instance_variable_set(:@title, metadata[:title])
428
- view.instance_variable_set(:@description, metadata[:description])
429
- view.instance_variable_set(:@url, metadata[:url])
430
- view.instance_variable_set(:@image, metadata[:image])
431
- puts " ✓ Set metadata for #{current_page_path}: #{metadata[:title]}"
432
- else
433
- puts " ⚠ No metadata found for #{current_page_path}"
434
- end
435
- end
436
- rescue => e
437
- puts " ⚠ Error loading PageHelpers: #{e.class} - #{e.message}"
438
- puts e.backtrace.first(3)
439
- end
231
+ page_content
232
+ end
440
233
 
441
- # Render layout using ActionView
442
- # Instance variables set in the page template are available in the layout
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
443
242
  layout_template = ActionView::Template.new(
444
243
  layout_content,
445
- "inline:layout",
244
+ layout_file.exist? ? layout_file.to_s : 'inline:layout',
446
245
  ActionView::Template::Handlers::ERB.new,
447
246
  virtual_path: "layouts/#{layout}",
448
247
  format: :html,
449
- locals: [:page_content, :importmap_json, :current_page]
248
+ locals: []
450
249
  )
451
-
452
- # Mark page_content as HTML safe to prevent escaping
453
- # ActionView will escape strings by default in ERB, so we mark it as safe
454
- safe_page_content = page_content.respond_to?(:html_safe) ? page_content.html_safe : page_content
455
250
 
456
- rendered = view.render(template: layout_template, locals: {
457
- page_content: safe_page_content,
458
- importmap_json: importmap_json_str,
459
- current_page: current_page_path
460
- })
461
-
462
- # Annotate layout if enabled
463
- # Note: We wrap the rendered content without removing existing annotations
464
- # 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
+
465
267
  if @annotate_template_file_names && layout_file.exist?
466
268
  relative_layout_path = Pathname.new(layout_file).relative_path_from(@root)
467
269
  begin_comment = "<!-- BEGIN #{relative_layout_path} -->"
@@ -469,28 +271,108 @@ module StaticSiteBuilder
469
271
  rendered = "#{begin_comment}\n#{rendered}\n#{end_comment}"
470
272
  end
471
273
 
274
+ rendered
275
+ end
276
+
277
+ # Write final rendered output to file
278
+ def write_page_output(dist_dir, page_name, rendered)
472
279
  output_path = dist_dir.join(page_name)
473
280
  FileUtils.mkdir_p(output_path.dirname)
474
- puts " Debug: Writing #{rendered.length} chars to #{output_path}"
475
281
  File.write(output_path, rendered)
282
+ end
476
283
 
477
- 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
478
308
  end
479
309
 
480
- def compile_phlex_pages(dist_dir)
481
- # Phlex compilation will be implemented when phlex-rails is available
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
335
+ end
336
+
337
+
338
+ def compile_erb_pages(dist_dir)
482
339
  pages_dir = @root.join("app", "views", "pages")
483
340
  return unless pages_dir.exist?
484
341
 
485
- puts "Phlex compilation not yet implemented"
486
- # 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}"
487
368
  end
488
369
 
370
+
489
371
  def copy_static_files(dist_dir)
490
372
  public_dir = @root.join("public")
491
373
  return unless public_dir.exist? && public_dir.directory?
492
374
 
493
- puts "Copying public files..."
375
+ puts "Copying static files from public/..."
494
376
  # Copy all files and subdirectories from public to dist
495
377
  Dir.glob(public_dir.join("*")).each do |item|
496
378
  FileUtils.cp_r(item, dist_dir, preserve: true)
@@ -510,26 +392,7 @@ module StaticSiteBuilder
510
392
  end
511
393
 
512
394
  def default_layout
513
- live_reload_script = if ENV["LIVE_RELOAD"] == "true"
514
- ws_port = ENV["WS_PORT"] || 3001
515
- <<~HTML
516
- <script>
517
- (function() {
518
- function connect() {
519
- var ws = new WebSocket('ws://localhost:#{ws_port}');
520
- ws.onmessage = function(e) {
521
- if (e.data === 'reload') window.location.reload();
522
- };
523
- ws.onclose = function() { setTimeout(connect, 1000); };
524
- ws.onerror = function() {};
525
- }
526
- connect();
527
- })();
528
- </script>
529
- HTML
530
- else
531
- ""
532
- end
395
+ live_reload_script_content = live_reload_script
533
396
 
534
397
  <<~HTML
535
398
  <!DOCTYPE html>
@@ -537,86 +400,21 @@ module StaticSiteBuilder
537
400
  <head>
538
401
  <meta charset="UTF-8">
539
402
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
540
- <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 %>
541
406
  <link rel="stylesheet" href="/assets/stylesheets/application.css">
542
407
  </head>
543
408
  <body>
544
- <%= page_content %>
545
- <% if defined?(@importmap_json) && @importmap_json %>
546
- <script type="importmap"><%= @importmap_json %></script>
547
- <% end %>
548
- <% if @js_modules && !@js_modules.empty? %>
549
- <% @js_modules.each do |module_name| %>
550
- <script type="module">import "<%= module_name %>";</script>
551
- <% end %>
552
- <% else %>
553
- <script type="module">import "application";</script>
409
+ <%= yield %>
410
+ <% if content_for?(:javascript) %>
411
+ <%= yield(:javascript) %>
554
412
  <% end %>
555
- #{live_reload_script}
413
+ #{live_reload_script_content}
556
414
  </body>
557
415
  </html>
558
416
  HTML
559
417
  end
560
418
 
561
- # Simple asset resolver for importmap
562
- class AssetResolver
563
- def initialize(root, dist_dir)
564
- @root = root
565
- @dist_dir = dist_dir
566
- end
567
-
568
- def javascript_path(path)
569
- "/assets/javascripts/#{path}"
570
- end
571
-
572
- def asset_path(path)
573
- "/assets/#{path}"
574
- end
575
- end
576
-
577
- # Simple importmap implementation if importmap-rails is not available
578
- class SimpleImportMap
579
- def initialize(root: nil)
580
- @pins = {}
581
- @root = root || Pathname.new(Dir.pwd)
582
- end
583
-
584
- def pin(name, to: nil, preload: true)
585
- # If 'to' is provided, use it as-is. Otherwise, default to name.js
586
- to_path = to || "#{name}.js"
587
- @pins[name] = { to: to_path, preload: preload }
588
- end
589
-
590
- def pin_all_from(directory, under: nil)
591
- dir_path = Pathname.new(directory)
592
- # Resolve to absolute path
593
- full_dir_path = dir_path.absolute? ? dir_path : @root.join(dir_path)
594
- return unless full_dir_path.exist?
595
-
596
- # Calculate base path from app/javascript for proper resolution
597
- app_js_path = @root.join("app", "javascript")
598
-
599
- Dir.glob(full_dir_path.join("**", "*.js")).each do |file|
600
- relative_path = Pathname.new(file).relative_path_from(full_dir_path)
601
- name = relative_path.to_s.gsub(/\.js$/, "")
602
- name = name.gsub(/_controller$/, "")
603
- # If under is specified, prepend it to the name
604
- name = under ? "#{under}/#{name}" : name
605
- # Store full path from app/javascript, preserving directory structure
606
- to_path = Pathname.new(file).relative_path_from(app_js_path).to_s
607
- pin(name, to: to_path, preload: true)
608
- end
609
- end
610
-
611
- def to_json(resolver:)
612
- imports = {}
613
- @pins.each do |name, config|
614
- path = resolver.javascript_path(config[:to])
615
- imports[name] = path
616
- end
617
-
618
- JSON.generate({ imports: imports })
619
- end
620
- end
621
419
  end
622
420
  end