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.
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'listen'
4
+ require 'pathname'
5
+ require 'static_site_builder/websocket_server'
6
+
7
+ begin
8
+ require 'webrick'
9
+ rescue LoadError
10
+ # webrick may not be available in all environments
11
+ end
12
+
13
+ module StaticSiteBuilder
14
+ # Development server with file watching and live reload
15
+ #
16
+ # Watches for file changes using the listen gem, rebuilds the site automatically,
17
+ # and provides live reload via WebSocket server.
18
+ class DevServer
19
+ def initialize(root: Dir.pwd, port: nil, ws_port: nil)
20
+ @root = Pathname.new(root)
21
+ @port = port || ENV['PORT']&.to_i || DEFAULT_PORT
22
+ @ws_port = ws_port || ENV['WS_PORT']&.to_i || DEFAULT_WS_PORT
23
+ @dist_dir = @root.join('dist')
24
+ @reload_file = @root.join('.reload')
25
+ @listener = nil
26
+ @ws_server = nil
27
+ @http_server = nil
28
+ @running = false
29
+ end
30
+
31
+ # Start the development server
32
+ #
33
+ # Builds the site initially, starts file watcher, WebSocket server, and HTTP server
34
+ def start
35
+ puts 'Building site...'
36
+ build_site
37
+
38
+ puts "\nStarting development server..."
39
+ puts " HTTP server: http://localhost:#{@port}"
40
+ puts " WebSocket server: ws://localhost:#{@ws_port}"
41
+ puts " Watching for changes... (Ctrl+C to stop)\n"
42
+
43
+ start_websocket_server
44
+ start_file_watcher
45
+ start_http_server
46
+ end
47
+
48
+ # Stop the development server
49
+ def stop
50
+ @running = false
51
+ @listener&.stop
52
+ @ws_server&.stop
53
+ @http_server&.shutdown if @http_server
54
+ puts "\nShutting down..."
55
+ end
56
+
57
+ private
58
+
59
+ def build_site
60
+ ENV['LIVE_RELOAD'] = 'true'
61
+ ENV['WS_PORT'] = @ws_port.to_s
62
+
63
+ begin
64
+ require 'rake'
65
+ Rake::Task['build:html'].invoke
66
+ rescue LoadError, RuntimeError
67
+ # If Rakefile not loaded or task not found, build directly
68
+ require 'static_site_builder'
69
+ builder = Builder.new(root: @root.to_s)
70
+ builder.build
71
+ end
72
+ end
73
+
74
+ def start_websocket_server
75
+ @ws_server = WebSocketServer.new(port: @ws_port, reload_file: @reload_file)
76
+ @ws_server.start
77
+ end
78
+
79
+ def start_file_watcher
80
+ @running = true
81
+ watched_dirs = ['app', 'config'].select { |dir| @root.join(dir).exist? }
82
+ return if watched_dirs.empty?
83
+
84
+ @listener = Listen.to(*watched_dirs.map { |dir| @root.join(dir).to_s }) do |modified, added, removed|
85
+ next if modified.empty? && added.empty? && removed.empty?
86
+
87
+ files_changed = (modified + added + removed).select do |file|
88
+ file.end_with?('.erb', '.rb', '.js')
89
+ end
90
+
91
+ if files_changed.any?
92
+ puts "\nFiles changed, rebuilding..."
93
+ build_site
94
+ puts 'Rebuild complete'
95
+ end
96
+ end
97
+
98
+ @listener.start
99
+ end
100
+
101
+ def start_http_server
102
+ unless defined?(WEBrick)
103
+ raise 'webrick gem is required for the development server. Add "gem \'webrick\'" to your Gemfile.'
104
+ end
105
+
106
+ @http_server = WEBrick::HTTPServer.new(
107
+ Port: @port,
108
+ DocumentRoot: @dist_dir.to_s,
109
+ BindAddress: '127.0.0.1'
110
+ )
111
+
112
+ trap('INT') { stop }
113
+ trap('TERM') { stop }
114
+
115
+ @http_server.start
116
+ end
117
+ end
118
+ end
119
+
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StaticSiteBuilder
4
- VERSION = "0.1.4"
4
+ VERSION = "1.0.0"
5
5
  end
@@ -6,66 +6,126 @@ require "digest/sha1"
6
6
  require "pathname"
7
7
 
8
8
  module StaticSiteBuilder
9
- # Simple WebSocket server for live reload
9
+ # WebSocket server for live reload functionality during development.
10
+ #
11
+ # Watches for rebuild notifications via a reload file and broadcasts reload
12
+ # messages to connected browser clients, enabling automatic page refresh.
10
13
  class WebSocketServer
