static-site-builder 0.0.1

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.
@@ -0,0 +1,456 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "fileutils"
5
+ require "json"
6
+ require "pathname"
7
+ require "digest"
8
+
9
+ begin
10
+ require "importmap-rails"
11
+ rescue LoadError
12
+ # importmap-rails is optional
13
+ end
14
+
15
+ module StaticSiteBuilder
16
+ class Builder
17
+ def initialize(root: Dir.pwd, template_engine: "erb", js_bundler: "importmap", importmap_config: nil, annotate_template_file_names: nil)
18
+ @root = Pathname.new(root)
19
+ @template_engine = template_engine
20
+ @js_bundler = js_bundler
21
+ @importmap_config_path = if importmap_config
22
+ Pathname.new(importmap_config)
23
+ else
24
+ @root.join("config", "importmap.rb")
25
+ end
26
+
27
+ # Auto-enable annotations in development (when LIVE_RELOAD is enabled)
28
+ @annotate_template_file_names = if annotate_template_file_names.nil?
29
+ ENV["LIVE_RELOAD"] == "true" || ENV["RAILS_ENV"] == "development"
30
+ else
31
+ annotate_template_file_names
32
+ end
33
+
34
+ @importmap = if defined?(Importmap::Map)
35
+ Importmap::Map.new
36
+ else
37
+ SimpleImportMap.new(root: @root)
38
+ end
39
+
40
+ load_importmap_config if @js_bundler == "importmap"
41
+ end
42
+
43
+ # Build the static site
44
+ #
45
+ # Compiles all templates, copies assets, generates importmap (if needed),
46
+ # and outputs everything to the dist/ directory.
47
+ #
48
+ # @return [void]
49
+ def build
50
+ puts "Building static site..."
51
+
52
+ # Clean dist directory
53
+ dist_dir = @root.join("dist")
54
+ FileUtils.rm_rf(dist_dir) if dist_dir.exist?
55
+ FileUtils.mkdir_p(dist_dir)
56
+
57
+ # Copy assets
58
+ copy_assets(dist_dir)
59
+
60
+ # Generate importmap JSON if using importmap
61
+ generate_importmap(dist_dir) if @js_bundler == "importmap"
62
+
63
+ # Compile pages based on template engine
64
+ case @template_engine
65
+ when "erb"
66
+ compile_erb_pages(dist_dir)
67
+ when "phlex"
68
+ compile_phlex_pages(dist_dir)
69
+ end
70
+
71
+ # Copy static files
72
+ copy_static_files(dist_dir)
73
+
74
+ # Notify WebSocket server (always update, even if file doesn't exist yet)
75
+ reload_file = @root.join(".reload")
76
+ File.write(reload_file, Time.now.to_f.to_s)
77
+
78
+ puts "\n✓ Build complete! Output in #{dist_dir}"
79
+ end
80
+
81
+ private
82
+
83
+ def load_importmap_config
84
+ return unless @importmap_config_path.exist?
85
+
86
+ config_content = File.read(@importmap_config_path)
87
+ # Replace relative paths with absolute paths
88
+ config_content = config_content.gsub(/"app\//, %("#{@root.join("app").to_s}/))
89
+ config_content = config_content.gsub(/"vendor\//, %("#{@root.join("vendor").to_s}/))
90
+
91
+ # Replace File.expand_path calls with actual paths
92
+ config_content = config_content.gsub(/File\.expand_path\(["'](.*?)["'], __dir__\)/) do |match|
93
+ path = $1
94
+ @root.join("config", path).expand_path.to_s.inspect
95
+ end
96
+
97
+ @importmap.instance_eval(config_content, @importmap_config_path.to_s)
98
+ end
99
+
100
+ def copy_assets(dist_dir)
101
+ puts "Copying assets..."
102
+
103
+ # Copy JavaScript files
104
+ js_dir = @root.join("app", "javascript")
105
+ if js_dir.exist? && js_dir.directory?
106
+ dist_js = dist_dir.join("assets", "javascripts")
107
+ FileUtils.mkdir_p(dist_js)
108
+ # Copy all files and subdirectories from js_dir to dist_js
109
+ Dir.glob(js_dir.join("*")).each do |item|
110
+ FileUtils.cp_r(item, dist_js, preserve: true)
111
+ end
112
+ end
113
+
114
+ # Copy vendor JavaScript files directly from node_modules to dist (for importmap)
115
+ if @js_bundler == "importmap"
116
+ copy_vendor_files_from_node_modules(dist_dir)
117
+ end
118
+
119
+ # Copy CSS
120
+ css_dir = @root.join("app", "assets", "stylesheets")
121
+ if css_dir.exist? && css_dir.directory?
122
+ dist_css = dist_dir.join("assets", "stylesheets")
123
+ FileUtils.mkdir_p(dist_css)
124
+ Dir.glob(css_dir.join("*")).each do |item|
125
+ FileUtils.cp_r(item, dist_css, preserve: true)
126
+ end
127
+ end
128
+ end
129
+
130
+ def copy_vendor_files_from_node_modules(dist_dir)
131
+ return unless @importmap_config_path && @importmap_config_path.exist?
132
+
133
+ config_content = File.read(@importmap_config_path.to_s)
134
+ dist_js = dist_dir.join("assets", "javascripts")
135
+ FileUtils.mkdir_p(dist_js)
136
+
137
+ # Extract vendor file references from importmap config
138
+ # Look for patterns like: pin "@hotwired/stimulus", to: "stimulus.min.js"
139
+ # Only process pins that have 'to:' specified (vendor files), not local app files
140
+ config_content.scan(/pin\s+["']([^"']+)["'],\s*to:\s*["']([^"']+)["']/) do |package_name, file_name|
141
+ # Skip if this is a local app file
142
+ next if file_name.start_with?("app/") || file_name.start_with?("./app/")
143
+
144
+ # Only try to copy npm packages (scoped packages like @hotwired/stimulus or known packages)
145
+ # Skip simple names like "application" that are local files
146
+ next unless package_name.include?("/") || package_name.start_with?("@")
147
+
148
+ dest_file = dist_js.join(file_name)
149
+ next if dest_file.exist?
150
+
151
+ # Try to copy directly from node_modules to dist
152
+ if copy_vendor_file_from_node_modules(package_name, file_name, dest_file)
153
+ puts " ✓ Copied #{file_name} from #{package_name}"
154
+ else
155
+ puts " ⚠️ Warning: Could not find #{file_name} for #{package_name} in node_modules"
156
+ puts " Ensure 'npm install' has been run and the package is installed."
157
+ end
158
+ end
159
+ end
160
+
161
+ def copy_vendor_file_from_node_modules(package_name, file_name, dest_file)
162
+ node_modules = @root.join("node_modules")
163
+ return false unless node_modules.exist?
164
+
165
+ package_dir = node_modules.join(*package_name.split("/"))
166
+ return false unless package_dir.exist?
167
+
168
+ # Try common source paths for the package
169
+ # Extract base name from file_name (e.g., "stimulus.min.js" -> "stimulus")
170
+ base_name = file_name.gsub(/\.(min\.)?js$/, "")
171
+ source_paths = [
172
+ "dist/#{base_name}.js",
173
+ "dist/#{base_name}.min.js",
174
+ "dist/index.js",
175
+ "#{base_name}.js",
176
+ "index.js",
177
+ "dist/#{file_name}",
178
+ file_name
179
+ ]
180
+
181
+ source_paths.each do |source_path|
182
+ source_file = package_dir.join(*source_path.split("/"))
183
+ if source_file.exist? && source_file.file?
184
+ FileUtils.cp(source_file, dest_file)
185
+ return true
186
+ end
187
+ end
188
+
189
+ false
190
+ end
191
+
192
+ def generate_importmap(dist_dir)
193
+ return unless defined?(@importmap) && @importmap
194
+
195
+ puts "Generating importmap..."
196
+
197
+ # Create a simple resolver for asset paths
198
+ resolver = AssetResolver.new(@root, dist_dir)
199
+
200
+ importmap_json = @importmap.to_json(resolver: resolver)
201
+
202
+ FileUtils.mkdir_p(dist_dir.join("assets"))
203
+ File.write(dist_dir.join("assets", "importmap.json"), JSON.pretty_generate(JSON.parse(importmap_json)))
204
+ end
205
+
206
+ def compile_erb_pages(dist_dir)
207
+ pages_dir = @root.join("app", "views", "pages")
208
+ return unless pages_dir.exist?
209
+
210
+ # Generate importmap JSON once for all pages
211
+ resolver = AssetResolver.new(@root, dist_dir)
212
+ importmap_json_str = @importmap.to_json(resolver: resolver) if defined?(@importmap) && @importmap
213
+
214
+ # Find all ERB files, including nested directories
215
+ Dir.glob(pages_dir.join("**", "*.html.erb")).each do |erb_file|
216
+ relative_path = Pathname.new(erb_file).relative_path_from(pages_dir)
217
+ page_name = relative_path.to_s.gsub(/\.html\.erb$/, ".html")
218
+
219
+ compile_erb_page(erb_file, page_name, dist_dir, importmap_json_str)
220
+ end
221
+ end
222
+
223
+ def compile_erb_page(erb_file, page_name, dist_dir, importmap_json_str)
224
+ puts "Compiling #{page_name}..."
225
+
226
+ # Read and parse frontmatter
227
+ content = File.read(erb_file)
228
+ frontmatter = {}
229
+ layout = "application"
230
+ js_modules = []
231
+
232
+ if content.match?(/^---\s*\n/)
233
+ match = content.match(/^---\s*\n(.*?)\n---\s*\n/m)
234
+ if match
235
+ frontmatter_text = match[1]
236
+ frontmatter_text.each_line do |line|
237
+ key, value = line.split(":", 2).map(&:strip)
238
+ case key
239
+ when "layout"
240
+ layout = value
241
+ when "js"
242
+ js_modules = value.split(",").map(&:strip)
243
+ else
244
+ frontmatter[key] = value
245
+ end
246
+ end
247
+ content = content.sub(/^---\s*\n.*?\n---\s*\n/m, "")
248
+ end
249
+ end
250
+
251
+ # Load layout - try .html.erb first, then .html
252
+ layout_file = @root.join("app", "views", "layouts", "#{layout}.html.erb")
253
+ layout_file = @root.join("app", "views", "layouts", "#{layout}.html") unless layout_file.exist?
254
+ layout_content = layout_file.exist? ? File.read(layout_file) : default_layout
255
+
256
+ # Inject live reload script if enabled and using custom layout
257
+ if ENV["LIVE_RELOAD"] == "true" && layout_file.exist?
258
+ # Inject live reload into custom layouts too
259
+ unless layout_content.include?("live reload") || layout_content.include?("LIVE_RELOAD")
260
+ ws_port = ENV["WS_PORT"] || 3001
261
+ live_reload_script = <<~HTML
262
+ <script>
263
+ (function() {
264
+ function connect() {
265
+ var ws = new WebSocket('ws://localhost:#{ws_port}');
266
+ ws.onmessage = function(e) {
267
+ if (e.data === 'reload') window.location.reload();
268
+ };
269
+ ws.onclose = function() { setTimeout(connect, 1000); };
270
+ ws.onerror = function() {};
271
+ }
272
+ connect();
273
+ })();
274
+ </script>
275
+ HTML
276
+ layout_content = layout_content.gsub(/<\/body>/, "#{live_reload_script}</body>")
277
+ end
278
+ end
279
+
280
+ # Create binding with variables for ERB
281
+ page_binding = binding
282
+ page_binding.local_variable_set(:frontmatter, frontmatter)
283
+ page_binding.local_variable_set(:js_modules, js_modules)
284
+ page_binding.local_variable_set(:importmap_json, importmap_json_str) if importmap_json_str
285
+
286
+ # Render ERB content
287
+ page_content = ERB.new(content).result(page_binding)
288
+
289
+ # Annotate page content if enabled
290
+ if @annotate_template_file_names
291
+ relative_template_path = Pathname.new(erb_file).relative_path_from(@root)
292
+ page_content = annotate_template(page_content, relative_template_path.to_s)
293
+ end
294
+
295
+ page_binding.local_variable_set(:page_content, page_content)
296
+
297
+ # Render layout with page content
298
+ layout_erb = ERB.new(layout_content)
299
+ rendered = layout_erb.result(page_binding)
300
+
301
+ # Annotate layout if enabled
302
+ if @annotate_template_file_names && layout_file.exist?
303
+ relative_layout_path = Pathname.new(layout_file).relative_path_from(@root)
304
+ rendered = annotate_template(rendered, relative_layout_path.to_s)
305
+ end
306
+
307
+ output_path = dist_dir.join(page_name)
308
+ FileUtils.mkdir_p(output_path.dirname)
309
+ File.write(output_path, rendered)
310
+
311
+ puts " ✓ Created #{page_name}"
312
+ end
313
+
314
+ def compile_phlex_pages(dist_dir)
315
+ # Phlex compilation will be implemented when phlex-rails is available
316
+ pages_dir = @root.join("app", "views", "pages")
317
+ return unless pages_dir.exist?
318
+
319
+ puts "Phlex compilation not yet implemented"
320
+ # TODO: Implement Phlex page compilation
321
+ end
322
+
323
+ def copy_static_files(dist_dir)
324
+ public_dir = @root.join("public")
325
+ return unless public_dir.exist? && public_dir.directory?
326
+
327
+ puts "Copying public files..."
328
+ # Copy all files and subdirectories from public to dist
329
+ Dir.glob(public_dir.join("*")).each do |item|
330
+ FileUtils.cp_r(item, dist_dir, preserve: true)
331
+ end
332
+ end
333
+
334
+ def annotate_template(content, template_path)
335
+ # Remove any existing annotations to avoid duplicates
336
+ content = content.gsub(/<!-- BEGIN .*? -->\n?/, "")
337
+ content = content.gsub(/\n?<!-- END .*? -->/, "")
338
+
339
+ begin_comment = "<!-- BEGIN #{template_path} -->"
340
+ end_comment = "<!-- END #{template_path} -->"
341
+
342
+ # Wrap content with template path annotations
343
+ "#{begin_comment}\n#{content}\n#{end_comment}"
344
+ end
345
+
346
+ def default_layout
347
+ live_reload_script = if ENV["LIVE_RELOAD"] == "true"
348
+ ws_port = ENV["WS_PORT"] || 3001
349
+ <<~HTML
350
+ <script>
351
+ (function() {
352
+ function connect() {
353
+ var ws = new WebSocket('ws://localhost:#{ws_port}');
354
+ ws.onmessage = function(e) {
355
+ if (e.data === 'reload') window.location.reload();
356
+ };
357
+ ws.onclose = function() { setTimeout(connect, 1000); };
358
+ ws.onerror = function() {};
359
+ }
360
+ connect();
361
+ })();
362
+ </script>
363
+ HTML
364
+ else
365
+ ""
366
+ end
367
+
368
+ <<~HTML
369
+ <!DOCTYPE html>
370
+ <html lang="en">
371
+ <head>
372
+ <meta charset="UTF-8">
373
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
374
+ <title><%= frontmatter['title'] || 'Site' %></title>
375
+ <link rel="stylesheet" href="/assets/stylesheets/application.css">
376
+ </head>
377
+ <body>
378
+ <%= page_content %>
379
+ <% if defined?(importmap_json) && importmap_json %>
380
+ <script type="importmap"><%= importmap_json %></script>
381
+ <% end %>
382
+ <% if js_modules && !js_modules.empty? %>
383
+ <% js_modules.each do |module_name| %>
384
+ <script type="module">import "<%= module_name %>";</script>
385
+ <% end %>
386
+ <% else %>
387
+ <script type="module">import "application";</script>
388
+ <% end %>
389
+ #{live_reload_script}
390
+ </body>
391
+ </html>
392
+ HTML
393
+ end
394
+
395
+ # Simple asset resolver for importmap
396
+ class AssetResolver
397
+ def initialize(root, dist_dir)
398
+ @root = root
399
+ @dist_dir = dist_dir
400
+ end
401
+
402
+ def javascript_path(path)
403
+ "/assets/javascripts/#{path}"
404
+ end
405
+
406
+ def asset_path(path)
407
+ "/assets/#{path}"
408
+ end
409
+ end
410
+
411
+ # Simple importmap implementation if importmap-rails is not available
412
+ class SimpleImportMap
413
+ def initialize(root: nil)
414
+ @pins = {}
415
+ @root = root || Pathname.new(Dir.pwd)
416
+ end
417
+
418
+ def pin(name, to: nil, preload: true)
419
+ # If 'to' is provided, use it as-is. Otherwise, default to name.js
420
+ to_path = to || "#{name}.js"
421
+ @pins[name] = { to: to_path, preload: preload }
422
+ end
423
+
424
+ def pin_all_from(directory, under: nil)
425
+ dir_path = Pathname.new(directory)
426
+ # Resolve to absolute path
427
+ full_dir_path = dir_path.absolute? ? dir_path : @root.join(dir_path)
428
+ return unless full_dir_path.exist?
429
+
430
+ # Calculate base path from app/javascript for proper resolution
431
+ app_js_path = @root.join("app", "javascript")
432
+
433
+ Dir.glob(full_dir_path.join("**", "*.js")).each do |file|
434
+ relative_path = Pathname.new(file).relative_path_from(full_dir_path)
435
+ name = relative_path.to_s.gsub(/\.js$/, "")
436
+ name = name.gsub(/_controller$/, "")
437
+ # If under is specified, prepend it to the name
438
+ name = under ? "#{under}/#{name}" : name
439
+ # Store full path from app/javascript, preserving directory structure
440
+ to_path = Pathname.new(file).relative_path_from(app_js_path).to_s
441
+ pin(name, to: to_path, preload: true)
442
+ end
443
+ end
444
+
445
+ def to_json(resolver:)
446
+ imports = {}
447
+ @pins.each do |name, config|
448
+ path = resolver.javascript_path(config[:to])
449
+ imports[name] = path
450
+ end
451
+
452
+ JSON.generate({ imports: imports })
453
+ end
454
+ end
455
+ end
456
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StaticSiteBuilder
4
+ VERSION = "0.0.1"
5
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "base64" # Required for Ruby 3.4+ (removed from default gems)
5
+ require "digest/sha1"
6
+ require "pathname"
7
+
8
+ module StaticSiteBuilder
9
+ # Simple WebSocket server for live reload
10
+ class WebSocketServer
11
+ def initialize(port: 3001, reload_file: nil)
12
+ @port = port
13
+ @reload_file = reload_file || Pathname.new(Dir.pwd).join(".reload")
14
+ @clients = []
15
+ @running = false
16
+ end
17
+
18
+ def start
19
+ @running = true
20
+ @server = TCPServer.new("127.0.0.1", @port)
21
+
22
+ # Initialize reload file
23
+ File.write(@reload_file, Time.now.to_f.to_s) unless @reload_file.exist?
24
+ @last_mtime = @reload_file.mtime
25
+
26
+ # Accept connections in background
27
+ @accept_thread = Thread.new do
28
+ while @running
29
+ begin
30
+ client = @server.accept
31
+ Thread.new { handle_client(client) }
32
+ rescue => e
33
+ sleep 0.1
34
+ break unless @running
35
+ end
36
+ end
37
+ end
38
+
39
+ # Watch for rebuilds
40
+ @watch_thread = Thread.new do
41
+ while @running
42
+ begin
43
+ sleep 0.3
44
+ if @reload_file.exist? && @reload_file.mtime > @last_mtime
45
+ @last_mtime = @reload_file.mtime
46
+ broadcast("reload")
47
+ end
48
+ rescue => e
49
+ sleep 0.3
50
+ break unless @running
51
+ end
52
+ end
53
+ end
54
+ end
55
+
56
+ def stop
57
+ @running = false
58
+ @clients.each { |c| c.close rescue nil }
59
+ @server.close rescue nil
60
+ @accept_thread.kill rescue nil
61
+ @watch_thread.kill rescue nil
62
+ end
63
+
64
+ private
65
+
66
+ def handle_client(client)
67
+ begin
68
+ # Read handshake
69
+ request = client.gets
70
+ headers = {}
71
+ while (line = client.gets.chomp) != ""
72
+ key, value = line.split(": ", 2)
73
+ headers[key] = value if key && value
74
+ end
75
+
76
+ if headers["Upgrade"]&.downcase == "websocket"
77
+ key = headers["Sec-WebSocket-Key"]
78
+ accept = Base64.strict_encode64(Digest::SHA1.digest(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))
79
+
80
+ client.print "HTTP/1.1 101 Switching Protocols\r\n"
81
+ client.print "Upgrade: websocket\r\n"
82
+ client.print "Connection: Upgrade\r\n"
83
+ client.print "Sec-WebSocket-Accept: #{accept}\r\n\r\n"
84
+
85
+ @clients << client
86
+
87
+ # Keep connection alive - just wait
88
+ loop do
89
+ sleep 1
90
+ break unless @running
91
+ break if client.closed?
92
+ end
93
+ else
94
+ client.close
95
+ end
96
+ rescue => e
97
+ @clients.delete(client)
98
+ client.close rescue nil
99
+ end
100
+ end
101
+
102
+ def broadcast(message)
103
+ frame = create_frame(message)
104
+ @clients.dup.each do |client|
105
+ begin
106
+ client.write(frame) unless client.closed?
107
+ rescue => e
108
+ @clients.delete(client)
109
+ end
110
+ end
111
+ end
112
+
113
+ def create_frame(message)
114
+ data = message.dup.force_encoding("BINARY")
115
+ length = data.bytesize
116
+
117
+ if length < 126
118
+ [0x81, length].pack("C*") + data
119
+ elsif length < 65536
120
+ [0x81, 126, length].pack("CCn") + data
121
+ else
122
+ [0x81, 127, length >> 32, length & 0xFFFFFFFF].pack("CCNN") + data
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "static_site_builder/version"
4
+ require_relative "static_site_builder/builder"
5
+ require_relative "static_site_builder/websocket_server"
6
+ require_relative "generator"
7
+
8
+ module StaticSiteBuilder
9
+ # Main module for the static site builder gem
10
+ end