static-site-builder 0.1.3 → 1.0.0

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