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