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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -85
- data/README.md +87 -309
- data/bin/generate +3 -40
- data/exe/static-site-builder +1 -40
- data/lib/generator.rb +613 -822
- data/lib/static_site_builder/builder.rb +266 -461
- 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 +28 -14
- 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,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
|
-
#
|
|
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
|
|
|
340
|
-
#
|
|
341
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
#
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
|
399
|
-
#
|
|
400
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
|
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
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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: [
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
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
|
-
|
|
474
|
-
|
|
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
|
-
|
|
479
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<%=
|
|
538
|
-
<% if
|
|
539
|
-
|
|
409
|
+
<%= yield %>
|
|
410
|
+
<% if content_for?(:javascript) %>
|
|
411
|
+
<%= yield(:javascript) %>
|
|
540
412
|
<% end %>
|
|
541
|
-
|
|
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
|