11
- def initialize(port: 3001, reload_file: nil)
14
+ # Sleep intervals for thread operations (in seconds)
15
+ ACCEPT_RETRY_INTERVAL = 0.1 # Retry delay when accepting connections fails
16
+ WATCH_POLL_INTERVAL = 0.3 # How often to check for rebuild notifications
17
+ CLIENT_KEEPALIVE_INTERVAL = 1 # Keep-alive check interval for client connections
18
+
19
+ # Initializes a new WebSocket server instance.
20
+ #
21
+ # @param port [Integer] Port number for the WebSocket server (default: 3001)
22
+ # @param reload_file [Pathname, nil] Path to the reload notification file. If nil, defaults to .reload in current directory.
23
+ def initialize(port: StaticSiteBuilder::DEFAULT_WS_PORT, reload_file: nil)
12
24
  @port = port
13
25
  @reload_file = reload_file || Pathname.new(Dir.pwd).join(".reload")
14
26
  @clients = []
15
27
  @running = false
16
28
  end
17
29
 
30
+ # Starts the WebSocket server and begins watching for rebuild notifications.
31
+ #
32
+ # Spawns two background threads: one for accepting client connections and
33
+ # one for watching the reload file for changes. Returns immediately after
34
+ # starting the threads.
35
+ #
36
+ # @return [void]
18
37
  def start
19
38
  @running = true
20
39
  @server = TCPServer.new("127.0.0.1", @port)
21
40
 
22
- # Initialize reload file
23
- File.write(@reload_file, Time.now.to_f.to_s) unless @reload_file.exist?
41
+ # Initialize reload file if it doesn't exist
42
+ File.write(@reload_file, Time.current.to_f.to_s) unless @reload_file.exist?
24
43
  @last_mtime = @reload_file.mtime
25
44
 
26
- # Accept connections in background
45
+ # Accept client connections in background thread
27
46
  @accept_thread = Thread.new do
28
47
  while @running
29
48
  begin
30
49
  client = @server.accept
31
50
  Thread.new { handle_client(client) }
32
- rescue => e
33
- sleep 0.1
51
+ rescue IOError, Errno::EBADF, Errno::ECONNABORTED
52
+ # Connection errors are expected during shutdown or network issues
53
+ # Retry accepting connections unless server is stopping
54
+ sleep ACCEPT_RETRY_INTERVAL
34
55
  break unless @running
35
56
  end
36
57
  end
37
58
  end
38
59
 
39
- # Watch for rebuilds
60
+ # Watch for rebuild notifications in background thread
40
61
  @watch_thread = Thread.new do
41
62
  while @running
42
63
  begin
43
- sleep 0.3
64
+ sleep WATCH_POLL_INTERVAL
44
65
  if @reload_file.exist? && @reload_file.mtime > @last_mtime
45
66
  @last_mtime = @reload_file.mtime
46
67
  broadcast("reload")
47
68
  end
48
- rescue => e
49
- sleep 0.3
69
+ rescue Errno::ENOENT, Errno::EACCES, SystemCallError
70
+ # File system errors during watch are non-fatal
71
+ # Continue watching unless server is stopping
72
+ sleep WATCH_POLL_INTERVAL
50
73
  break unless @running
51
74
  end
52
75
  end
53
76
  end
54
77
  end
55
78
 
79
+ # Stops the WebSocket server gracefully.
80
+ #
81
+ # Closes all client connections, stops accepting new connections, stops
82
+ # watching for rebuilds, and closes the server socket.
83
+ #
84
+ # @return [void]
56
85
  def stop
57
86
  @running = false
58
- @clients.each { |c| c.close rescue nil }
59
- @server.close rescue nil
60
- @accept_thread.kill rescue nil
61
- @watch_thread.kill rescue nil
87
+ @clients.each { |client| safe_close(client) }
88
+ safe_close(@server) if @server
89
+ safe_kill_thread(@accept_thread)
90
+ safe_kill_thread(@watch_thread)
62
91
  end
63
92
 
64
93
  private
65
94
 
