├── .gitignore ├── .ruby-version ├── LICENSE ├── README.md ├── Rakefile ├── TODO.md ├── bin └── rails_build ├── lib ├── rails_build.rb └── rails_build │ └── _lib.rb ├── notes └── serverless.rb └── rails_build.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle/ 2 | log/*.log 3 | pkg/ 4 | test/dummy/db/*.sqlite3 5 | test/dummy/db/*.sqlite3-journal 6 | test/dummy/log/*.log 7 | test/dummy/tmp/ 8 | *.gem 9 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.3.5 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Nonstandard 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | SELF 2 | ---- 3 | 4 | https://github.com/ahoward/rails_build 5 | 6 | TL;DR; 7 | ------ 8 | 9 | - install 10 | 11 | ```sh 12 | echo 'gem "raild_build"' >> Gemfile 13 | bundle 14 | ``` 15 | 16 | - setup 17 | 18 | ```sh 19 | rails_build --init 20 | ``` 21 | 22 | - build 23 | 24 | ```sh 25 | rails_build 26 | ``` 27 | 28 | - deploy? 29 | 30 | the contents of ./build/ are good to deploy to *any* static web host 31 | including netlify, vercel, an s3 bucket, or simply your app's own ./public 32 | directory in order to 'pre-cache' a ton of pages 33 | 34 | ps. if you want to preview your local static ./build i *highly* recommend 35 | 36 | https://github.com/copiousfreetime/launchy 37 | 38 | 39 | ABOUT 40 | ----- 41 | 42 | rails_build is a very small, fast enough, static site generator built on top 43 | of the rails you already know and love. 44 | 45 | it's been in production usage for close to a decade but i've been too busy 46 | to relase it until now. also, #wtf is up with javascript land?! 47 | 48 | it has a small set of dependencies, namely the `parallel` gem, and requires 49 | absolutely minimal configuration. it should be pretty darn self 50 | explanatory: 51 | 52 | ```ruby 53 | 54 | # file : ./config/rails_build.rb 55 | 56 | <<~________ 57 | 58 | this file should to enumerate all the urls you'd like to build 59 | 60 | the contents of your ./public directory, and any assets, are automaticaly 61 | included 62 | 63 | therefore you need only declare which dynamic urls, that is to say, 'routes' 64 | 65 | you would like included in your build 66 | 67 | it is not loaded except during build time, and will not affect your normal 68 | rails app in any way 69 | 70 | ________ 71 | 72 | 73 | RailsBuild.configure do |config| 74 | 75 | # most of the time you are going to want your root route included, which 76 | # will translate into an ./index.html being output in the build, as you 77 | # would expect. 78 | # 79 | 80 | config.urls << '/' 81 | 82 | # include any/all additional routes youd' like built thusly 83 | # 84 | 85 | Post.each do |post| 86 | config.urls << "/posts/#{ post.id }" 87 | end 88 | 89 | # thats it! - now just run `rails_build` and you are gtg 90 | 91 | end 92 | 93 | ``` 94 | 95 | CONFIGURATION 96 | ------------- 97 | 98 | although `rails_build` aims to be as zero-config as possible, it does expose 99 | a few configuration settings, which you may configure in 100 | `config/rails_build.rb`: 101 | 102 | - *config.urls* 103 | 104 | as shown above, the config has a list of urls that the build process will 105 | GET. this is a simple array and contains only '/' by default, the root 106 | route, such that the default unconfigured build would map '/' -> 107 | 'index.html' and not be empty. if your app does not have a root route, or 108 | you do not wish to include that route in your build, simply call 109 | `config.urls.clear` 110 | 111 | 112 | - *config.force_ssl* 113 | 114 | this one can be important. when `rails_build` starts your rails app, it 115 | does so with *RAILS_ENV=production*, such that the build is of production 116 | quality and speed. (you can change this by running `rails_build 117 | --env=development`, etc.). this can cause issues since the build runs on 118 | localhost, and rails (without `thruster`), has no facility for ssl 119 | termination. as such, you may want the the following 120 | 121 | ```ruby 122 | # file : ./config/environments/production.rb 123 | 124 | config.force_ssl = ENV['RAILS_BUILD'] ? false : true 125 | ``` 126 | 127 | - *config.index_html* 128 | 129 | controls the mapping of urls to build files, eg. 130 | 131 | ```ruby 132 | RailsBuild.configure do 133 | config.index_html = true # the default 134 | config.urls << "/post/42" #=> ./build/posts/42/index.html 135 | end 136 | 137 | # vs. 138 | 139 | RailsBuild.configure do 140 | config.index_html = false 141 | config.urls << "/post/42" #=> ./build/posts/42.html 142 | end 143 | ``` 144 | 145 | - *config.path* 146 | 147 | this is the path to the build config file itself, the default is 148 | `./config/rails_build.rb`, as you would expect 149 | 150 | - *config.trailing_slash* 151 | 152 | this is currently un-used, but maybe be used in the future in combination 153 | with `config.index_html`. it's default is the value of 154 | 155 | ```ruby 156 | Rails.application.default_url_options[:trailing_slash] 157 | ``` 158 | 159 | MOTIVATION 160 | ---------- 161 | 162 | why yet _another_ static site builder? why not hugo or, the 163 | other-soon-to-be-released-blazing-fast-one-i-am-building-on Roda? 164 | 165 | because: 166 | 167 | - there are a lot of Rails apps 168 | - nothing is as fast as static 169 | - Rails has no facility to 'pre-render' routes on deployment 170 | 171 | so, you can use this tool to leverage the code and the app you already have, 172 | and/or to utilize the entire Rails ecosystem, it's docs and gems, to build 173 | sophisticated sites without needing to learn yet another framework. 174 | 175 | SPEED 176 | ----- 177 | 178 | `rails_build` is as fast as your rails app. optimizing the build means 179 | optimizing your app so, the internet abounds with advice here, and all the 180 | production stuff one might normally do, caching, etc, is applicable for 181 | cranking things up. that being said, on a 'normal' laptop one should expect 182 | to crank through 1000 urls in 10s of seconds. this is what is meant by, 183 | _"good enough"_. 184 | 185 | ENV 186 | --- 187 | 188 | at build time, the following environment variables will be available to your 189 | app, such that i can _"know"_ that it is being built. they should be self 190 | explanatory: 191 | 192 | ```ruby 193 | ENV['RAILS_BUILD'] # a uuid fo the build 194 | ENV['RAILS_BUILD_TIME'] # a timestamp when the build was run 195 | 196 | ``` 197 | 198 | RTFM 199 | ---- 200 | 201 | as always, docs are worthless and ruby is beautiful, so #RTFC -> 202 | 203 | https://github.com/ahoward/rails_build/blob/main/bin/rails_build 204 | 205 | 206 | NOAI 207 | ---- 208 | the gem is released under a 'NOAI' license. 209 | 210 | any and all usage, in any way, shape, or form, in prohibited by any ai 211 | application, or llm. this includes indexing, editor support, knowing about, 212 | training on, or existing inside a commercial llm or training data set. iff 213 | (that is not a typo) your llm is oss, please contact me directly before 214 | including this source. 215 | 216 | any usage by openai, or google, is forbidden regardless. 217 | 218 | for all other purposes and usages, the license is the same as Ruby's. 219 | 220 | ... helping Ruby developers keep thier jobs since 1995. 221 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | This.author = "Ara T. Howard" 2 | This.email = "ara.t.howard@gmail.com" 3 | This.github = "ahoward" 4 | This.homepage = "https://github.com/#{ This.github }/#{ This.basename }" 5 | This.repo = "https://github.com/#{ This.github }/#{ This.basename }" 6 | 7 | task :license do 8 | open('LICENSE', 'w'){|fd| fd.puts "Ruby"} 9 | end 10 | 11 | task :default do 12 | puts((Rake::Task.tasks.map{|task| task.name.gsub(/::/,':')} - ['default']).sort) 13 | end 14 | 15 | task :test do 16 | run_tests! 17 | end 18 | 19 | namespace :test do 20 | task(:unit){ run_tests!(:unit) } 21 | task(:functional){ run_tests!(:functional) } 22 | task(:integration){ run_tests!(:integration) } 23 | end 24 | 25 | def run_tests!(which = nil) 26 | which ||= '**' 27 | test_dir = File.join(This.dir, "test") 28 | test_glob ||= File.join(test_dir, "#{ which }/**_test.rb") 29 | test_rbs = Dir.glob(test_glob).sort 30 | 31 | div = ('=' * 119) 32 | line = ('-' * 119) 33 | 34 | test_rbs.each_with_index do |test_rb, index| 35 | testno = index + 1 36 | command = "#{ This.ruby } -w -I ./lib -I ./test/lib #{ test_rb }" 37 | 38 | puts 39 | say(div, :color => :cyan, :bold => true) 40 | say("@#{ testno } => ", :bold => true, :method => :print) 41 | say(command, :color => :cyan, :bold => true) 42 | say(line, :color => :cyan, :bold => true) 43 | 44 | system(command) 45 | 46 | say(line, :color => :cyan, :bold => true) 47 | 48 | status = $?.exitstatus 49 | 50 | if status.zero? 51 | say("@#{ testno } <= ", :bold => true, :color => :white, :method => :print) 52 | say("SUCCESS", :color => :green, :bold => true) 53 | else 54 | say("@#{ testno } <= ", :bold => true, :color => :white, :method => :print) 55 | say("FAILURE", :color => :red, :bold => true) 56 | end 57 | say(line, :color => :cyan, :bold => true) 58 | 59 | exit(status) unless status.zero? 60 | end 61 | end 62 | 63 | 64 | task :gemspec do 65 | ignore_extensions = ['git', 'svn', 'tmp', /sw./, 'bak', 'gem'] 66 | ignore_directories = ['pkg'] 67 | ignore_files = ['test/log'] 68 | 69 | shiteless = 70 | lambda do |list| 71 | list.delete_if do |entry| 72 | next unless test(?e, entry) 73 | extension = File.basename(entry).split(%r/[.]/).last 74 | ignore_extensions.any?{|ext| ext === extension} 75 | end 76 | 77 | list.delete_if do |entry| 78 | next unless test(?d, entry) 79 | dirname = File.expand_path(entry) 80 | ignore_directories.any?{|dir| File.expand_path(dir) == dirname} 81 | end 82 | 83 | list.delete_if do |entry| 84 | next unless test(?f, entry) 85 | filename = File.expand_path(entry) 86 | ignore_files.any?{|file| File.expand_path(file) == filename} 87 | end 88 | end 89 | 90 | name = This.basename 91 | object = This.object 92 | version = This.version 93 | files = shiteless[Dir::glob("**/**")] 94 | executables = shiteless[Dir::glob("bin/*")].map{|exe| File.basename(exe)} 95 | summary = Util.unindent(This.summary).strip 96 | description = Util.unindent(This.description).strip 97 | license = This.license.strip 98 | 99 | if This.extensions.nil? 100 | This.extensions = [] 101 | extensions = This.extensions 102 | %w( Makefile configure extconf.rb ).each do |ext| 103 | extensions << ext if File.exist?(ext) 104 | end 105 | end 106 | extensions = [extensions].flatten.compact 107 | 108 | if This.dependencies.nil? 109 | dependencies = [] 110 | else 111 | case This.dependencies 112 | when Hash 113 | dependencies = This.dependencies.values 114 | when Array 115 | dependencies = This.dependencies 116 | end 117 | end 118 | 119 | template = 120 | if test(?e, 'gemspec.erb') 121 | Template{ IO.read('gemspec.erb') } 122 | else 123 | Template { 124 | <<-__ 125 | ## <%= name %>.gemspec 126 | # 127 | 128 | Gem::Specification::new do |spec| 129 | spec.name = <%= name.inspect %> 130 | spec.version = <%= version.inspect %> 131 | spec.required_ruby_version = '>= 3.0' 132 | spec.platform = Gem::Platform::RUBY 133 | spec.summary = <%= summary.inspect %> 134 | spec.description = <%= description.inspect %> 135 | spec.license = <%= license.inspect %> 136 | 137 | spec.files =\n<%= files.sort.pretty_inspect %> 138 | spec.executables = <%= executables.inspect %> 139 | 140 | spec.require_path = "lib" 141 | 142 | <% dependencies.each do |lib_version| %> 143 | spec.add_dependency(*<%= Array(lib_version).flatten.inspect %>) 144 | <% end %> 145 | 146 | spec.extensions.push(*<%= extensions.inspect %>) 147 | 148 | spec.author = <%= This.author.inspect %> 149 | spec.email = <%= This.email.inspect %> 150 | spec.homepage = <%= This.homepage.inspect %> 151 | end 152 | __ 153 | } 154 | end 155 | 156 | Fu.mkdir_p(This.pkgdir) 157 | gemspec = "#{ name }.gemspec" 158 | open(gemspec, "w"){|fd| fd.puts(template)} 159 | This.gemspec = gemspec 160 | end 161 | 162 | task :gem => [:clean, :gemspec] do 163 | Fu.mkdir_p(This.pkgdir) 164 | before = Dir['*.gem'] 165 | cmd = "gem build #{ This.gemspec }" 166 | `#{ cmd }` 167 | after = Dir['*.gem'] 168 | gem = ((after - before).first || after.first) or abort('no gem!') 169 | Fu.mv(gem, This.pkgdir) 170 | This.gem = File.join(This.pkgdir, File.basename(gem)) 171 | end 172 | 173 | task :README => [:readme] 174 | 175 | task :readme do 176 | samples = '' 177 | prompt = '~ > ' 178 | lib = This.lib 179 | version = This.version 180 | 181 | Dir['sample*/**/**.rb'].sort.each do |sample| 182 | link = "[#{ sample }](#{ This.repo }/blob/main/#{ sample })" 183 | samples << " #### <========< #{ link } >========>\n" 184 | 185 | cmd = "cat #{ sample }" 186 | samples << "```sh\n" 187 | samples << Util.indent(prompt + cmd, 2) << "\n" 188 | samples << "```\n" 189 | samples << "```ruby\n" 190 | samples << Util.indent(IO.binread(sample), 4) << "\n" 191 | samples << "```\n" 192 | 193 | samples << "\n" 194 | 195 | cmd = "ruby #{ sample }" 196 | samples << "```sh\n" 197 | samples << Util.indent(prompt + cmd, 2) << "\n" 198 | samples << "```\n" 199 | 200 | cmd = "ruby -e'STDOUT.sync=true; exec %(ruby -I ./lib #{ sample })'" 201 | oe = `#{ cmd } 2>&1` 202 | samples << "```txt\n" 203 | samples << Util.indent(oe, 4) << "\n" 204 | samples << "```\n" 205 | 206 | samples << "\n" 207 | end 208 | 209 | This.samples = samples 210 | 211 | template = 212 | case 213 | when test(?e, 'README.md.erb') 214 | Template{ IO.read('README.md.erb') } 215 | when test(?e, 'README.erb') 216 | Template{ IO.read('README.erb') } 217 | else 218 | Template { 219 | <<-__ 220 | NAME 221 | #{ lib } 222 | 223 | DESCRIPTION 224 | 225 | INSTALL 226 | gem install #{ lib } 227 | 228 | SAMPLES 229 | #{ samples } 230 | __ 231 | } 232 | end 233 | 234 | IO.binwrite('README.md', template) 235 | end 236 | 237 | task :clean do 238 | Dir[File.join(This.pkgdir, '**/**')].each{|entry| Fu.rm_rf(entry)} 239 | end 240 | 241 | task :release => [:dist, :gem] do 242 | gems = Dir[File.join(This.pkgdir, '*.gem')].flatten 243 | abort "which one? : #{ gems.inspect }" if gems.size > 1 244 | abort "no gems?" if gems.size < 1 245 | 246 | cmd = "gem push #{ This.gem }" 247 | puts cmd 248 | puts 249 | system(cmd) 250 | abort("cmd(#{ cmd }) failed with (#{ $?.inspect })") unless $?.exitstatus.zero? 251 | end 252 | 253 | 254 | 255 | 256 | 257 | BEGIN { 258 | # support for this rakefile 259 | # 260 | $VERBOSE = nil 261 | 262 | require 'ostruct' 263 | require 'erb' 264 | require 'fileutils' 265 | require 'rbconfig' 266 | require 'pp' 267 | 268 | # fu shortcut! 269 | # 270 | Fu = FileUtils 271 | 272 | # guess a bunch of stuff about this rakefile/environment based on the 273 | # 274 | This = OpenStruct.new 275 | 276 | This.file = File.expand_path(__FILE__) 277 | This.dir = File.dirname(This.file) 278 | This.pkgdir = File.join(This.dir, 'pkg') 279 | This.basename = File.basename(This.dir) 280 | 281 | # load actual shit _lib 282 | # 283 | _libpath = ["./lib/#{ This.basename }/_lib.rb", "./lib/#{ This.basename }.rb"] 284 | _lib = _libpath.detect{|l| test(?s, l)} 285 | 286 | abort "could not find a _lib in ./lib/ via #{ _libpath.join(':') }" unless _lib 287 | 288 | This._lib = _lib 289 | require This._lib 290 | 291 | # extract the name from the _lib 292 | # 293 | lines = IO.binread(This._lib).split("\n") 294 | re = %r`\A \s* (module|class) \s+ ([^\s]+) \s* \z`iomx 295 | name = nil 296 | lines.each do |line| 297 | match = line.match(re) 298 | if match 299 | name = match.to_a.last 300 | break 301 | end 302 | end 303 | unless name 304 | abort "could not extract `name` from #{ This._lib }" 305 | end 306 | This.name = name 307 | This.basename = This.name.gsub(/([A-Z][a-z])/){|ab| "_#{ ab }"}.gsub(/^_/, '').downcase 308 | 309 | # now, fully grok This 310 | # 311 | This.object = eval(This.name) 312 | This.version = This.object.version 313 | This.dependencies = This.object.dependencies 314 | This.summary = This.object.summary 315 | This.description = This.object.respond_to?(:description) ? This.object.description : This.summary 316 | This.license = This.object.respond_to?(:license) ? This.object.license : IO.binread('LICENSE').strip 317 | 318 | # discover full path to this ruby executable 319 | # 320 | c = RbConfig::CONFIG 321 | bindir = c["bindir"] || c['BINDIR'] 322 | ruby_install_name = c['ruby_install_name'] || c['RUBY_INSTALL_NAME'] || 'ruby' 323 | ruby_ext = c['EXEEXT'] || '' 324 | ruby = File.join(bindir, (ruby_install_name + ruby_ext)) 325 | This.ruby = ruby 326 | 327 | # some utils, alwayze teh utils... 328 | # 329 | module Util 330 | def indent(s, n = 2) 331 | s = unindent(s) 332 | ws = ' ' * n 333 | s.gsub(%r/^/, ws) 334 | end 335 | 336 | def unindent(s) 337 | indent = nil 338 | s.each_line do |line| 339 | next if line =~ %r/^\s*$/ 340 | indent = line[%r/^\s*/] and break 341 | end 342 | unindented = indent ? s.gsub(%r/^#{ indent }/, "") : s 343 | unindented.strip 344 | end 345 | extend self 346 | end 347 | 348 | # template support 349 | # 350 | class Template 351 | def Template.indent(string, n = 2) 352 | string = string.to_s 353 | n = n.to_i 354 | padding = (42 - 10).chr * n 355 | initial = %r/^#{ Regexp.escape(padding) }/ 356 | #require 'debug' 357 | #binding.break 358 | Util.indent(string, n).sub(initial, '') 359 | end 360 | def initialize(&block) 361 | @block = block 362 | @template = block.call.to_s 363 | end 364 | def expand(b=nil) 365 | ERB.new(Util.unindent(@template), trim_mode: '%<>-').result((b||@block).binding) 366 | end 367 | alias_method 'to_s', 'expand' 368 | end 369 | def Template(*args, &block) Template.new(*args, &block) end 370 | 371 | # os / platform support 372 | # 373 | module Platform 374 | def Platform.windows? 375 | (/cygwin|mswin|mingw|bccwin|wince|emx/ =~ RUBY_PLATFORM) != nil 376 | end 377 | 378 | def Platform.darwin? 379 | (/darwin/ =~ RUBY_PLATFORM) != nil 380 | end 381 | 382 | def Platform.mac? 383 | Platform.darwin? 384 | end 385 | 386 | def Platform.unix? 387 | !Platform.windows? 388 | end 389 | 390 | def Platform.linux? 391 | Platform.unix? and not Platform.darwin? 392 | end 393 | 394 | def Platform.jruby? 395 | RUBY_ENGINE == 'jruby' 396 | end 397 | end 398 | 399 | # colored console output support 400 | # 401 | This.ansi = { 402 | :clear => "\e[0m", 403 | :reset => "\e[0m", 404 | :erase_line => "\e[K", 405 | :erase_char => "\e[P", 406 | :bold => "\e[1m", 407 | :dark => "\e[2m", 408 | :underline => "\e[4m", 409 | :underscore => "\e[4m", 410 | :blink => "\e[5m", 411 | :reverse => "\e[7m", 412 | :concealed => "\e[8m", 413 | :black => "\e[30m", 414 | :red => "\e[31m", 415 | :green => "\e[32m", 416 | :yellow => "\e[33m", 417 | :blue => "\e[34m", 418 | :magenta => "\e[35m", 419 | :cyan => "\e[36m", 420 | :white => "\e[37m", 421 | :on_black => "\e[40m", 422 | :on_red => "\e[41m", 423 | :on_green => "\e[42m", 424 | :on_yellow => "\e[43m", 425 | :on_blue => "\e[44m", 426 | :on_magenta => "\e[45m", 427 | :on_cyan => "\e[46m", 428 | :on_white => "\e[47m" 429 | } 430 | def say(phrase, *args) 431 | options = args.last.is_a?(Hash) ? args.pop : {} 432 | options[:color] = args.shift.to_s.to_sym unless args.empty? 433 | keys = options.keys 434 | keys.each{|key| options[key.to_s.to_sym] = options.delete(key)} 435 | 436 | color = options[:color] 437 | bold = options.has_key?(:bold) 438 | 439 | parts = [phrase] 440 | parts.unshift(This.ansi[color]) if color 441 | parts.unshift(This.ansi[:bold]) if bold 442 | parts.push(This.ansi[:clear]) if parts.size > 1 443 | 444 | method = options[:method] || :puts 445 | 446 | Kernel.send(method, parts.join) 447 | end 448 | 449 | # always run out of the project dir 450 | # 451 | Dir.chdir(This.dir) 452 | } 453 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | now 2 | --- 3 | 4 | - configurable cp cmd 5 | - persistant http? 6 | 7 | next 8 | ---- 9 | 10 | done 11 | ---- 12 | -------------------------------------------------------------------------------- /bin/rails_build: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # encoding: utf-8 3 | 4 | module RailsBuild 5 | class CLI 6 | def CLI.usage 7 | <<~__ 8 | NAME 9 | rails_build 10 | 11 | SYNOPSIS 12 | a small, simple, bullet proof, and fast enough static site generator built on top of the rails you already know and love 13 | 14 | USAGE 15 | rails_build *(options) 16 | 17 | options: 18 | --help, -h : this message 19 | --init, -i : initialize ./config/rails_build.rb 20 | --parallel,--p : how many requests to make in parallel, default=n_cpus-1 21 | --env,--e : speciify the RAILS_ENV, default=production 22 | --url, -u : provide the url of the build server, do *not* start separate one 23 | --log, -l : specify the logfile, default=STDERR 24 | --verbose, -v : turn on verbose logging 25 | __ 26 | end 27 | 28 | def CLI.opts 29 | GetoptLong.new( 30 | [ '--help' , '-h' , GetoptLong::NO_ARGUMENT ] , 31 | [ '--init' , '-i' , GetoptLong::NO_ARGUMENT ] , 32 | [ '--parallel' , '-p' , GetoptLong::REQUIRED_ARGUMENT ] , 33 | [ '--env' , '-e' , GetoptLong::REQUIRED_ARGUMENT ] , 34 | [ '--url' , '-u' , GetoptLong::REQUIRED_ARGUMENT ] , 35 | [ '--server' , '-s' , GetoptLong::REQUIRED_ARGUMENT ] , 36 | [ '--log' , '-l' , GetoptLong::REQUIRED_ARGUMENT ] , 37 | [ '--verbose' , '-v' , GetoptLong::NO_ARGUMENT ] , 38 | ) 39 | end 40 | 41 | def run! 42 | @args = parse_args! 43 | @opts = parse_opts! 44 | 45 | case 46 | when @args[0] == 'help' || @opts[:help] 47 | usage! 48 | 49 | when @args[0] == 'init' || @opts[:init] 50 | init! 51 | 52 | else 53 | if @args.empty? 54 | build! 55 | else 56 | usage! 57 | exit(42) 58 | end 59 | end 60 | end 61 | 62 | def build! 63 | prepare! 64 | 65 | load_config! 66 | 67 | unless url 68 | clear_cache! 69 | start_server! 70 | end 71 | 72 | extract_urls! 73 | 74 | precompile_assets! 75 | 76 | rsync_public! 77 | 78 | parallel_build! 79 | 80 | finalize! 81 | end 82 | 83 | # 84 | def CLI.run!(*args, &block) 85 | new(*args, &block).run! 86 | end 87 | 88 | # 89 | attr_accessor :rails_root 90 | attr_accessor :url 91 | attr_accessor :server 92 | attr_accessor :directory 93 | attr_accessor :uuid 94 | attr_accessor :id 95 | attr_accessor :env 96 | attr_accessor :parallel 97 | 98 | # 99 | def prepare! 100 | # 101 | @rails_root = find_rails_root! 102 | 103 | # 104 | Dir.chdir(@rails_root) 105 | 106 | # 107 | @logger = Logger.new(@opts[:log] || STDERR) 108 | 109 | @env = @opts[:env] || ENV['RAILS_BUILD_ENV'] || ENV['RAILS_ENV'] 110 | @url = @opts[:url] || ENV['RAILS_BUILD_URL'] 111 | @parallel = @opts[:parallel] || ENV['RAILS_BUILD_PARALLEL'] 112 | @verbose = @opts[:verbose] || ENV['RAILS_BUILD_VERBOSE'] 113 | 114 | @uuid = ENV['RAILS_BUILD_UUID'] 115 | @time = ENV['RAILS_BUILD_TIME'] 116 | 117 | @env ||= 'production' 118 | @parallel ||= (Etc.nprocessors - 1) 119 | @uuid ||= SecureRandom.uuid 120 | @time ||= Time.now.utc 121 | 122 | unless @time.is_a?(Time) 123 | @time = Time.parse(@time.to_s).utc 124 | end 125 | 126 | @parallel = @parallel.to_i 127 | 128 | if ENV['RAILS_BUILD_DIRECTORY'] 129 | @build_directory = File.expand_path(ENV['RAILS_BUILD_DIRECTORY']) 130 | else 131 | @build_directory = File.join(@rails_root, 'builds') 132 | end 133 | 134 | @directory = File.join(@build_directory, @uuid) 135 | 136 | ENV['RAILS_ENV'] = @env 137 | ENV['RAILS_BUILD'] = @uuid 138 | ENV['RAILS_BUILD_ENV'] = @env 139 | ENV['RAILS_BUILD_TIME'] = @time.httpdate 140 | 141 | @urls = [] 142 | 143 | @started_at = Time.now 144 | 145 | mkdir! 146 | 147 | @server = Server.new(cli: self) 148 | end 149 | 150 | # 151 | def find_rails_root!(path = '.') 152 | rails_root = File.expand_path(path.to_s) 153 | 154 | loop do 155 | is_rails_root = %w[ app lib config public ].all?{|dir| test(?d, File.join(rails_root, dir))} 156 | 157 | if is_rails_root 158 | return(rails_root) 159 | else 160 | rails_root = File.dirname(rails_root) 161 | break if rails_root == '/' 162 | end 163 | end 164 | 165 | abort("could not find a rails_root in or above #{ path }!?") 166 | end 167 | 168 | # 169 | def parse_args! 170 | @args = ARGV.map{|arg| "#{ arg }"} 171 | end 172 | 173 | # 174 | def parse_opts! 175 | @opts = Hash.new 176 | 177 | CLI.opts.each do |opt, arg| 178 | key, val = opt.split('--').last, arg 179 | @opts[key.to_s.to_sym] = (val == '' ? true : val) 180 | end 181 | 182 | @opts 183 | end 184 | 185 | # 186 | def usage! 187 | lines = CLI.usage.strip.split(/\n/) 188 | n = lines[1].to_s.scan(/^\s+/).size 189 | indent = ' ' * n 190 | re = /^#{ Regexp.escape(indent) }/ 191 | usage = lines.map{|line| line.gsub(re, '')}.join("\n") 192 | STDERR.puts(usage) 193 | end 194 | 195 | # 196 | def init! 197 | config = SAMPLE_CONFIG 198 | 199 | path = './config/rails_build.rb' 200 | 201 | FileUtils.mkdir_p(File.dirname(path)) 202 | 203 | IO.binwrite(path, config) 204 | 205 | STDERR.puts("please review #{ path } before running `rails_build`") 206 | end 207 | 208 | # 209 | def mkdir! 210 | FileUtils.rm_rf(@directory) 211 | FileUtils.mkdir_p(@directory) 212 | end 213 | 214 | # 215 | def start_server! 216 | @url = 217 | nil 218 | 219 | @port = 220 | nil 221 | 222 | ports = 223 | (2000 .. 9000).to_a 224 | 225 | ports.each do |port| 226 | next unless port_open?(port) 227 | 228 | @server.start!(port:) 229 | 230 | timeout = 11 231 | t = Time.now.to_f 232 | i = 0 233 | 234 | @proto = @config.fetch('force_ssl') ? 'https' : 'http' 235 | url = nil 236 | 237 | loop do 238 | i += 1 239 | sleep(rand(0.42)) 240 | 241 | begin 242 | raise if port_open?(port) 243 | url = "#{ @proto }://0.0.0.0:#{ port }" 244 | @url = url 245 | @port = port 246 | break 247 | rescue Object => e 248 | if((Time.now.to_f - t) > timeout) 249 | abort("could not start server inside of #{ timeout } seconds") 250 | end 251 | end 252 | end 253 | 254 | break if @url 255 | end 256 | 257 | # barf if server could not be started 258 | # 259 | unless @url 260 | abort("could not start server on any of ports #{ ports.first } .. #{ ports.last }") 261 | else 262 | log(:info, "url: #{ @url }") 263 | end 264 | 265 | # 266 | @started_at = Time.now 267 | @url 268 | end 269 | 270 | # 271 | def load_config! 272 | unless test(?s, RailsBuild.config_path) 273 | log(:error, "no config found in #{ RailsBuild.config_path }") 274 | abort 275 | end 276 | 277 | Tempfile.open do |tmp| 278 | env = {RAILS_ENV:@env, RAILS_BUILD_CONFIG_DUMP:tmp.path} 279 | spawn('rails', 'runner', 'RailsBuild.dump_config!', env:) 280 | json = IO.binread(tmp.path) 281 | hash = JSON.parse(json) 282 | 283 | @config = Configuration.new(hash) 284 | end 285 | end 286 | 287 | def extract_urls! 288 | path = @config.path 289 | urls = @config.urls.uniq 290 | 291 | if urls.size == 0 292 | abort("failed to find any rails_build urls in:\n#{ @config.to_json }") 293 | end 294 | 295 | urls.map!{|url| url_for(url)} 296 | 297 | log(:info, "extracted #{ urls.size } url(s) to build from #{ path }") 298 | 299 | @urls = urls 300 | end 301 | 302 | # 303 | def clear_cache! 304 | spawn "rails tmp:cache:clear", error: false 305 | spawn "rails runner 'Rails.cache.clear'", error: false 306 | end 307 | 308 | # 309 | def precompile_assets! 310 | @asset_dir = File.join(@rails_root, "public/assets") 311 | @asset_tmp = false 312 | 313 | if test(?d, @asset_dir) 314 | @asset_tmp = File.join(@rails_root, "tmp/assets-build-#{ @uuid }") 315 | FileUtils.mv(@asset_dir, @asset_tmp) 316 | end 317 | 318 | spawn "RAILS_ENV=production DISABLE_SPRING=true rake assets:precompile" 319 | 320 | assets = Dir.glob(File.join(@rails_root, 'public/assets/**/**')) 321 | 322 | log(:info, "precompiled #{ assets.size } assets") 323 | 324 | ensure_non_digested_assets_also_exist!(assets) 325 | end 326 | 327 | # 328 | def rsync_public! 329 | commands = [ 330 | "rsync -avz ./public/ #{ @directory }", 331 | "cp -ru ./public/ #{ @directory }", 332 | proc{ FileUtils.cp_r('./public', @directory) } 333 | ] 334 | 335 | rsynced = false 336 | 337 | commands.each do |command| 338 | begin 339 | spawn(command) 340 | rsynced = true 341 | break 342 | rescue 343 | next 344 | end 345 | end 346 | 347 | unless rsynced 348 | abort "failed to rsync ./public to `#{ @directory }`" 349 | end 350 | 351 | count = 0 352 | Dir.glob(File.join(@directory, '**/**')).each{ count += 1 } 353 | 354 | log(:info, "rsync'd #{ count } files") 355 | 356 | if @asset_tmp 357 | FileUtils.rm_rf(@asset_dir) 358 | FileUtils.mv(@asset_tmp, @asset_dir) 359 | end 360 | end 361 | 362 | # 363 | def parallel_build! 364 | slices = 365 | @urls.each_slice(@parallel).map.to_a 366 | 367 | Parallel.each(slices, in_processes: @parallel) do |slice| 368 | Parallel.each(slice, in_threads: 4) do |url| 369 | uri = uri_for(url) 370 | path = path_for(uri) 371 | 372 | rpath = relative_path(path, :from => @directory) 373 | 374 | code = nil 375 | body = nil 376 | 377 | time = 378 | timing do 379 | code, body = http_get(uri) 380 | write_path(path, body) if code == 200 381 | end 382 | 383 | msg = "#{ url } -> /#{ rpath } (time:#{ time }, code:#{ code })" 384 | 385 | case code 386 | when 200 387 | log(:info, msg) 388 | else 389 | log(:error, msg) 390 | abort 391 | end 392 | end 393 | end 394 | 395 | @urls 396 | end 397 | 398 | # 399 | def finalize! 400 | @finished_at = Time.now 401 | 402 | elapsed = (@finished_at.to_f - @started_at.to_f) 403 | 404 | log(:info, "build time - #{ hms(elapsed) }") 405 | 406 | # because netlify refuses to deploy from a symlink! 407 | on_netlify = ENV['DEPLOY_PRIME_URL'].to_s =~ /netlify/ 408 | 409 | cp = on_netlify ? 'cp_r' : 'ln_s' 410 | 411 | build = File.join(@rails_root, 'build') 412 | 413 | FileUtils.rm_rf(build) 414 | FileUtils.send(cp, @directory, build) 415 | end 416 | 417 | def timing(&block) 418 | t = Time.now.to_f 419 | 420 | block.call 421 | 422 | (Time.now.to_f - t).round(2) 423 | end 424 | 425 | def http_get(url) 426 | uri = URI.parse(url.to_s) 427 | 428 | response = 429 | begin 430 | Net::HTTP.get_response(uri) 431 | rescue 432 | [code = 500, body = ''] 433 | end 434 | 435 | if response.is_a?(Net::HTTPRedirection) 436 | location = response['Location'] 437 | 438 | if location.to_s == url.to_s 439 | log(:fatal, "circular redirection on #{ url }") 440 | exit(1) 441 | end 442 | 443 | return http_get(location) 444 | end 445 | 446 | code = response.code.to_i rescue 500 447 | body = response.body.to_s rescue '' 448 | 449 | [code, body] 450 | end 451 | 452 | # 453 | def to_s 454 | @directory.to_s 455 | end 456 | 457 | # 458 | def log(level, *args, &block) 459 | @logger.send(level, *args, &block) 460 | end 461 | 462 | # 463 | def path_for(url) 464 | uri = uri_for(url) 465 | path = nil 466 | 467 | case 468 | when uri.path=='/' || uri.path=='.' 469 | path = File.join(@directory, 'index.html') 470 | 471 | else 472 | path = File.join(@directory, uri.path) 473 | 474 | dirname, basename = File.split(path) 475 | base, ext = basename.split('.', 2) 476 | 477 | case 478 | when uri.path.end_with?('/') 479 | path = 480 | File.join(path, 'index.html') 481 | 482 | when ext.nil? 483 | path = 484 | if @config.fetch('index_html') 485 | File.join(path, 'index.html') 486 | else 487 | path + '.html' 488 | end 489 | end 490 | end 491 | 492 | path 493 | end 494 | 495 | # 496 | def write_path(path, body) 497 | FileUtils.mkdir_p(File.dirname(path)) 498 | IO.binwrite(path, body) 499 | end 500 | 501 | # 502 | def ensure_non_digested_assets_also_exist!(assets) 503 | re = /(-{1}[a-z0-9]{32}*\.{1}){1}/ 504 | 505 | assets.each do |file| 506 | next if File.directory?(file) || file !~ re 507 | source = file.split('/') 508 | source.push(source.pop.gsub(re, '.')) 509 | non_digested = File.join(source) 510 | #log(:debug, "asset: #{ file } -> #{ non_digested }") 511 | FileUtils.ln(file, non_digested) 512 | end 513 | end 514 | 515 | # 516 | def url_for(url) 517 | uri = URI.parse(url.to_s) 518 | 519 | if uri.absolute? 520 | uri.path = path 521 | uri.to_s 522 | else 523 | rel = @url ? URI.parse(@url) : URI.parse('') 524 | rel.path = absolute_path_for(uri.path) 525 | rel.query = uri.query 526 | rel.fragment = uri.fragment 527 | rel.to_s 528 | end 529 | end 530 | 531 | # 532 | def uri_for(url) 533 | uri = url.is_a?(URI) ? url : URI.parse(url.to_s) 534 | end 535 | 536 | # 537 | def hms(seconds) 538 | return unless seconds 539 | "%02d:%02d:%02d" % hours_minutes_seconds(seconds) 540 | end 541 | 542 | # 543 | def hours_minutes_seconds(seconds) 544 | return unless seconds 545 | seconds = Float(seconds).to_i 546 | hours, seconds = seconds.divmod(3600) 547 | minutes, seconds = seconds.divmod(60) 548 | [hours.to_i, minutes.to_s, seconds] 549 | end 550 | 551 | # 552 | def port_open?(port, options = {}) 553 | seconds = options[:timeout] || 1 554 | ip = options[:ip] || '0.0.0.0' 555 | 556 | Timeout::timeout(seconds) do 557 | begin 558 | TCPSocket.new(ip, port).close 559 | false 560 | rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH 561 | true 562 | rescue Object 563 | false 564 | end 565 | end 566 | rescue Timeout::Error 567 | false 568 | end 569 | 570 | # 571 | def paths_for(*args) 572 | path = args.flatten.compact.join('/') 573 | path.gsub!(%r|[.]+/|, '/') 574 | path.squeeze!('/') 575 | path.sub!(%r|^/|, '') 576 | path.sub!(%r|/$|, '') 577 | paths = path.split('/') 578 | end 579 | 580 | # 581 | def absolute_path_for(*args) 582 | trailing_slash = args.join.end_with?('/') ? '/' : '' 583 | path = ('/' + paths_for(*args).join('/') + trailing_slash).squeeze('/') 584 | path unless path.strip.empty? 585 | end 586 | 587 | # 588 | def relative_path_for(*args) 589 | path = absolute_path_for(*args).sub(%r{^/+}, '') 590 | path unless path.strip.empty? 591 | end 592 | 593 | # 594 | def relative_path(path, *args) 595 | options = args.last.is_a?(Hash) ? args.pop : {} 596 | path = path.to_s 597 | relative = args.shift || options[:relative] || options[:to] || options[:from] 598 | if relative 599 | Pathname.new(path).relative_path_from(Pathname.new(relative.to_s)).to_s 600 | else 601 | relative_path_for(path) 602 | end 603 | end 604 | 605 | # 606 | def spawn(arg, *args, **kws) 607 | command = [arg, *args] 608 | 609 | env = kws.fetch(:env){ {} } 610 | error = kws.fetch(:error){ true } 611 | quiet = kws.fetch(:quiet){ false } 612 | stdin = kws.fetch(:stdin){ '' } 613 | 614 | env.transform_keys!(&:to_s) 615 | env.transform_values!(&:to_s) 616 | 617 | pid = nil 618 | status = nil 619 | stdout = nil 620 | stderr = nil 621 | 622 | Tempfile.open do |i| 623 | i.write(stdin) 624 | i.flush 625 | 626 | Tempfile.open do |o| 627 | Tempfile.open do |e| 628 | redirects = {:in => i.path, :out => o.path, :err => e.path} 629 | 630 | pid = Process.spawn(env, *command, redirects) 631 | 632 | Process.wait(pid) 633 | 634 | status = $?.exitstatus 635 | 636 | stdout = IO.binread(o.path) 637 | stderr = IO.binread(e.path) 638 | end 639 | end 640 | end 641 | 642 | unless status == 0 643 | unless kws[:quiet] == true 644 | log(:error, "#{ command.join(' ') } ###===>>> #{ status }\nSTDOUT:\n#{ stdout }\n\STDERR:\n#{ stderr }") 645 | exit(status) 646 | end 647 | end 648 | 649 | {command:, pid:, env:, status:, stdin:, stdout:, stderr:} 650 | end 651 | end 652 | 653 | # 654 | class Server 655 | attr_reader :pid 656 | 657 | def initialize(cli:) 658 | @cli = cli 659 | 660 | @env = @cli.env 661 | @directory = @cli.directory 662 | @rails_root = @cli.rails_root 663 | @parallel = @cli.parallel 664 | @uuid = @cli.uuid 665 | 666 | @thread = nil 667 | @pid = nil 668 | end 669 | 670 | def start!(port:) 671 | system("#{ version_command } >/dev/null 2>&1") || 672 | abort("app fails to load via: #{ version_command }") 673 | 674 | @cli.log(:info, "rails_build version: #{ RailsBuild.version }") 675 | @cli.log(:info, "build: #{ @directory }") 676 | 677 | q = Queue.new 678 | 679 | cmd = start_command_for(port) 680 | 681 | log = './tmp/rails_build_server.log' 682 | 683 | @cli.log(:info, "server: #{ cmd } > #{ log } 2>&1") 684 | 685 | @thread = Thread.new do 686 | Thread.current.abort_on_exception = true 687 | pipe = IO.popen("#{ cmd } > #{ log } 2>&1") 688 | q.push(pipe.pid) 689 | end 690 | 691 | @pid = q.pop 692 | 693 | @cli.log(:info, "pid: #{ @pid }") 694 | 695 | @assassin = Assassin.ate(@pid) 696 | 697 | at_exit{ stop! } 698 | end 699 | 700 | def version_command 701 | cmd_for( 702 | %W[ 703 | RAILS_ENV=#{ @env } 704 | DISABLE_SPRING=true 705 | 706 | rails --version 707 | ] 708 | ) 709 | end 710 | 711 | def start_command_for(port) 712 | cmd_for( 713 | %W[ 714 | RAILS_ENV=#{ @env } 715 | DISABLE_SPRING=true 716 | 717 | RAILS_BUILD=#{ @uuid } 718 | 719 | RAILS_SERVE_STATIC_FILES=true 720 | RAILS_LOG_TO_STDOUT=false 721 | WEB_CONCURRENCY=#{ @parallel.to_s } 722 | RAILS_MAX_THREADS=8 723 | 724 | rails server 725 | 726 | --environment=#{ @env } 727 | --port=#{ port } 728 | --binding=0.0.0.0 729 | ] 730 | ) 731 | end 732 | 733 | def cmd_for(arg, *args) 734 | [arg, *args].flatten.compact.join(' ').squeeze(' ').strip 735 | end 736 | 737 | def stop! 738 | kill!(@pid) 739 | @thread.kill 740 | @cli.log(:info, "stopped: #{ @pid }") 741 | end 742 | 743 | def kill!(pid) 744 | 42.times do 745 | begin 746 | Process.kill(0, pid) 747 | return(true) 748 | rescue Object => e 749 | if e.is_a?(Errno::ESRCH) 750 | Process.kill(-15, pid) rescue nil 751 | sleep(rand + rand) 752 | Process.kill(-9, pid) rescue nil 753 | end 754 | end 755 | sleep(0.42 + rand) 756 | end 757 | return(false) 758 | end 759 | end 760 | end 761 | 762 | END { 763 | require_relative '../lib/rails_build.rb' 764 | 765 | STDOUT.sync = true 766 | STDERR.sync = true 767 | 768 | RailsBuild::CLI.run! 769 | } 770 | 771 | module RailsBuild 772 | SAMPLE_CONFIG = <<~'__' 773 | <<~________ 774 | 775 | this file should to enumerate all the urls you'd like to build 776 | 777 | the contents of your ./public directory, and any assets, are automaticaly included 778 | 779 | therefore you need only declare which dynamic urls, that is to say, 'routes' 780 | 781 | you would like included in your build 782 | 783 | it is not loaded except during build time, and will not affect your normal rails app in any way 784 | 785 | ________ 786 | 787 | 788 | RailsBuild.configure do |config| 789 | 790 | # most of the time you are going to want your route included, which will 791 | # translate into an ./index.html being output in the build 792 | # 793 | 794 | config.urls << '/' 795 | 796 | # include any/all additional routes youd' like built thusly 797 | # 798 | 799 | Post.each do |post| 800 | config.urls << "/posts/#{ post.id }" 801 | end 802 | 803 | # thats it! - now just run `rails_build` and you are GTG 804 | 805 | end 806 | __ 807 | end 808 | -------------------------------------------------------------------------------- /lib/rails_build.rb: -------------------------------------------------------------------------------- 1 | require_relative 'rails_build/_lib.rb' 2 | 3 | RailsBuild.load_dependencies! 4 | 5 | module RailsBuild 6 | def RailsBuild.configure(&block) 7 | @configure = block 8 | end 9 | 10 | def RailsBuild.dump_config!(path: config_path, dump: config_dump_path) 11 | config = RailsBuild.load_config!(path:) 12 | 13 | json = JSON.pretty_generate(config.as_json) 14 | 15 | dirname = File.dirname(dump) 16 | FileUtils.mkdir_p(dirname) 17 | IO.binwrite(dump, json) 18 | 19 | dump 20 | end 21 | 22 | def RailsBuild.load_config!(path: config_path) 23 | Kernel.load(path) 24 | 25 | if @configure 26 | @configure.call(RailsBuild.configuration) 27 | end 28 | 29 | RailsBuild.configuration 30 | end 31 | 32 | def RailsBuild.config_path 33 | case 34 | when ENV['RAILS_BUILD_CONFIG'] 35 | ENV['RAILS_BUILD_CONFIG'] 36 | when defined?(Rails) 37 | Rails.application.root.join('config/rails_build.rb') 38 | else 39 | './config/rails_build.rb' 40 | end.to_s 41 | end 42 | 43 | def RailsBuild.config_dump_path 44 | case 45 | when ENV['RAILS_BUILD_CONFIG_DUMP'] 46 | ENV['RAILS_BUILD_CONFIG_DUMP'] 47 | when defined?(Rails) 48 | Rails.application.root.join('tmp/rails_build.json') 49 | else 50 | './tmp/rails_build.json' 51 | end.to_s 52 | end 53 | 54 | def RailsBuild.configuration 55 | @configuration ||= Configuration.new 56 | end 57 | 58 | def RailsBuild.config 59 | RailsBuild.configuration 60 | end 61 | 62 | class Configuration < Hash 63 | ATTRS = %w[ 64 | path 65 | trailing_slash 66 | force_ssl 67 | urls 68 | index_html 69 | ] 70 | 71 | def Configuration.defaults 72 | defaults = { 73 | path: RailsBuild.config_path, 74 | trailing_slash: (defined?(Rails) ? !!Rails.application.default_url_options[:trailing_slash] : false), 75 | force_ssl: (defined?(Rails) ? !!Rails.configuration.force_ssl : false), 76 | urls: %w[ / ], 77 | index_html: true, 78 | } 79 | end 80 | 81 | def Configuration.stringify_keys!(hash) 82 | hash.transform_keys!(&:to_s) 83 | 84 | hash.each do |key, val| 85 | if val.is_a?(Hash) 86 | Configuration.stringify_keys!(val) 87 | end 88 | end 89 | 90 | hash 91 | end 92 | 93 | def initialize(hash = {}) 94 | if hash.empty? 95 | hash = Configuration.defaults 96 | end 97 | 98 | hash.each{|attr, value| send("#{ attr }=", value)} 99 | 100 | Configuration.stringify_keys!(self) 101 | end 102 | 103 | ATTRS.each do |attr| 104 | getter = "#{ attr }" 105 | setter = "#{ attr }=" 106 | query = "#{ attr }?" 107 | 108 | define_method(getter) do |*args| 109 | case 110 | when args.size == 0 111 | fetch(attr) 112 | when args.size == 1 113 | value = args.first 114 | send(setter, value) 115 | else 116 | raise ArguementError.new(args.inspect) 117 | end 118 | end 119 | 120 | define_method(setter) do |value| 121 | update(attr => value) 122 | end 123 | 124 | define_method(query) do 125 | !!fetch(attr) 126 | end 127 | end 128 | 129 | def to_json(*args, **kws, &block) 130 | JSON.pretty_generate(self) 131 | end 132 | end 133 | 134 | class Assassin 135 | def Assassin.ate(*args, &block) 136 | new(*args, &block) 137 | end 138 | 139 | attr_accessor :parent_pid 140 | attr_accessor :child_pid 141 | attr_accessor :pid 142 | attr_accessor :path 143 | 144 | def initialize(child_pid, options = {}) 145 | @child_pid = child_pid.to_s.to_i 146 | @parent_pid = Process.pid 147 | @options = Assassin.options_for(options) 148 | @pid, @path = Assassin.generate(@child_pid, @options) 149 | end 150 | 151 | def Assassin.options_for(options) 152 | options.inject({}){|h, kv| k,v = kv; h.update(k.to_s.to_sym => v)} 153 | end 154 | 155 | def Assassin.generate(child_pid, options = {}) 156 | path = File.join(Dir.tmpdir, "assassin-#{ child_pid }-#{ SecureRandom.uuid }.rb") 157 | script = Assassin.script_for(child_pid, options) 158 | IO.binwrite(path, script) 159 | pid = Process.spawn "ruby #{ path }" 160 | [pid, path] 161 | end 162 | 163 | def Assassin.script_for(child_pid, options = {}) 164 | parent_pid = Process.pid 165 | delay = (options[:delay] || 0.42).to_f 166 | 167 | script = <<-__ 168 | Process.daemon 169 | 170 | require 'fileutils' 171 | at_exit{ FileUtils.rm_f(__FILE__) } 172 | 173 | parent_pid = #{ parent_pid } 174 | child_pid = #{ child_pid } 175 | delay = #{ delay } 176 | 177 | m = 24*60*60 178 | n = 42 179 | 180 | m.times do 181 | begin 182 | Process.kill(0, parent_pid) 183 | rescue Object => e 184 | sleep(delay) 185 | 186 | if e.is_a?(Errno::ESRCH) 187 | n.times do 188 | begin 189 | Process.kill(15, child_pid) rescue nil 190 | sleep(rand + rand) 191 | Process.kill(9, child_pid) rescue nil 192 | sleep(rand + rand) 193 | Process.kill(0, child_pid) 194 | rescue Errno::ESRCH 195 | break 196 | end 197 | end 198 | end 199 | 200 | exit 201 | end 202 | 203 | sleep(1) 204 | end 205 | __ 206 | 207 | return script 208 | end 209 | end 210 | end 211 | -------------------------------------------------------------------------------- /lib/rails_build/_lib.rb: -------------------------------------------------------------------------------- 1 | module RailsBuild 2 | VERSION = '2.4.5' unless defined?(VERSION) 3 | 4 | class << self 5 | def version 6 | VERSION 7 | end 8 | 9 | def repo 10 | 'https://github.com/ahoward/rails_build' 11 | end 12 | 13 | def summary 14 | <<~____ 15 | a small, simple, bullet proof, and fast enough static site generator built on top of the rails you already know and love 16 | ____ 17 | end 18 | 19 | def description 20 | <<~____ 21 | rails_build is a very small, fast enough, static site generator built 22 | on top of the rails you already know and love. 23 | 24 | it's been in production usage for close to a decade but i've been too 25 | busy to relase it until now. also, #wtf is up with javascript land?! 26 | 27 | it has a small set of dependencies, namely the `parallel` gem, and 28 | requires absolutely minimal configuration. it should be pretty darn 29 | self explanatory: 30 | ____ 31 | end 32 | 33 | def libs 34 | %w[ 35 | fileutils pathname thread socket timeout time uri etc securerandom logger json tempfile net/http 36 | ] 37 | end 38 | 39 | def dependencies 40 | { 41 | 'parallel' => 42 | ['parallel', '~> 1.26'], 43 | 44 | 'getoptlong' => 45 | ['getoptlong', '~> 0.2'], 46 | } 47 | end 48 | 49 | def libdir(*args, &block) 50 | @libdir ||= File.dirname(File.expand_path(__FILE__)) 51 | args.empty? ? @libdir : File.join(@libdir, *args) 52 | ensure 53 | if block 54 | begin 55 | $LOAD_PATH.unshift(@libdir) 56 | block.call 57 | ensure 58 | $LOAD_PATH.shift 59 | end 60 | end 61 | end 62 | 63 | def load(*libs) 64 | libs = libs.join(' ').scan(/[^\s+]+/) 65 | libdir { libs.each { |lib| Kernel.load(lib) } } 66 | end 67 | 68 | def load_dependencies! 69 | libs.each do |lib| 70 | require lib 71 | end 72 | 73 | begin 74 | require 'rubygems' 75 | rescue LoadError 76 | nil 77 | end 78 | 79 | has_rubygems = defined?(gem) 80 | 81 | dependencies.each do |lib, dependency| 82 | gem(*dependency) if has_rubygems 83 | require(lib) 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /notes/serverless.rb: -------------------------------------------------------------------------------- 1 | 2 | 3 | app = Rails.application 4 | 5 | app.configure do 6 | #app.config.force_ssl = false 7 | #app.config.hosts.clear 8 | end 9 | 10 | url = '/' 11 | 12 | session = ActionDispatch::Integration::Session.new(app) 13 | 14 | uuids = [] 15 | Dir.glob('public/ro/pages/*') do |path| 16 | uuid = File.basename(path) 17 | uuids << uuid 18 | end 19 | 20 | urls = [] 21 | uuids.each do |uuid| 22 | urls << "/md/#{ uuid }" 23 | end 24 | 25 | 26 | #uuid = '019400b0-1a56-7b12-bee4-2b607c906359' 27 | #url = "/md/#{ uuid }" 28 | 29 | FileUtils.mkdir_p('./md') 30 | 31 | a = Time.now 32 | 33 | slices = [] 34 | 35 | urls.each_slice(100) do |slice| 36 | slices << slice 37 | end 38 | 39 | Parallel.each(slices, in_proccess: 4) do |slice| 40 | #session = ActionDispatch::Integration::Session.new(app) 41 | 42 | Parallel.each(slice, in_threads: 4) do |url| 43 | Rails.logger.silence do 44 | status = session.get(url) 45 | body = session.response.body 46 | 47 | path = '.' + url + '.html' 48 | IO.binwrite(path, body) 49 | puts path 50 | end 51 | end 52 | end 53 | 54 | 55 | b = Time.now 56 | puts((b - a).round(2)) 57 | 58 | -------------------------------------------------------------------------------- /rails_build.gemspec: -------------------------------------------------------------------------------- 1 | ## rails_build.gemspec 2 | # 3 | 4 | Gem::Specification::new do |spec| 5 | spec.name = "rails_build" 6 | spec.version = "2.4.5" 7 | spec.required_ruby_version = '>= 3.0' 8 | spec.platform = Gem::Platform::RUBY 9 | spec.summary = "a small, simple, bullet proof, and fast enough static site generator built on top of the rails you already know and love" 10 | spec.description = "rails_build is a very small, fast enough, static site generator built\non top of the rails you already know and love.\n\nit's been in production usage for close to a decade but i've been too\nbusy to relase it until now. also, #wtf is up with javascript land?!\n\nit has a small set of dependencies, namely the `parallel` gem, and\nrequires absolutely minimal configuration. it should be pretty darn\nself explanatory:" 11 | spec.license = "Nonstandard" 12 | 13 | spec.files = 14 | ["LICENSE", 15 | "README.md", 16 | "Rakefile", 17 | "TODO.md", 18 | "bin", 19 | "bin/rails_build", 20 | "lib", 21 | "lib/rails_build", 22 | "lib/rails_build.rb", 23 | "lib/rails_build/_lib.rb", 24 | "rails_build.gemspec"] 25 | 26 | spec.executables = ["rails_build"] 27 | 28 | spec.require_path = "lib" 29 | 30 | 31 | spec.add_dependency(*["parallel", "~> 1.26"]) 32 | 33 | spec.add_dependency(*["getoptlong", "~> 0.2"]) 34 | 35 | 36 | spec.extensions.push(*[]) 37 | 38 | spec.author = "Ara T. Howard" 39 | spec.email = "ara.t.howard@gmail.com" 40 | spec.homepage = "https://github.com/ahoward/rails_build" 41 | end 42 | --------------------------------------------------------------------------------