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.
data/lib/generator.rb CHANGED
@@ -6,69 +6,43 @@ require "erb"
6
6
  require "json"
7
7
 
8
8
  module StaticSiteBuilder
9
- # Generator for creating new static site projects
9
+ # Generates new static site projects.
10
10
  #
11
- # @example Generate a site with default options
12
- # generator = StaticSiteBuilder::Generator.new("my-site")
13
- # generator.generate
11
+ # Creates a complete project structure including Gemfile, build
12
+ # configuration files, example pages, and development server setup.
14
13
  #
15
- # @example Generate a site with custom stack
16
- # generator = StaticSiteBuilder::Generator.new("my-site", {
17
- # template_engine: "phlex",
18
- # js_bundler: "esbuild",
19
- # css_framework: "shadcn",
20
- # js_framework: "react"
21
- # })
14
+ # @example Generate a site
15
+ # generator = StaticSiteBuilder::Generator.new("my-site")
22
16
  # generator.generate
23
17
  class Generator
24
- # Available template engines
25
- TEMPLATE_ENGINES = %w[erb phlex].freeze
26
-
27
- # Available JavaScript bundlers
28
- JS_BUNDLERS = %w[importmap esbuild webpack vite none].freeze
29
-
30
- # Available CSS frameworks
31
- CSS_FRAMEWORKS = %w[tailwindcss shadcn plain].freeze
32
18
 
33
- # Available JavaScript frameworks
34
- JS_FRAMEWORKS = %w[stimulus react vue alpine vanilla].freeze
19
+ # Reference StaticSiteBuilder constants for consistency
20
+ DEFAULT_PORT = StaticSiteBuilder::DEFAULT_PORT
21
+ DEFAULT_WS_PORT = StaticSiteBuilder::DEFAULT_WS_PORT
35
22
 
36
- # Initialize a new generator
23
+ # Initializes a new generator instance.
37
24
  #
38
- # @param app_name [String] Name of the application/site to generate
39
- # @param options [Hash] Configuration options
40
- # @option options [String] :template_engine ("erb") Template engine to use (erb or phlex)
41
- # @option options [String] :js_bundler ("importmap") JavaScript bundler (importmap, esbuild, webpack, vite, none)
42
- # @option options [String] :css_framework ("tailwindcss") CSS framework (tailwindcss, shadcn, plain)
43
- # @option options [String] :js_framework ("stimulus") JavaScript framework (stimulus, react, vue, alpine, vanilla)
25
+ # @param app_name [String] The name of the site to generate (will be used as directory name)
44
26
  def initialize(app_name, options = {})
45
27
  @app_name = app_name
46
28
  @app_path = Pathname.new(app_name)
47
- @options = {
48
- template_engine: options[:template_engine] || "erb",
49
- js_bundler: options[:js_bundler] || "importmap",
50
- css_framework: options[:css_framework] || "tailwindcss",
51
- js_framework: options[:js_framework] || "stimulus"
52
- }
29
+ @options = {}
53
30
  end
54
31
 
55
- # Generate the static site project
32
+ # Generates the complete static site project.
56
33
  #
57
- # Creates all necessary files and directory structure for the project.
58
- # Outputs instructions for next steps after generation.
34
+ # Creates the directory structure, configuration files, example pages, build
35
+ # scripts, and all necessary dependencies. Provides next steps after successful generation.
59
36
  #
60
37
  # @return [void]
61
38
  def generate
62
39
  puts "Generating static site: #{@app_name}"
63
- puts "Stack: #{@options[:template_engine]} + #{@options[:js_bundler]} + #{@options[:css_framework]} + #{@options[:js_framework]}"
64
40
 
65
41
  create_directory_structure
66
42
  create_gemfile
67
- create_package_json
68
43
  create_config_files
69
44
  create_app_structure
70
45
  create_build_files
71
- create_page_helpers
72
46
  create_sitemap_config
73
47
  create_example_pages
74
48
  create_readme
@@ -78,10 +52,9 @@ module StaticSiteBuilder
78
52
  puts "\nNext steps:"
79
53
  puts " cd #{@app_name}"
80
54
  puts " bundle install"
81
- puts " npm install" if needs_npm?
82
- puts " rake dev:server # Start development server with auto-rebuild"
55
+ puts " bundle exec rake dev:server # Start development server with live reload"
83
56
  puts " # or"
84
- puts " rake build:all # Build for production"
57
+ puts " bundle exec rake build:all # Build for deployment"
85
58
  end
86
59
 
87
60
  private