95
+ # Safely closes a socket or connection, ignoring common connection errors.
96
+ #
97
+ # Used to handle cases where connections may already be closed or reset,
98
+ # preventing exceptions during cleanup.
99
+ #
100
+ # @param io [IO] The IO object to close
101
+ def safe_close(io)
102
+ return if io.nil? || io.closed?
103
+
104
+ io.close
105
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET, Errno::EBADF
106
+ # Connection already closed or reset - this is expected during shutdown
107
+ end
108
+
109
+ # Safely terminates a thread, ignoring errors if the thread is already dead.
110
+ #
111
+ # @param thread [Thread, nil] The thread to kill
112
+ def safe_kill_thread(thread)
113
+ return unless thread&.alive?
114
+
115
+ thread.kill
116
+ rescue ThreadError
117
+ # Thread already dead - this is expected during shutdown
118
+ end
119
+
120
+ # Handles a new client connection, performing WebSocket handshake.
121
+ #
122
+ # Reads the HTTP upgrade request, validates it's a WebSocket request, performs
123
+ # the handshake, and keeps the connection alive for receiving reload messages.
124
+ #
125
+ # @param client [TCPSocket] The client socket connection
66
126
  def handle_client(client)
67
127
  begin
68
- # Read handshake
128
+ # Read HTTP upgrade request headers
69
129
  request = client.gets
70
130
  headers = {}
71
131
  while (line = client.gets.chomp) != ""
@@ -74,6 +134,7 @@ module StaticSiteBuilder
74
134
  end
75
135
 
76
136
  if headers["Upgrade"]&.downcase == "websocket"
137
+ # Perform WebSocket handshake
77
138
  key = headers["Sec-WebSocket-Key"]
78
139
  accept = Base64.strict_encode64(Digest::SHA1.digest(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))
79
140
 
@@ -84,32 +145,47 @@ module StaticSiteBuilder
84
145
 
85
146
  @clients << client
86
147
 
87
- # Keep connection alive - just wait
148
+ # Keep connection alive, waiting for reload messages
88
149
  loop do
89
- sleep 1
150
+ sleep CLIENT_KEEPALIVE_INTERVAL
90
151
  break unless @running
91
152
  break if client.closed?
92
153
  end
93
154
  else
155
+ # Not a WebSocket request - close connection
94
156
  client.close
95
157
  end
96
- rescue => e
158
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET, Errno::ECONNABORTED
159
+ # Client connection errors are expected (e.g., browser tab closed)
160
+ # Clean up and continue accepting other connections
97
161
  @clients.delete(client)
98
- client.close rescue nil
162
+ safe_close(client)
99
163
  end
100
164
  end
101
165
 
166
+ # Broadcasts a reload message to all connected clients.
167
+ #
168
+ # @param message [String] The message to broadcast (typically "reload")
102
169
  def broadcast(message)
103
170
  frame = create_frame(message)
104
171
  @clients.dup.each do |client|
105
172
  begin
106
173
  client.write(frame) unless client.closed?
107
- rescue => e
174
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET, Errno::ECONNABORTED
175
+ # Broadcast errors are expected when clients disconnect
176
+ # Remove failed client and continue broadcasting to others
108
177
  @clients.delete(client)
109
178
  end
110
179
  end
111
180
  end
112
181
 
182
+ # Creates a WebSocket frame for the given message.
183
+ #
184
+ # Implements WebSocket frame encoding according to RFC 6455, supporting
185
+ # messages up to 2^32 bytes in length.
186
+ #
187
+ # @param message [String] The message to encode
188
+ # @return [String] Binary WebSocket frame data
113
189
  def create_frame(message)
114
190
  data = message.dup.force_encoding("BINARY")
115
191
  length = data.bytesize
@@ -1,10 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "static_site_builder/version"
4
- require_relative "static_site_builder/builder"
5
- require_relative "static_site_builder/websocket_server"
6
- require_relative "generator"
3
+ require 'active_support'
4
+ require 'active_support/core_ext'
7
5
 
8
6
  module StaticSiteBuilder
9
7
  # Main module for the static site builder gem
8
+
9
+ # Default WebSocket port for live reload server
10
+ DEFAULT_WS_PORT = 3001
11
+
12
+ # Default HTTP port for development server
13
+ DEFAULT_PORT = 3000
14
+
15
+ # Default layout name
16
+ DEFAULT_LAYOUT_NAME = 'application'
10
17
  end
18
+
19
+ require_relative "static_site_builder/version"
20
+ require_relative "static_site_builder/builder"
21
+ require_relative "static_site_builder/dev_server"
22
+ require_relative "static_site_builder/websocket_server"
23
+ require_relative "generator"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: static-site-builder
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lukasz Czapiewski
@@ -38,33 +38,47 @@ dependencies:
38
38
  - !ruby/object:Gem::Version
39
39
  version: '0.1'
40
40
  - !ruby/object:Gem::Dependency
41
- name: rake
41
+ name: listen
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - "~>"
45
45
  - !ruby/object:Gem::Version
46
- version: '13.0'
46
+ version: '3.8'
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
- version: '13.0'
53
+ version: '3.8'
54
54
  - !ruby/object:Gem::Dependency
