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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -64
- data/README.md +92 -286
- data/bin/generate +3 -40
- data/exe/static-site-builder +1 -40
- data/lib/generator.rb +627 -751
- data/lib/static_site_builder/builder.rb +265 -467
- data/lib/static_site_builder/dev_server.rb +119 -0
- data/lib/static_site_builder/version.rb +1 -1
- data/lib/static_site_builder/websocket_server.rb +97 -21
- data/lib/static_site_builder.rb +17 -4
- metadata +31 -17
- data/ARCHITECTURE.md +0 -61
data/lib/generator.rb
CHANGED
|
@@ -6,68 +6,44 @@ require "erb"
|
|
|
6
6
|
require "json"
|
|
7
7
|
|
|
8
8
|
module StaticSiteBuilder
|
|
9
|
-
#
|
|
9
|
+
# Generates new static site projects.
|
|
10
10
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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
|
|
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
|
-
#
|
|
34
|
-
|
|
19
|
+
# Reference StaticSiteBuilder constants for consistency
|
|
20
|
+
DEFAULT_PORT = StaticSiteBuilder::DEFAULT_PORT
|
|
21
|
+
DEFAULT_WS_PORT = StaticSiteBuilder::DEFAULT_WS_PORT
|
|
35
22
|
|
|
36
|
-
#
|
|
23
|
+
# Initializes a new generator instance.
|
|
37
24
|
#
|
|
38
|
-
# @param app_name [String]
|
|
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
|
-
#
|
|
32
|
+
# Generates the complete static site project.
|
|
56
33
|
#
|
|
57
|
-
# Creates
|
|
58
|
-
#
|
|
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 "
|
|
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
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
91
|
+
source 'https://rubygems.org'
|
|
120
92
|
|
|
121
|
-
#{gems.map { |g| %(gem
|
|
93
|
+
#{gems.map { |g| %(gem '#{g}') }.join("\n")}
|
|
122
94
|
RUBY
|
|
123
95
|
|
|
124
|
-
write_file(
|
|
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
|
|
104
|
+
def create_sitemap_config
|
|
301
105
|
content = <<~RUBY
|
|
302
106
|
# frozen_string_literal: true
|
|
303
107
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
348
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
<%=
|
|
172
|
+
<%= yield %>
|
|
419
173
|
</main>
|
|
420
174
|
|
|
421
|
-
|
|
422
|
-
|
|
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(
|
|
199
|
+
write_file('app/views/layouts/application.html.erb', content)
|
|
428
200
|
end
|
|
429
201
|
|
|
430
|
-
def
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
459
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
319
|
+
desc 'Generate sitemap from actual pages'
|
|
320
|
+
task :sitemap do
|
|
321
|
+
require './config/sitemap'
|
|
322
|
+
end
|
|
323
|
+
end
|
|
506
324
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
JS
|
|
375
|
+
sleep 0.5
|
|
376
|
+
end
|
|
377
|
+
}
|
|
517
378
|
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
539
|
-
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
|
|
546
|
-
|
|
451
|
+
server.start
|
|
452
|
+
end
|
|
453
|
+
end
|
|
547
454
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
app.mount('#app')
|
|
551
|
-
})
|
|
552
|
-
JS
|
|
455
|
+
task default: 'build:all'
|
|
456
|
+
RUBY
|
|
553
457
|
|
|
554
|
-
write_file(
|
|
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
|
-
|
|
566
|
-
|
|
463
|
+
def create_site_builder
|
|
464
|
+
content = <<~RUBY
|
|
465
|
+
# frozen_string_literal: true
|
|
567
466
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
|
|
575
|
-
|
|
488
|
+
def build
|
|
489
|
+
copy_javascript
|
|
490
|
+
copy_stylesheets
|
|
491
|
+
compile_views
|
|
492
|
+
copy_public
|
|
493
|
+
write_reload_file
|
|
494
|
+
end
|
|
576
495
|
|
|
577
|
-
|
|
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
|
-
|
|
592
|
-
|
|
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
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
end
|
|
501
|
+
if @views_dir.exist?
|
|
502
|
+
view = build_action_view
|
|
503
|
+
layout_virtual = 'layouts/application'
|
|
624
504
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
-
|
|
665
|
-
|
|
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
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
-
|
|
695
|
-
<<~'RUBY'
|
|
696
|
-
require "fileutils"
|
|
697
|
-
require "pathname"
|
|
517
|
+
rendered = view.render(template: virtual_path, layout: layout_virtual)
|
|
698
518
|
|
|
699
|
-
|
|
700
|
-
|
|
519
|
+
FileUtils.mkdir_p(output_path.dirname)
|
|
520
|
+
File.write(output_path, rendered)
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
end
|
|
701
525
|
|
|
702
|
-
|
|
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
|
-
|
|
705
|
-
ENV["LIVE_RELOAD"] = "true"
|
|
706
|
-
Rake::Task["build:all"].invoke
|
|
531
|
+
load_and_include_helpers(view_class)
|
|
707
532
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
533
|
+
view = view_class.new(lookup_context, {}, nil)
|
|
534
|
+
view
|
|
535
|
+
end
|
|
711
536
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
|
|
718
|
-
|
|
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
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
|
|
731
|
-
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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
|
-
|
|
812
|
-
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
Rake::Task["build:all"].invoke
|
|
822
|
-
end
|
|
823
|
-
end
|
|
701
|
+
@clients.each do |client|
|
|
702
|
+
safe_close(client)
|
|
703
|
+
end
|
|
824
704
|
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
-
|
|
833
|
-
RUBY
|
|
834
|
-
end
|
|
713
|
+
private
|
|
835
714
|
|
|
836
|
-
|
|
837
|
-
|
|
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
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
847
|
-
|
|
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
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
790
|
+
def create_frame(message)
|
|
791
|
+
data = message.dup.force_encoding('BINARY')
|
|
792
|
+
length = data.bytesize
|
|
864
793
|
|
|
865
|
-
|
|
866
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
886
|
-
|
|
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(
|
|
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
|
|
859
|
+
Build for deployment:
|
|
947
860
|
|
|
948
861
|
```bash
|
|
949
|
-
rake build:all #
|
|
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
|
-
|
|
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
|
-
|
|
972
|
-
|
|
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
|
-
|
|
978
|
-
if builds.empty?
|
|
979
|
-
"echo 'No bundling needed'"
|
|
980
|
-
else
|
|
981
|
-
builds.join(" && ")
|
|
982
|
-
end
|
|
983
|
-
end
|
|
875
|
+
## CSS
|
|
984
876
|
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
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
|
-
|
|
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
|