@@ -90,6 +63,7 @@ module StaticSiteBuilder
90
63
  dirs = [
91
64
  "app/views/layouts",
92
65
  "app/views/pages",
66
+ "app/helpers",
93
67
  "app/javascript",
94
68
  "app/assets/stylesheets",
95
69
  "config",
@@ -104,142 +78,35 @@ module StaticSiteBuilder
104
78
 
105
79
  def create_gemfile
106
80
  gems = [
107
- "rake",
108
- "static-site-builder",
109
- "webrick", # Required for dev server (removed from stdlib in Ruby 3.0+)
110
- "sitemap_generator" # For generating sitemaps from PageHelpers::PAGES
81
+ 'rake',
82
+ 'actionview',
83
+ 'base64', # Required for Ruby 3.4+ (removed from default gems)
84
+ 'webrick', # Required for dev server (removed from stdlib in Ruby 3.0+)
85
+ 'sitemap_generator' # For generating sitemaps from actual pages
111
86
  ]
112
- gems << "importmap-rails" if @options[:js_bundler] == "importmap"
113
- gems << "phlex-rails" if @options[:template_engine] == "phlex"
114
- if @options[:edit_rails]
115
- gems << "rails"
116
- gems << "edit_rails"
117
- end
118
87
 
119
88
  content = <<~RUBY
120
89
  # frozen_string_literal: true
121
90
 
122
- source "https://rubygems.org"
91
+ source 'https://rubygems.org'
123
92
 
124
- #{gems.map { |g| %(gem "#{g}") }.join("\n")}
93
+ #{gems.map { |g| %(gem '#{g}') }.join("\n")}
125
94
  RUBY
126
95
 
127
- write_file("Gemfile", content)
96
+ write_file('Gemfile', content)
128
97
  end
129
98
 
130
- def create_package_json
131
- return unless needs_npm?
132
-
133
- deps = {}
134
- dev_deps = {}
135
-
136
- case @options[:js_bundler]
137
- when "esbuild"
138
- dev_deps["esbuild"] = "^0.19.0"
139
- when "webpack"
140
- dev_deps["webpack"] = "^5.0.0"
141
- dev_deps["webpack-cli"] = "^5.0.0"
142
- when "vite"
143
- dev_deps["vite"] = "^5.0.0"
144
- dev_deps["vite-plugin-ruby"] = "^3.0.0"
145
- end
146
-
147
- case @options[:css_framework]
148
- when "tailwindcss", "shadcn"
149
- dev_deps["tailwindcss"] = "^3.4.0"
150
- dev_deps["autoprefixer"] = "^10.4.0"
151
- dev_deps["postcss"] = "^8.4.0"
152
- end
153
-
154
- case @options[:js_framework]
155
- when "react"
156
- deps["react"] = "^18.0.0"
157
- deps["react-dom"] = "^18.0.0"
158
- when "vue"
159
- deps["vue"] = "^3.0.0"
160
- when "alpine"
161
- deps["alpinejs"] = "^3.0.0"
162
- when "stimulus"
163
- deps["@hotwired/stimulus"] = "^3.2.0"
164
- end
165
-
166
- scripts = {}
167
- scripts["build"] = build_script
168
- scripts["build:css"] = css_build_script if needs_css_build?
169
- scripts["watch:css"] = css_watch_script if needs_css_build?
170
-
171
- content = {
172
- name: @app_name,
173
- version: "1.0.0",
174
- description: "Static site generated with static-site-generator",
175
- scripts: scripts,
176
- dependencies: deps,
177
- devDependencies: dev_deps
178
- }
179
-
180
- write_file("package.json", JSON.pretty_generate(content))
181
- end
182
99
 
183
100
  def create_config_files
184
- create_importmap_config if @options[:js_bundler] == "importmap"
185
- create_tailwind_config if @options[:css_framework] == "tailwindcss" || @options[:css_framework] == "shadcn"
186
- create_esbuild_config if @options[:js_bundler] == "esbuild"
187
- create_webpack_config if @options[:js_bundler] == "webpack"
188
- create_vite_config if @options[:js_bundler] == "vite"
189
- create_rails_config if @options[:edit_rails]
190
101
  end
191
102
 
192
- def create_page_helpers
193
- content = <<~RUBY
194
- # frozen_string_literal: true
195
-
196
- module PageHelpers
197
- # Page metadata configuration
198
- # The builder automatically loads this and sets @title, @description, @url, and @image
199
- # instance variables for use in your templates.
200
- # This metadata is also used by sitemap_generator for generating sitemaps.
201
- PAGES = {
202
- '/' => {
203
- title: 'Home',
204
- description: 'Welcome to my site',
205
- url: 'https://example.com',
206
- image: 'https://example.com/image.jpg',
207
- priority: 1.0,
208
- changefreq: 'weekly'
209
- }
210
- }.freeze
211
-
212
- def page_title(path = nil)
213
- path ||= @current_page
214
- PAGES[path]&.fetch(:title) || 'Site'
215
- end
216
-
217
- def page_description(path = nil)
218
- path ||= @current_page
219
- PAGES[path]&.fetch(:description) || 'A static site'
220
- end
221
-
222
- def page_url(path = nil)
223
- path ||= @current_page
224
- PAGES[path]&.fetch(:url) || 'https://example.com'
225
- end
226
-
227
- def page_image(path = nil)
228
- path ||= @current_page
229
- PAGES[path]&.fetch(:image) || 'https://example.com/image.jpg'
230
- end
231
- end
232
- RUBY
233
-
234
- write_file("lib/page_helpers.rb", content)
235
- end
236
103
 
237
104
  def create_sitemap_config
238
105
  content = <<~RUBY
239
106
  # frozen_string_literal: true
240
107
 
241
108
  require 'sitemap_generator'
242
- require_relative '../lib/page_helpers'
109
+ require 'pathname'
243
110
 
244
111
  # Configure sitemap generator
245
112
  # Update default_host to your actual domain
@@ -247,222 +114,39 @@ module StaticSiteBuilder
247
114
  SitemapGenerator::Sitemap.sitemaps_path = 'sitemaps'
248
115
  SitemapGenerator::Sitemap.public_path = 'dist'
249
116
 
250
- # Generate sitemap from PageHelpers::PAGES
117
+ # Generate sitemap from templates in app/views (excluding layouts and partials)
251
118
  SitemapGenerator::Sitemap.create do
252
- PageHelpers::PAGES.each do |path, metadata|
253
- add path,
254
- lastmod: Time.now,
255
- priority: metadata[:priority] || 0.5,
256
- changefreq: metadata[:changefreq] || 'weekly'
257
- end
258
- end
259
- RUBY
260
-
261
- write_file("config/sitemap.rb", content)
262
- end
263
-
264
- def create_importmap_config
265
- content = <<~RUBY
266
- # frozen_string_literal: true
267
-
268
- pin "application", preload: true
269
- #{stimulus_pin if @options[:js_framework] == "stimulus"}
270
- RUBY
271
-
272
- write_file("config/importmap.rb", content)
273
- end
274
-
275
- def stimulus_pin
276
- <<~RUBY
277
- pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
278
- pin_all_from "app/javascript/controllers", under: "controllers"
279
- RUBY
280
- end
281
-
282
-
283
- # Detect which package manager is available
284
- def detect_package_manager
285
- # Check in order: yarn, pnpm, bun, npm
286
- return "yarn" if system("which yarn > /dev/null 2>&1")
287
- return "pnpm" if system("which pnpm > /dev/null 2>&1")
288
- return "bun" if system("which bun > /dev/null 2>&1")
289
- return "npm" if system("which npm > /dev/null 2>&1")
290
- nil
291
- end
292
-
293
- def create_tailwind_config
294
- content = <<~JS
295
- /** @type {import('tailwindcss').Config} */
296
- module.exports = {
297
- content: [
298
- "./app/views/**/*.{html,erb,phlex}",
299
- "./app/javascript/**/*.js",
300
- ],
301
- theme: {
302
- extend: {},
303
- },
304
- plugins: [],
305
- }
306
- JS
307
-
308
- write_file("tailwind.config.js", content)
309
-
310
- # Create PostCSS config
311
- postcss_content = <<~JS
312
- module.exports = {
313
- plugins: {
314
- tailwindcss: {},
315
- autoprefixer: {},
316
- },
317
- }
318
- JS
319
-
320
- write_file("postcss.config.js", postcss_content)
321
- end
322
-
323
- def create_esbuild_config
324
- content = <<~JS
325
- require('esbuild').build({
326
- entryPoints: ['app/javascript/application.js'],
327
- bundle: true,
328
- outdir: 'dist/assets/javascripts',
329
- format: 'esm',
330
- minify: true,
331
- }).catch(() => process.exit(1))
332
- JS
333
-
334
- write_file("esbuild.config.js", content)
335
- end
336
-
337
- def create_webpack_config
338
- content = <<~JS
339
- const path = require('path');
340
-
341
- module.exports = {
342
- entry: './app/javascript/application.js',
343
- output: {
344
- filename: 'application.js',
345
- path: path.resolve(__dirname, 'dist/assets/javascripts'),
346
- },
347
- mode: 'production',
348
- };
349
- JS
350
-
351
- write_file("webpack.config.js", content)
352
- end
353
-
354
- def create_vite_config
355
- content = <<~JS
356
- import { defineConfig } from 'vite';
357
- import RubyPlugin from 'vite-plugin-ruby';
358
-
359
- export default defineConfig({
360
- plugins: [RubyPlugin()],
361
- });
362
- JS
363
-
364
- write_file("vite.config.js", content)
365
- end
366
-
367
- def create_rails_config
368
- create_boot_config
369
- create_routes_config
370
- create_application_config
371
- create_environment_file
372
- create_environment_configs
373
- end
374
-
375
- def create_boot_config
376
- content = <<~RUBY
377
- # frozen_string_literal: true
378
-
379
- ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
380
-
381
- require "bundler/setup" # Set up gems listed in the Gemfile.
382
- RUBY
383
-
384
- write_file("config/boot.rb", content)
385
- end
386
-
387
- def create_routes_config
388
- content = <<~RUBY
389
- # frozen_string_literal: true
390
-
391
- Rails.application.routes.draw do
392
- mount EditRails::Engine => "/edit_rails", as: "edit_rails"
393
- end
394
- RUBY
395
-
396
- write_file("config/routes.rb", content)
397
- end
398
-
399
- def create_application_config
400
- content = <<~RUBY
401
- # frozen_string_literal: true
402
-
403
- require_relative "boot"
404
-
405
- require "rails/all"
406
-
407
- Bundler.require(*Rails.groups)
408
-
409
- module StaticSite
410
- class Application < Rails::Application
411
- config.load_defaults Rails::VERSION::STRING.to_f
412
-
413
- # Configuration for the application's routes
414
- config.api_only = false
415
-
416
- # Don't generate system test files
417
- config.generators.system_tests = nil
418
-
419
- # Serve static files from public directory
420
- config.public_file_server.enabled = true
119
+ views_dir = Pathname.new('app/views')
120
+ if views_dir.exist?
121
+ Dir.glob(views_dir.join('**', '*.html.erb')).each do |erb_file|
122
+ relative_path = Pathname.new(erb_file).relative_path_from(views_dir)
123
+ file_name = Pathname.new(erb_file).basename.to_s
124
+
125
+ if file_name.start_with?('_') == false && relative_path.to_s.start_with?('layouts/') == false
126
+ page_name = relative_path.to_s.gsub(/\.html\.erb$/, '')
127
+
128
+ # Convert page name to URL path
129
+ # Handle index pages: 'index' -> '/', 'blog/index' -> '/blog/'
130
+ path = if page_name == 'index'
131
+ '/'
132
+ elsif page_name.end_with?('/index')
133
+ "/\#{page_name[0..-7]}/"
134
+ else
135
+ "/\#{page_name}"
136
+ end
421
137
 
422
- # Don't require database
423
- config.active_record.maintain_test_schema = false
138
+ add path, lastmod: File.mtime(erb_file), changefreq: 'weekly', priority: 0.5
139
+ end
140
+ end
424
141
  end
425
142
  end
426
143
  RUBY
427
144
 
428
- write_file("config/application.rb", content)
145
+ write_file('config/sitemap.rb', content)
429
146
  end
430
147
 
431
- def create_environment_file
432
- content = <<~RUBY
433
- # frozen_string_literal: true
434
148
 
435
- # Load the Rails application.
436
- require_relative "application"
437
149
 
438
- # Initialize the Rails application.
439
- Rails.application.initialize!
440
- RUBY
441
-
442
- write_file("config/environment.rb", content)
443
- end
444
-
445
- def create_environment_configs
446
- # Create development environment config
447
- dev_content = <<~RUBY
448
- # frozen_string_literal: true
449
-
450
- require "active_support/core_ext/integer/time"
451
-
452
- Rails.application.configure do
453
- config.cache_classes = false
454
- config.eager_load = false
455
- config.consider_all_requests_local = true
456
- config.server_timing = true
457
- config.public_file_server.enabled = true
458
- config.public_file_server.headers = {
459
- "Cache-Control" => "public, max-age=#{1.hour.to_i}"
460
- }
461
- end
462
- RUBY
463
-
464
- write_file("config/environments/development.rb", dev_content)
465
- end
466
150
 
467
151
  def create_app_structure
468
152
  create_layout
@@ -471,11 +155,7 @@ module StaticSiteBuilder
471
155
  end
472
156
 
473
157
  def create_layout
474
- if @options[:template_engine] == "phlex"
475
- create_phlex_layout
476
- else
477
- create_erb_layout
478
- end
158
+ create_erb_layout
479
159
  end
480
160
 
481
161
  def create_erb_layout
@@ -485,529 +165,677 @@ module StaticSiteBuilder
485
165
  <head>
486
166
  <meta charset="UTF-8">
487
167
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
488
- <title><%= @title || 'Site' %></title>
489
168
  <link rel="stylesheet" href="/assets/stylesheets/application.css">
490
169
  </head>
491
170
  <body>
492
171
  <main>
493
- <%= page_content %>
172
+ <%= yield %>
494
173
  </main>
495
174
 
496
- #{importmap_script if @options[:js_bundler] == "importmap"}
497
- #{js_script}
175
+ <% if content_for?(:javascript) %>
176
+ <%= yield(:javascript) %>
177
+ <% end %>
178
+
179
+ <% if ENV['LIVE_RELOAD'] == 'true' %>
180
+ <script>
181
+ (function() {
182
+ function connect() {
183
+ var port = '<%= ENV['WS_PORT'] || #{DEFAULT_WS_PORT} %>';
184
+ var ws = new WebSocket('ws://localhost:' + port);
185
+ ws.onmessage = function(e) {
186
+ if (e.data === 'reload') window.location.reload();
187
+ };
188
+ ws.onclose = function() { setTimeout(connect, 500); };
189
+ ws.onerror = function() {};
190
+ }
191
+ connect();
192
+ })();
193
+ </script>
194
+ <% end %>
498
195
  </body>
499
196
  </html>
500
197
  ERB
501
198
 
502
- write_file("app/views/layouts/application.html.erb", content)
199
+ write_file('app/views/layouts/application.html.erb', content)
200
+ end
201
+
202
+ def create_javascript_entry
203
+ content = <<~JS
204
+ // Your JavaScript code here
205
+ console.log("Application loaded")
206
+ JS
207
+
208
+ write_file('app/javascript/application.js', content)
209
+ end
210
+
211
+ def create_css_entry
212
+ write_file('app/assets/stylesheets/application.css', <<~CSS)
213
+ /* Your custom CSS here */
214
+ body {
215
+ font-family: system-ui, sans-serif;
216
+ }
217
+ CSS
503
218
  end
504
219
 
505
- def create_phlex_layout
220
+ def create_build_files
221
+ create_rakefile
222
+ create_site_builder
223
+ end
224
+
225
+ def create_rakefile
506
226
  content = <<~RUBY
507
227
  # frozen_string_literal: true
508
-
509
- class ApplicationLayout < Phlex::HTML
510
- def initialize(title: "Site", **options)
511
- @title = title
512
- @options = options
228
+ # encoding: utf-8
229
+
230
+ # Set default external encoding to UTF-8
231
+ Encoding.default_external = Encoding::UTF_8
232
+
233
+ require_relative 'lib/site_builder'
234
+ require 'fileutils'
235
+ require 'pathname'
236
+ require 'json'
237
+
238
+ namespace :build do
239
+ desc 'Build everything (assets + HTML + CSS + sitemap)'
240
+ task :all do
241
+ Rake::Task['build:clean'].invoke
242
+ Rake::Task['build:assets'].invoke
243
+ Rake::Task['build:html'].invoke
244
+ Rake::Task['build:css'].invoke
245
+ Rake::Task['build:sitemap'].invoke
246
+
247
+ puts "\\n✓ Build complete!"
513
248
  end
514
249
 
515
- def template
516
- html do
517
- head do
518
- meta charset: "UTF-8"
519
- meta name: "viewport", content: "width=device-width, initial-scale=1.0"
520
- title { @title }
521
- link rel: "stylesheet", href: "/assets/stylesheets/application.css"
250
+ desc 'Build JavaScript assets'
251
+ task :assets do
252
+ package_json_path = Pathname.new(Dir.pwd).join('package.json')
253
+ if package_json_path.exist?
254
+ package_json = JSON.parse(File.read(package_json_path))
255
+ build_script = package_json.dig('scripts', 'build')
256
+
257
+ if build_script && !build_script.include?('build:css') && build_script != "echo 'No JS bundling needed'"
258
+ sh 'npm run build'
522
259
  end
523
- body do
524
- main { yield }
525
- #{importmap_script if @options[:js_bundler] == "importmap"}
526
- #{js_script}
260
+ else
261
+ js_dir = Pathname.new(Dir.pwd).join('app', 'javascript')
262
+ if js_dir.exist?
263
+ dist_js = Pathname.new(Dir.pwd).join('dist', 'assets', 'javascripts')
264
+ FileUtils.mkdir_p(dist_js)
265
+ Dir.glob(js_dir.join('**', '*')).each do |item|
266
+ if File.file?(item)
267
+ dest = dist_js.join(Pathname.new(item).relative_path_from(js_dir))
268
+ FileUtils.mkdir_p(dest.dirname)
269
+ FileUtils.cp(item, dest)
270
+ end
271
+ end
527
272
  end
528
273
  end
529
274
  end
530
- end
531
- RUBY
532
275
 
533
- write_file("app/views/layouts/application.rb", content)
534
- end
276
+ desc 'Compile all pages to static HTML'
277
+ task :html => [:assets] do
278
+ builder = SiteBuilder::Builder.new(root: Dir.pwd)
279
+ builder.build
280
+ end
535
281
 
536
- def importmap_script
537
- <<~ERB
538
- <script type="importmap">
539
- <%= importmap_json %>
540
- </script>
541
- ERB
542
- end
282
+ desc 'Build CSS (runs after HTML so dist directory exists)'
283
+ task :css do
284
+ package_json_path = Pathname.new(Dir.pwd).join('package.json')
285
+ dist_css = Pathname.new(Dir.pwd).join('dist', 'assets', 'stylesheets')
543
286
 
544
- def js_script
545
- case @options[:js_bundler]
546
- when "importmap"
547
- <<~ERB
548
- <% if @js_modules.present? %>
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>
554
- <% end %>
555
- ERB
556
- when "esbuild", "webpack", "vite"
557
- '<script type="module" src="/assets/javascripts/application.js"></script>'
558
- else
559
- '<script src="/assets/javascripts/application.js"></script>'
560
- end
561
- end
287
+ if package_json_path.exist?
288
+ package_json = JSON.parse(File.read(package_json_path))
289
+ if package_json.dig('scripts', 'build:css')
290
+ sh 'npm run build:css'
291
+ end
292
+ elsif File.exist?('tailwind.config.js')
293
+ if system('which tailwindcss > /dev/null 2>&1')
294
+ FileUtils.mkdir_p(dist_css)
295
+ sh 'tailwindcss -i ./app/assets/stylesheets/application.css -o ./dist/assets/stylesheets/application.css --minify'
296
+ end
297
+ else
298
+ css_dir = Pathname.new(Dir.pwd).join('app', 'assets', 'stylesheets')
299
+ if css_dir.exist?
300
+ FileUtils.mkdir_p(dist_css)
301
+ Dir.glob(css_dir.join('**', '*')).each do |item|
302
+ if File.file?(item)
303
+ dest = dist_css.join(Pathname.new(item).relative_path_from(css_dir))
304
+ FileUtils.mkdir_p(dest.dirname)
305
+ FileUtils.cp(item, dest)
306
+ end
307
+ end
308
+ end
309
+ end
310
+ end
562
311
 
563
- def create_javascript_entry
564
- case @options[:js_framework]
565
- when "stimulus"
566
- create_stimulus_entry
567
- when "react"
568
- create_react_entry
569
- when "vue"
570
- create_vue_entry
571
- when "alpine"
572
- create_alpine_entry
573
- else
574
- create_vanilla_entry
575
- end
576
- end
312
+ desc 'Clean dist directory'
313
+ task :clean do
314
+ dist_dir = Pathname.new(Dir.pwd).join('dist')
315
+ FileUtils.rm_rf(dist_dir) if dist_dir.exist?
316
+ puts "Cleaned \#{dist_dir}"
317
+ end
577
318
 
578
- def create_stimulus_entry
579
- # Create controllers directory
580
- FileUtils.mkdir_p(@app_path.join("app/javascript/controllers"))
319
+ desc 'Generate sitemap from actual pages'
320
+ task :sitemap do
321
+ require './config/sitemap'
322
+ end
323
+ end
581
324
 
582
- # Main entry - starts Application and registers controllers (standard Stimulus pattern)
583
- content = <<~JS
584
- import { Application } from "@hotwired/stimulus"
325
+ namespace :dev do
326
+ desc 'Start development server with auto-rebuild and live reload'
327
+ task :server do
328
+ require 'webrick'
329
+
330
+ port = ENV['PORT'] || #{DEFAULT_PORT}
331
+ ws_port = ENV['WS_PORT'] || #{DEFAULT_WS_PORT}
332
+ dist_dir = Pathname.new(Dir.pwd).join('dist')
333
+ reload_file = Pathname.new(Dir.pwd).join('.reload')
334
+
335
+ ws_server = SiteBuilder::WebSocketServer.new(port: ws_port.to_i, reload_file: reload_file)
336
+ ws_server.start
337
+
338
+ ENV['LIVE_RELOAD'] = 'true'
339
+ ENV['WS_PORT'] = ws_port.to_s
340
+
341
+ Rake::Task['build:html'].invoke
342
+ Rake::Task['build:css'].invoke
343
+
344
+ puts "\\n🚀 Starting development server at http://localhost:\#{port}"
345
+ puts "📡 WebSocket server at ws://localhost:\#{ws_port}"
346
+ puts "📝 Watching for changes... (Ctrl+C to stop)"
347
+ puts "🔄 Live reload enabled - pages will auto-refresh on changes\\n"
348
+
349
+ watcher_code = %q{
350
+ watched = ['app', 'config']
351
+ exts = ['.erb', '.rb', '.js', '.css']
352
+ mtimes = {}
353
+ last_build = Time.now
354
+
355
+ loop do
356
+ changed = false
357
+
358
+ watched.each do |dir|
359
+ Dir.glob(File.join(dir, '**', '*')).each do |f|
360
+ if File.file?(f) && exts.any? { |e| f.end_with?(e) }
361
+ mtime = File.mtime(f)
362
+ if mtimes[f] != mtime
363
+ mtimes[f] = mtime
364
+ changed = true
365
+ end
366
+ end
367
+ end
368
+ end
585
369
 
586
- window.Stimulus = Application.start()
370
+ if changed && (Time.now - last_build) > 1
371
+ system('rake build:html > /dev/null 2>&1 && rake build:css > /dev/null 2>&1')
372
+ last_build = Time.now
373
+ end
587
374
 
588
- // Register your controllers here
589
- // import HelloController from "./controllers/hello_controller"
590
- // Stimulus.register("hello", HelloController)
591
- JS
375
+ sleep 0.5
376
+ end
377
+ }
592
378
 
593
- write_file("app/javascript/application.js", content)
594
- end
379
+ watcher_pid = spawn('ruby', '-e', watcher_code, err: File::NULL, out: File::NULL)
380
+
381
+ server = WEBrick::HTTPServer.new(
382
+ Port: port,
383
+ BindAddress: '127.0.0.1'
384
+ )
385
+
386
+ server.mount('/assets', WEBrick::HTTPServlet::FileHandler, File.join(dist_dir.to_s, 'assets'))
387
+ server.mount('/images', WEBrick::HTTPServlet::FileHandler, File.join(dist_dir.to_s, 'images'))
388
+
389
+ server.mount_proc('/') do |req, res|
390
+ path = req.path
391
+
392
+ if path == '/'
393
+ index_path = File.join(dist_dir.to_s, 'index.html')
394
+ if File.exist?(index_path)
395
+ res.status = 200
396
+ res['Content-Type'] = 'text/html'
397
+ res.body = File.read(index_path)
398
+ else
399
+ res.status = 404
400
+ res['Content-Type'] = 'text/plain'
401
+ res.body = "Not Found\\n"
402
+ end
403
+ else
404
+ clean_path = path.start_with?('/') ? path[1..] : path
405
+ served = false
406
+
407
+ if !path.include?('.') && !path.end_with?('/')
408
+ html_path = File.join(dist_dir.to_s, "\#{clean_path}.html")
409
+ if File.exist?(html_path)
410
+ res.status = 200
411
+ res['Content-Type'] = 'text/html'
412
+ res.body = File.read(html_path)
413
+ served = true
414
+ end
415
+ end
595
416
 
596
- def create_react_entry
597
- content = <<~JS
598
- import React from 'react'
599
- import { createRoot } from 'react-dom/client'
600
-
601
- // Import your React components
602
- // import App from './components/App'
603
-
604
- document.addEventListener('DOMContentLoaded', () => {
605
- const container = document.getElementById('app')
606
- if (container) {
607
- const root = createRoot(container)
608
- root.render(<App />)
609
- }
610
- })
611
- JS
417
+ if served == false
418
+ file_path = File.join(dist_dir.to_s, clean_path)
419
+
420
+ if File.directory?(file_path)
421
+ nested_index = File.join(file_path, 'index.html')
422
+ if File.exist?(nested_index)
423
+ res.status = 200
424
+ res['Content-Type'] = 'text/html'
425
+ res.body = File.read(nested_index)
426
+ served = true
427
+ end
428
+ elsif File.exist?(file_path) && File.file?(file_path)
429
+ res.status = 200
430
+ res['Content-Type'] = WEBrick::HTTPUtils.mime_type(file_path, WEBrick::HTTPUtils::DefaultMimeTypes)
431
+ res.body = File.read(file_path)
432
+ served = true
433
+ end
434
+ end
612
435
 
613
- write_file("app/javascript/application.js", content)
614
- end
436
+ if served == false
437
+ res.status = 404
438
+ res['Content-Type'] = 'text/plain'
439
+ res.body = "Not Found\\n"
440
+ end
441
+ end
442
+ end
615
443
 
616
- def create_vue_entry
617
- content = <<~JS
618
- import { createApp } from 'vue'
444
+ trap('INT') do
445
+ puts "\\n\\nShutting down..."
446
+ Process.kill('TERM', watcher_pid) if watcher_pid
447
+ ws_server.stop
448
+ server.shutdown
449
+ end
619
450
 
620
- // Import your Vue components
621
- // import App from './components/App.vue'
451
+ server.start
452
+ end
453
+ end
622
454
 
623
- document.addEventListener('DOMContentLoaded', () => {
624
- const app = createApp(App)
625
- app.mount('#app')
626
- })
627
- JS
455
+ task default: 'build:all'
456
+ RUBY
628
457
 
629
- write_file("app/javascript/application.js", content)
458
+ write_file('Rakefile', content)
630
459
  end
631
460
 
632
- def create_alpine_entry
633
- content = <<~JS
634
- import Alpine from 'alpinejs'
635
461
 
636
- window.Alpine = Alpine
637
- Alpine.start()
638
- JS
639
462
 
640
- write_file("app/javascript/application.js", content)
641
- end
463
+ def create_site_builder
464
+ content = <<~RUBY
465
+ # frozen_string_literal: true
642
466
 
643
- def create_vanilla_entry
644
- content = <<~JS
645
- // Your vanilla JavaScript code here
646
- console.log("Application loaded")
647
- JS
467
+ require 'action_view'
468
+ require 'action_view/helpers'
469
+ require 'fileutils'
470
+ require 'pathname'
471
+ require 'socket'
472
+ require 'base64'
473
+ require 'digest/sha1'
474
+ require 'json'
475
+
476
+ module SiteBuilder
477
+ DIST_DIR = 'dist'
478
+ DEFAULT_PORT = 3000
479
+ DEFAULT_WS_PORT = 3001
480
+
481
+ class Builder
482
+ def initialize(root: Dir.pwd)
483
+ @root = Pathname.new(root)
484
+ @dist_dir = @root.join(DIST_DIR)
485
+ @views_dir = @root.join('app', 'views')
486
+ end
648
487
 
649
- write_file("app/javascript/application.js", content)
650
- end
488
+ def build
489
+ copy_javascript
490
+ copy_stylesheets
491
+ compile_views
492
+ copy_public
493
+ write_reload_file
494
+ end
651
495
 
652
- def create_css_entry
653
- case @options[:css_framework]
654
- when "tailwindcss"
655
- write_file("app/assets/stylesheets/application.css", <<~CSS)
656
- @tailwind base;
657
- @tailwind components;
658
- @tailwind utilities;
659
-
660
- @layer base {
661
- html {
662
- scroll-behavior: smooth;
663
- }
664
- }
496
+ private
665
497
 
666
- @layer utilities {
667
- section[id] {
668
- scroll-margin-top: 5rem;
669
- }
670
- }
671
- CSS
672
- when "shadcn"
673
- write_file("app/assets/stylesheets/application.css", <<~CSS)
674
- @tailwind base;
675
- @tailwind components;
676
- @tailwind utilities;
677
-
678
- @layer base {
679
- :root {
680
- --background: 0 0% 100%;
681
- --foreground: 222.2 84% 4.9%;
682
- }
683
- }
684
- CSS
685
- else
686
- write_file("app/assets/stylesheets/application.css", <<~CSS)
687
- /* Your custom CSS here */
688
- body {
689
- font-family: system-ui, sans-serif;
690
- }
691
- CSS
692
- end
693
- end
498
+ def compile_views
499
+ FileUtils.mkdir_p(@dist_dir)
694
500
 
695
- def create_build_files
696
- create_rakefile
697
- create_site_builder
698
- end
501
+ if @views_dir.exist?
502
+ view = build_action_view
503
+ layout_virtual = 'layouts/application'
699
504
 
700
- def webrick_server_code
701
- <<~'RUBY'
702
- require "webrick"
703
- require "fileutils"
704
- require "static_site_builder/websocket_server"
705
- require "json"
706
-
707
- port = ENV["PORT"] || 3000
708
- ws_port = ENV["WS_PORT"] || 3001
709
- dist_dir = Pathname.new(Dir.pwd).join("dist")
710
- reload_file = Pathname.new(Dir.pwd).join(".reload")
711
-
712
- # Start WebSocket server for live reload (before first build)
713
- ws_server = StaticSiteBuilder::WebSocketServer.new(port: ws_port, reload_file: reload_file)
714
- ws_server.start
715
-
716
- # Build once before starting (with live reload enabled)
717
- ENV["LIVE_RELOAD"] = "true"
718
- ENV["WS_PORT"] = ws_port.to_s
719
- Rake::Task["build:all"].invoke
720
-
721
- # Check if we need to run Tailwind CSS watch (after initial build)
722
- tailwind_pid = nil
723
- package_json_path = Pathname.new(Dir.pwd).join("package.json")
724
- if package_json_path.exist?
725
- package_json = JSON.parse(File.read(package_json_path))
726
- if package_json.dig("scripts", "watch:css")
727
- puts "🎨 Starting Tailwind CSS watch mode..."
728
- tailwind_pid = spawn("npm", "run", "watch:css", :err => File::NULL, :out => File::NULL)
729
- # Touch the source file to trigger Tailwind watch to process CSS immediately
730
- css_source = Pathname.new(Dir.pwd).join("app", "assets", "stylesheets", "application.css")
731
- if css_source.exist?
732
- FileUtils.touch(css_source)
733
- end
734
- # Give Tailwind a moment to process CSS
735
- sleep 1.5
736
- end
737
- end
505
+ Dir.glob(@views_dir.join('**', '*.html.erb')).each do |erb_file|
506
+ relative = Pathname.new(erb_file).relative_path_from(@views_dir).to_s
507
+ file_name = Pathname.new(erb_file).basename.to_s
738
508
 
739
- puts "\n🚀 Starting development server at http://localhost:#{port}"
740
- puts "📡 WebSocket server at ws://localhost:#{ws_port}"
741
- puts "📝 Watching for changes... (Ctrl+C to stop)"
742
- puts "🔄 Live reload enabled - pages will auto-refresh on changes\n"
743
-
744
- # Simple file watcher - rebuild HTML when non-CSS files change
745
- # CSS changes are handled by Tailwind watch, so we skip rebuild for CSS files
746
- # When HTML rebuilds, it cleans dist, so we need to rebuild CSS immediately after
747
- watcher_code = %q{watched = ['app', 'config']; exts = ['.erb', '.rb', '.js']; mtimes = {}; loop do; changed = false; watched.each do |dir|; Dir.glob(File.join(dir, '**', '*')).each do |f|; next unless File.file?(f) && exts.any? { |e| f.end_with?(e) }; next if f.end_with?('.css'); mtime = File.mtime(f); if mtimes[f] != mtime; mtimes[f] = mtime; changed = true; end; end; end; if changed; system('rake build:html > /dev/null 2>&1 && rake build:css > /dev/null 2>&1'); end; sleep 0.5; end}
748
- watcher_pid = spawn("ruby", "-e", watcher_code, :err => File::NULL)
749
-
750
- # Start web server
751
- server = WEBrick::HTTPServer.new(
752
- Port: port,
753
- DocumentRoot: dist_dir.to_s,
754
- BindAddress: "127.0.0.1"
755
- )
756
-
757
- trap("INT") do
758
- puts "\n\nShutting down..."
759
- Process.kill("TERM", watcher_pid) if watcher_pid
760
- Process.kill("TERM", tailwind_pid) if tailwind_pid
761
- ws_server.stop
762
- server.shutdown
763
- end
509
+ is_partial = file_name.start_with?('_')
510
+ is_layout = relative.start_with?('layouts/')
764
511
 
765
- server.start
766
- RUBY
767
- end
512
+ if is_partial == false && is_layout == false
513
+ virtual_path = relative.gsub(/\.html\.erb$/, '')
514
+ output_rel = relative.gsub(/\.html\.erb$/, '.html')
515
+ output_path = @dist_dir.join(output_rel)
768
516
 
769
- def rails_server_code
770
- <<~'RUBY'
771
- require "fileutils"
772
- require "pathname"
517
+ rendered = view.render(template: virtual_path, layout: layout_virtual)
773
518
 
774
- # Load Rails environment
775
- require_relative "config/environment"
519
+ FileUtils.mkdir_p(output_path.dirname)
520
+ File.write(output_path, rendered)
521
+ end
522
+ end
523
+ end
524
+ end
776
525
 
777
- port = ENV["PORT"] || 3000
526
+ def build_action_view
527
+ view_paths = ActionView::PathSet.new([@views_dir.to_s])
528
+ lookup_context = ActionView::LookupContext.new(view_paths)
529
+ view_class = ActionView::Base.with_empty_template_cache
778
530
 
779
- # Build once before starting
780
- ENV["LIVE_RELOAD"] = "true"
781
- Rake::Task["build:all"].invoke
531
+ load_and_include_helpers(view_class)
782
532
 
783
- puts "\n🚀 Starting Rails development server at http://localhost:#{port}"
784
- puts "📝 EditRails available at http://localhost:#{port}/edit_rails"
785
- puts "📝 Watching for changes... (Ctrl+C to stop)\n"
533
+ view = view_class.new(lookup_context, {}, nil)
534
+ view
535
+ end
786
536
 
787
- # Start Rails server
788
- exec "rails server -p #{port} -b 127.0.0.1"
789
- RUBY
790
- end
537
+ def load_and_include_helpers(view_class)
538
+ helpers_dir = @root.join('app', 'helpers')
539
+ if helpers_dir.exist? && helpers_dir.directory?
540
+ Dir.glob(helpers_dir.join('**', '*_helper.rb')).each do |helper_file|
541
+ load helper_file
791
542
 
792
- def create_rakefile
793
- needs_npm = needs_npm?
794
- has_edit_rails = @options[:edit_rails]
795
- server_code = has_edit_rails ? rails_server_code : webrick_server_code
543
+ relative = Pathname.new(helper_file).relative_path_from(helpers_dir).to_s
544
+ module_name = helper_module_name(relative)
796
545
 
797
- if needs_npm
798
- content = <<~RUBY
799
- # frozen_string_literal: true
546
+ if Object.const_defined?(module_name)
547
+ view_class.include(Object.const_get(module_name))
548
+ end
549
+ end
550
+ end
551
+ end
800
552
 
801
- require_relative "lib/site_builder"
802
- require "fileutils"
803
- require "pathname"
553
+ def helper_module_name(relative_path)
554
+ without_ext = relative_path.gsub(/\.rb$/, '')
555
+ parts = without_ext.split('/')
556
+ parts.map { |p| camelize(p) }.join('::')
557
+ end
804
558
 
805
- namespace :build do
806
- desc "Build everything (HTML + CSS + Sitemap)"
807
- task :all => [:html, :css, :sitemap] do
808
- puts "\\n✓ Build complete!"
559
+ def camelize(value)
560
+ value.split('_').map { |part| part[0] ? part[0].upcase + part[1..] : '' }.join
809
561
  end
810
562
 
811
- desc "Build JavaScript assets"
812
- task :assets do
813
- if File.exist?("package.json")
814
- package_json = JSON.parse(File.read("package.json"))
815
- build_script = package_json.dig("scripts", "build")
816
- # Only run if build script exists and doesn't include CSS (CSS handled separately)
817
- if build_script && !build_script.include?("build:css")
818
- sh "npm run build"
563
+ def copy_javascript
564
+ package_json_path = @root.join('package.json')
565
+ should_copy = true
566
+
567
+ if package_json_path.exist?
568
+ begin
569
+ package_json = JSON.parse(File.read(package_json_path))
570
+ should_copy = package_json.dig('scripts', 'build').nil?
571
+ rescue JSON::ParserError
572
+ should_copy = true
819
573
  end
820
574
  end
821
- end
822
575
 
823
- desc "Compile all pages to static HTML"
824
- task :html => [:assets] do
825
- load "lib/site_builder.rb"
576
+ if should_copy
577
+ js_dir = @root.join('app', 'javascript')
578
+ if js_dir.exist? && js_dir.directory?
579
+ dist_js = @dist_dir.join('assets', 'javascripts')
580
+ FileUtils.mkdir_p(dist_js)
581
+ copy_tree_skip_existing(js_dir, dist_js)
582
+ end
583
+ end
826
584
  end
827
585
 
828
- desc "Build CSS (runs after HTML so dist directory exists)"
829
- task :css do
830
- if File.exist?("package.json")
831
- package_json = JSON.parse(File.read("package.json"))
832
- if package_json.dig("scripts", "build:css")
833
- sh "npm run build:css"
586
+ def copy_stylesheets
587
+ package_json_path = @root.join('package.json')
588
+ should_copy = true
589
+
590
+ if package_json_path.exist?
591
+ begin
592
+ package_json = JSON.parse(File.read(package_json_path))
593
+ should_copy = package_json.dig('scripts', 'build:css').nil?
594
+ rescue JSON::ParserError
595
+ should_copy = true
834
596
  end
835
- elsif File.exist?("tailwind.config.js")
836
- # Build CSS even if no package.json (standalone Tailwind)
837
- if system("which tailwindcss > /dev/null 2>&1")
838
- FileUtils.mkdir_p("dist/assets/stylesheets")
839
- sh "tailwindcss -i ./app/assets/stylesheets/application.css -o ./dist/assets/stylesheets/application.css --minify"
597
+ end
598
+
599
+ if should_copy
600
+ css_dir = @root.join('app', 'assets', 'stylesheets')
601
+ if css_dir.exist? && css_dir.directory?
602
+ dist_css = @dist_dir.join('assets', 'stylesheets')
603
+ FileUtils.mkdir_p(dist_css)
604
+ copy_tree_skip_existing(css_dir, dist_css)
840
605
  end
841
606
  end
842
607
  end
843
608
 
844
- desc "Clean dist directory"
845
- task :clean do
846
- dist_dir = Pathname.new(Dir.pwd).join("dist")
847
- FileUtils.rm_rf(dist_dir) if dist_dir.exist?
848
- puts "Cleaned \#{dist_dir}"
609
+ def copy_public
610
+ public_dir = @root.join('public')
611
+ if public_dir.exist? && public_dir.directory?
612
+ Dir.glob(public_dir.join('**', '*')).each do |item|
613
+ if File.file?(item)
614
+ dest = @dist_dir.join(Pathname.new(item).relative_path_from(public_dir))
615
+ FileUtils.mkdir_p(dest.dirname)
616
+ FileUtils.cp(item, dest)
617
+ end
618
+ end
619
+ end
849
620
  end
850
621
 
851
- desc "Build for production/release (cleans dist directory first)"
852
- task :production do
853
- ENV["PRODUCTION"] = "true"
854
- Rake::Task["build:all"].invoke
622
+ def write_reload_file
623
+ reload_file = @root.join('.reload')
624
+ File.write(reload_file, Time.now.to_f.to_s)
855
625
  end
856
626
 
857
- desc "Generate sitemap from PageHelpers::PAGES"
858
- task :sitemap do
859
- require './config/sitemap'
627
+ def copy_tree_skip_existing(source_dir, dest_dir)
628
+ Dir.glob(source_dir.join('**', '*')).each do |item|
629
+ if File.file?(item)
630
+ relative = Pathname.new(item).relative_path_from(source_dir)
631
+ dest = dest_dir.join(relative)
632
+
633
+ if dest.exist? == false
634
+ FileUtils.mkdir_p(dest.dirname)
635
+ FileUtils.cp(item, dest)
636
+ end
637
+ end
638
+ end
860
639
  end
861
640
  end
862
641
 
863
- namespace :dev do
864
- desc "Start development server with auto-rebuild and live reload"
865
- task :server do
866
- #{server_code}
642
+ class WebSocketServer
643
+ ACCEPT_RETRY_INTERVAL = 0.1
644
+ WATCH_POLL_INTERVAL = 0.3
645
+ CLIENT_KEEPALIVE_INTERVAL = 1
646
+
647
+ def initialize(port: DEFAULT_WS_PORT, reload_file: nil)
648
+ @port = port
649
+ @reload_file = reload_file || Pathname.new(Dir.pwd).join('.reload')
650
+ @clients = []
651
+ @running = false
652
+ @server = nil
867
653
  end
868
- end
869
654
 
870
- task default: "build:all"
871
- RUBY
872
- else
873
- content = <<~RUBY
874
- # frozen_string_literal: true
655
+ def start
656
+ @running = true
657
+ @server = TCPServer.new('127.0.0.1', @port)
875
658
 
876
- require_relative "lib/site_builder"
877
- require "fileutils"
878
- require "pathname"
659
+ if @reload_file.exist?
660
+ @last_mtime = @reload_file.mtime
661
+ else
662
+ File.write(@reload_file, Time.now.to_f.to_s)
663
+ @last_mtime = @reload_file.mtime
664
+ end
879
665
 
880
- namespace :build do
881
- desc "Build everything (HTML + Sitemap)"
882
- task :all => [:html, :sitemap] do
883
- puts "\\n✓ Build complete!"
884
- end
666
+ @accept_thread = Thread.new do
667
+ while @running
668
+ begin
669
+ client = @server.accept
670
+ Thread.new { handle_client(client) }
671
+ rescue IOError, Errno::EBADF, Errno::ECONNABORTED
672
+ sleep ACCEPT_RETRY_INTERVAL
673
+ if @running == false
674
+ break
675
+ end
676
+ end
677
+ end
678
+ end
885
679
 
886
- desc "Compile all pages to static HTML"
887
- task :html do
888
- load "lib/site_builder.rb"
680
+ @watch_thread = Thread.new do
681
+ while @running
682
+ begin
683
+ sleep WATCH_POLL_INTERVAL
684
+ if @reload_file.exist? && @reload_file.mtime > @last_mtime
685
+ @last_mtime = @reload_file.mtime
686
+ broadcast('reload')
687
+ end
688
+ rescue Errno::ENOENT, Errno::EACCES, SystemCallError
689
+ sleep WATCH_POLL_INTERVAL
690
+ if @running == false
691
+ break
692
+ end
693
+ end
694
+ end
695
+ end
889
696
  end
890
697
 
891
- desc "Clean dist directory"
892
- task :clean do
893
- dist_dir = Pathname.new(Dir.pwd).join("dist")
894
- FileUtils.rm_rf(dist_dir) if dist_dir.exist?
895
- puts "Cleaned \#{dist_dir}"
896
- end
698
+ def stop
699
+ @running = false
897
700
 
898
- desc "Build for production/release (cleans dist directory first)"
899
- task :production do
900
- ENV["PRODUCTION"] = "true"
901
- Rake::Task["build:all"].invoke
902
- end
701
+ @clients.each do |client|
702
+ safe_close(client)
703
+ end
903
704
 
904
- desc "Generate sitemap from PageHelpers::PAGES"
905
- task :sitemap do
906
- require './config/sitemap'
907
- end
908
- end
705
+ if @server
706
+ safe_close(@server)
707
+ end
909
708
 
910
- namespace :dev do
911
- desc "Start development server with auto-rebuild and live reload"
912
- task :server do
913
- #{server_code}
709
+ safe_kill_thread(@accept_thread)
710
+ safe_kill_thread(@watch_thread)
914
711
  end
915
- end
916
712
 
917
- task default: "build:all"
918
- RUBY
919
- end
713
+ private
920
714
 
921
- write_file("Rakefile", content)
922
- end
715
+ def safe_close(io)
716
+ if io && io.closed? == false
717
+ begin
718
+ io.close
719
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET, Errno::EBADF
720
+ end
721
+ end
722
+ end
923
723
 
924
- def create_site_builder
925
- # Generated sites use the static-site-builder gem
926
- # This file just configures it for the chosen stack
927
- phlex_require = @options[:template_engine] == "phlex" ? 'require "phlex-rails"' : ""
928
- importmap_require = @options[:js_bundler] == "importmap" ? 'require "importmap-rails"' : ""
929
- importmap_config_line = @options[:js_bundler] == "importmap" ? importmap_config : ""
724
+ def safe_kill_thread(thread)
725
+ if thread && thread.alive?
726
+ begin
727
+ thread.kill
728
+ rescue ThreadError
729
+ end
730
+ end
731
+ end
930
732
 
931
- content = <<~RUBY
932
- # frozen_string_literal: true
733
+ def handle_client(client)
734
+ begin
735
+ _request = client.gets
736
+ headers = {}
737
+ line = nil
738
+
739
+ while (line = client.gets)
740
+ stripped = line.chomp
741
+ if stripped == ''
742
+ break
743
+ end
744
+
745
+ key, value = stripped.split(': ', 2)
746
+ if key && value
747
+ headers[key] = value
748
+ end
749
+ end
933
750
 
934
- require "static_site_builder"
935
- #{phlex_require}
936
- #{importmap_require}
751
+ if headers['Upgrade']&.downcase == 'websocket'
752
+ key = headers['Sec-WebSocket-Key']
753
+ accept = Base64.strict_encode64(Digest::SHA1.digest(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'))
754
+
755
+ client.print "HTTP/1.1 101 Switching Protocols\r\n"
756
+ client.print "Upgrade: websocket\r\n"
757
+ client.print "Connection: Upgrade\r\n"
758
+ client.print "Sec-WebSocket-Accept: \#{accept}\r\n\r\n"
759
+
760
+ @clients << client
761
+
762
+ loop do
763
+ sleep CLIENT_KEEPALIVE_INTERVAL
764
+ if @running == false || client.closed?
765
+ break
766
+ end
767
+ end
768
+ else
769
+ safe_close(client)
770
+ end
771
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET, Errno::ECONNABORTED
772
+ @clients.delete(client)
773
+ safe_close(client)
774
+ end
775
+ end
937
776
 
938
- # Configure the builder for your stack
939
- builder = StaticSiteBuilder::Builder.new(
940
- root: Dir.pwd,
941
- template_engine: "#{@options[:template_engine]}",
942
- js_bundler: "#{@options[:js_bundler]}",
943
- #{importmap_config_line}
944
- )
777
+ def broadcast(message)
778
+ frame = create_frame(message)
779
+ @clients.dup.each do |client|
780
+ begin
781
+ if client.closed? == false
782
+ client.write(frame)
783
+ end
784
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET, Errno::ECONNABORTED
785
+ @clients.delete(client)
786
+ end
787
+ end
788
+ end
945
789
 
946
- # Build the site
947
- builder.build
948
- RUBY
790
+ def create_frame(message)
791
+ data = message.dup.force_encoding('BINARY')
792
+ length = data.bytesize
949
793
 
950
- write_file("lib/site_builder.rb", content)
951
- end
794
+ if length < 126
795
+ [0x81, length].pack('C*') + data
796
+ elsif length < 65_536
797
+ [0x81, 126, length].pack('CCn') + data
798
+ else
799
+ [0x81, 127, length >> 32, length & 0xFFFFFFFF].pack('CCNN') + data
800
+ end
801
+ end
802
+ end
803
+ end
952
804
 
953
- def importmap_config
954
- <<~RUBY
955
- importmap_config: "config/importmap.rb",
805
+ # This file provides the build and live reload code for the generated project.
956
806
  RUBY
807
+
808
+ write_file('lib/site_builder.rb', content)
957
809
  end
958
810
 
959
811
  def create_example_pages
960
- if @options[:template_engine] == "phlex"
961
- create_phlex_example
962
- else
963
- create_erb_example
964
- end
812
+ create_erb_example
965
813
  end
966
814
 
967
815
  def create_erb_example
968
816
  content = <<~ERB
969
- ---
970
- title: Home Page
971
- js: application
972
- ---
817
+ <% content_for(:javascript) do %>
818
+ <script src="/assets/javascripts/application.js"></script>
819
+ <% end %>
973
820
 
974
821
  <h1>Welcome</h1>
975
822
  <p>This is your generated static site.</p>
976
823
  ERB
977
824
 
978
- write_file("app/views/pages/index.html.erb", content)
825
+ write_file('app/views/index.html.erb', content)
979
826
  end
980
827
 
981
- def create_phlex_example
982
- content = <<~RUBY
983
- # frozen_string_literal: true
984
-
985
- class IndexPage < Phlex::HTML
986
- def template
987
- h1 { "Welcome" }
988
- p { "This is your generated static site." }
989
- end
990
- end
991
- RUBY
992
-
993
- write_file("app/views/pages/index.rb", content)
994
- end
995
828
 
996
829
  def create_readme
997
830
  content = <<~MD
998
831
  # #{@app_name}
999
832
 
1000
- Generated static site using:
1001
- - Template: #{@options[:template_engine]}
1002
- - JS Bundler: #{@options[:js_bundler]}
1003
- - CSS: #{@options[:css_framework]}
1004
- - JS Framework: #{@options[:js_framework]}
833
+ Generated static site using ERB templates.
1005
834
 
1006
835
  ## Setup
1007
836
 
1008
837
  ```bash
1009
838
  bundle install
1010
- #{'npm install' if needs_npm?}
1011
839
  ```
1012
840
 
1013
841
  ## Development
@@ -1028,69 +856,32 @@ module StaticSiteBuilder
1028
856
 
1029
857
  ## Build
1030
858
 
1031
- Build for production:
859
+ Build for deployment:
1032
860
 
1033
861
  ```bash
1034
- rake build:all # Build everything (assets + HTML)
1035
- rake build:html # Build HTML only
862
+ rake build:all # Clean dist and build everything (assets + HTML + CSS + sitemap)
863
+ rake build:html # Build HTML only (still runs build:assets)
864
+ rake build:css # Build CSS only
865
+ rake build:sitemap # Build sitemap only
1036
866
  ```
1037
867
 
1038
868
  Output goes to `dist/` directory.
1039
- MD
1040
-
1041
- write_file("README.md", content)
1042
- end
1043
869
 
1044
- def build_script
1045
- js_build = case @options[:js_bundler]
1046
- when "esbuild"
1047
- "node esbuild.config.js"
1048
- when "webpack"
1049
- "webpack --mode production"
1050
- when "vite"
1051
- "vite build"
1052
- else
1053
- nil
1054
- end
870
+ ## JavaScript Bundling
1055
871
 
1056
- css_build = if needs_css_build?
1057
- "npm run build:css"
1058
- else
1059
- nil
1060
- end
872
+ If you add a `package.json` with a `scripts.build`, `rake build:assets` will run `npm run build`.
873
+ If you do not use a bundler, it will copy files from `app/javascript/` into `dist/assets/javascripts/`.
1061
874
 
1062
- builds = [js_build, css_build].compact
1063
- if builds.empty?
1064
- "echo 'No bundling needed'"
1065
- else
1066
- builds.join(" && ")
1067
- end
1068
- end
875
+ ## CSS
1069
876
 
1070
- def css_build_script
1071
- if @options[:css_framework] == "tailwindcss" || @options[:css_framework] == "shadcn"
1072
- "tailwindcss -i ./app/assets/stylesheets/application.css -o ./dist/assets/stylesheets/application.css --minify"
1073
- end
1074
- end
877
+ If you add a `package.json` with a `scripts.build:css`, `rake build:css` will run `npm run build:css`.
878
+ If you do not use a CSS build step, it will copy files from `app/assets/stylesheets/` into `dist/assets/stylesheets/`.
879
+ MD
1075
880
 
1076
- def css_watch_script
1077
- if @options[:css_framework] == "tailwindcss" || @options[:css_framework] == "shadcn"
1078
- "tailwindcss -i ./app/assets/stylesheets/application.css -o ./dist/assets/stylesheets/application.css --watch"
1079
- end
881
+ write_file('README.md', content)
1080
882
  end
1081
883
 
1082
- def needs_npm?
1083
- @options[:js_bundler] != "none" ||
1084
- @options[:css_framework] == "tailwindcss" ||
1085
- @options[:css_framework] == "shadcn" ||
1086
- @options[:js_framework] == "react" ||
1087
- @options[:js_framework] == "vue" ||
1088
- @options[:js_framework] == "alpine"
1089
- end
1090
884
 
1091
- def needs_css_build?
1092
- @options[:css_framework] == "tailwindcss" || @options[:css_framework] == "shadcn"
1093
- end
1094
885
 
1095
886
  def create_gitignore
1096
887
  content = <<~GITIGNORE