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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -64
- data/README.md +92 -286
- data/bin/generate +3 -40
- data/exe/static-site-builder +1 -40
- data/lib/generator.rb +627 -751
- data/lib/static_site_builder/builder.rb +265 -467
- data/lib/static_site_builder/dev_server.rb +119 -0
- data/lib/static_site_builder/version.rb +1 -1
- data/lib/static_site_builder/websocket_server.rb +97 -21
- data/lib/static_site_builder.rb +17 -4
- metadata +31 -17
- data/ARCHITECTURE.md +0 -61
|
@@ -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 "
|
|
14
|
-
rescue
|
|
15
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
62
|
+
# Builds the complete static site.
|
|
47
63
|
#
|
|
48
|
-
# Compiles
|
|
49
|
-
#
|
|
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(
|
|
73
|
+
dist_dir = @root.join(DIST_DIR)
|
|
56
74
|
|
|
57
|
-
#
|
|
58
|
-
# In development, update files in place to prevent 404s during
|
|
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
|
|
88
|
+
# Copy JavaScript and CSS assets to dist
|
|
73
89
|
copy_assets(dist_dir)
|
|
74
90
|
|
|
75
|
-
#
|
|
76
|
-
|
|
91
|
+
# Compile ERB templates to static HTML pages
|
|
92
|
+
compile_erb_pages(dist_dir)
|
|
77
93
|
|
|
78
|
-
#
|
|
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
|
|
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.
|
|
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
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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[
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
'/'
|
|
301
|
-
else
|
|
302
|
-
"/#{page_name.gsub(/\.html$/, '')}"
|
|
303
|
-
end
|
|
142
|
+
[layout_content, layout_file]
|
|
143
|
+
end
|
|
304
144
|
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
#
|
|
329
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
#
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
|
381
|
-
#
|
|
382
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
|
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
|
-
|
|
417
|
-
|
|
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
|
-
|
|
442
|
-
|
|
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
|
-
|
|
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: [
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
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
|
|
481
|
-
|
|
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
|
-
|
|
486
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<%=
|
|
545
|
-
<% if
|
|
546
|
-
|
|
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
|
-
#{
|
|
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
|