55
- name: tty-prompt
55
+ name: meta-tags
56
56
  requirement: !ruby/object:Gem::Requirement
57
57
  requirements:
58
58
  - - "~>"
59
59
  - !ruby/object:Gem::Version
60
- version: '0.23'
60
+ version: '2.0'
61
61
  type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
- version: '0.23'
67
+ version: '2.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rake
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '13.0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '13.0'
68
82
  - !ruby/object:Gem::Dependency
69
83
  name: websocket
70
84
  requirement: !ruby/object:Gem::Requirement
@@ -93,10 +107,10 @@ dependencies:
93
107
  - - "~>"
94
108
  - !ruby/object:Gem::Version
95
109
  version: '3.0'
96
- description: 'A Ruby gem for building static HTML sites. Includes generator for creating
97
- projects and builder for compiling ERB/Phlex templates to static HTML. Choose your
98
- stack: ERB or Phlex, Importmap or ESBuild/Webpack/Vite, TailwindCSS or shadcn, Stimulus
99
- or React/Vue/Alpine.'
110
+ description: A Ruby gem for building static HTML sites. Uses ActionView to render
111
+ partials, layouts, and helpers using ERB. Compiles to static HTML with JavaScript
112
+ support. Simple and flexible - add your own JavaScript bundling and CSS processing
113
+ as needed. No backend required.
100
114
  email:
101
115
  - luke@mmtm.io
102
116
  executables:
@@ -104,7 +118,6 @@ executables:
104
118
  extensions: []
105
119
  extra_rdoc_files: []
106
120
  files:
107
- - ARCHITECTURE.md
108
121
  - CHANGELOG.md
109
122
  - LICENSE
110
123
  - README.md
@@ -113,6 +126,7 @@ files:
113
126
  - lib/generator.rb
114
127
  - lib/static_site_builder.rb
115
128
  - lib/static_site_builder/builder.rb
129
+ - lib/static_site_builder/dev_server.rb
116
130
  - lib/static_site_builder/version.rb
117
131
  - lib/static_site_builder/websocket_server.rb
118
132
  homepage: https://github.com/Ancez/static-site-builder
@@ -135,7 +149,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
135
149
  - !ruby/object:Gem::Version
136
150
  version: '0'
137
151
  requirements: []
138
- rubygems_version: 3.6.9
152
+ rubygems_version: 4.0.0
139
153
  specification_version: 4
140
- summary: Build static HTML sites with working JavaScript
154
+ summary: Build static HTML sites from ERB with working JavaScript
141
155
  test_files: []
data/ARCHITECTURE.md DELETED
@@ -1,61 +0,0 @@
1
- # Architecture
2
-
3
- This project is a **generator tool**, similar to `rails new`. It creates static site projects that use standard Ruby gems.
4
-
5
- ## Structure
6
-
7
- ```
8
- static-site-generator/
9
- ├── lib/
10
- │ └── generator.rb # Main generator logic
11
- ├── bin/
12
- │ └── generate # CLI entry point
13
- ├── exe/
14
- │ └── static-site-generator # Gem executable
15
- └── templates/ # Template files (future)
16
- ```
17
-
18
- ## How It Works
19
-
20
- 1. **Generator** (`static-site-generator` gem)
21
- - Creates project structure
22
- - Generates Gemfile with dependencies
23
- - Creates config files
24
- - Sets up build tasks
25
-
26
- 2. **Builder Gem** (`static-site-builder` gem) - **Separate gem**
27
- - Handles ERB/Phlex compilation
28
- - Manages asset copying
29
- - Generates importmap JSON
30
- - Outputs static HTML
31
-
32
- 3. **Standard Gems** - Used by generated sites
33
- - `importmap-rails` - Importmap support
34
- - `phlex-rails` - Phlex components
35
- - `static-site-builder` - Core builder functionality
36
-
37
- ## Generated Site Structure
38
-
39
- ```
40
- my-site/
41
- ├── Gemfile # Dependencies
42
- ├── package.json # JS dependencies (if needed)
43
- ├── Rakefile # Build tasks
44
- ├── config/
45
- │ └── importmap.rb # Importmap config
46
- ├── app/
47
- │ ├── views/
48
- │ ├── javascript/
49
- │ └── assets/
50
- └── lib/
51
- └── site_builder.rb # Thin wrapper using static-site-builder gem
52
- ```
53
-
54
- ## Separation of Concerns
55
-
56
- - **Generator** - Creates projects (this repo)
57
- - **Builder** - Compiles sites (`static-site-builder` gem)
58
- - **Standard Gems** - Provide functionality (importmap-rails, etc.)
59
-
60
- This keeps the generator lightweight and maintainable.
61
-