rails2static 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: dd83d25a90cc44c2e6154ccc5ac5c92743a08789bdf4570a1aed6ef6b8fd6545
4
+ data.tar.gz: 61ab9a728ee7ad23e0d2fcf2953dee3fa89d2905ce1f4096775804eea51beae8
5
+ SHA512:
6
+ metadata.gz: 882585a94c5c0dfe240603d401a7fc51da998182c3f8300aca96212af698975b3edc9d5198b183a2f75936d60108d2fb92d58f874c9d4c143be79faadc773405
7
+ data.tar.gz: c20cf0177c17118ac081363f032de783b559cdd7f3dc2f7cfbd096317501e8329ea74f32cb137e28b0053e424597b711c524f665958517050900d46196b73c1d
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ian Terrell
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # Rails2static
2
+
3
+ Generate a deployable static site from a server-rendered Rails app. Add the gem, run a rake task, and get a `_site/` directory with HTML, CSS, JS, and images ready to deploy anywhere.
4
+
5
+ Rails2static uses [Rack::Test](https://github.com/rack/rack-test) to call your Rails app directly — no running server needed. It crawls your app through the full middleware stack, rewrites links for static hosting, and collects all referenced assets.
6
+
7
+ ## Installation
8
+
9
+ Add to your Gemfile:
10
+
11
+ ```ruby
12
+ gem "rails2static"
13
+ ```
14
+
15
+ Then `bundle install`.
16
+
17
+ Generate the initializer:
18
+
19
+ ```sh
20
+ rails generate rails2static:install
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```sh
26
+ rake rails2static
27
+ # or
28
+ rake static:generate
29
+ ```
30
+
31
+ This will:
32
+
33
+ 1. BFS-crawl your app starting from `/`
34
+ 2. Rewrite internal links for static hosting
35
+ 3. Collect all referenced CSS, JS, images, and fonts
36
+ 4. Copy `public/` directory files (favicon, robots.txt, etc.)
37
+ 5. Write everything to `_site/`
38
+
39
+ Preview the result locally:
40
+
41
+ ```sh
42
+ rake static:preview
43
+ ```
44
+
45
+ This starts a local server at http://localhost:8000 serving your generated site.
46
+
47
+ ## Configuration
48
+
49
+ The install generator creates `config/initializers/rails2static.rb` with all options commented out. Uncomment and adjust as needed:
50
+
51
+ ```ruby
52
+ Rails2static.configure do |config|
53
+ config.output_dir = "_site" # Output directory (default: "_site")
54
+ config.entry_paths = ["/"] # Starting paths for the crawl (default: ["/"])
55
+ config.host = "www.example.com" # Host header sent with requests (default: "www.example.com")
56
+ config.trailing_slash = true # /about -> /about/index.html (default: true)
57
+ # false -> /about -> /about.html
58
+ config.exclude_patterns = [%r{/admin}] # Regex patterns to skip (default: [])
59
+ config.include_assets = true # Collect CSS/JS/images (default: true)
60
+ config.max_pages = 10_000 # Safety limit on pages crawled (default: 10,000)
61
+ end
62
+ ```
63
+
64
+ ## How it works
65
+
66
+ - **Crawler** — BFS from entry paths using `Rack::Test::Session`. Normalizes paths, follows redirects internally, detects cycles, skips external links, warns on 404s.
67
+ - **Link Extractor** — Parses HTML with Nokogiri for `<a>`, `<link>`, `<script>`, `<img>`, `srcset`, `<video>`, and `<audio>` references.
68
+ - **Link Rewriter** — Rewrites `<a href>` values so links work on static hosts. Skips external links, anchors, mailto/tel, and paths with file extensions.
69
+ - **Asset Collector** — Fetches all asset URLs found in HTML via Rack::Test. Parses CSS for `url()` and `@import` references and fetches those recursively.
70
+ - **Writer** — Writes HTML as `path/index.html` (trailing slash mode) or `path.html`. Non-HTML content keeps its original path. Binary content uses `binwrite`.
71
+
72
+ ## Demo
73
+
74
+ The `demo/` directory contains a fully working Rails blog app that showcases rails2static. It uses SQLite with a pre-seeded database so you can try it immediately:
75
+
76
+ ```sh
77
+ cd demo
78
+ bundle install
79
+ rake rails2static # generates _site/
80
+ rake static:preview # serves it at http://localhost:8000
81
+ ```
82
+
83
+ The demo app includes:
84
+
85
+ - Posts with categories (HABTM), an about page, and a shared layout
86
+ - Admin scaffolds at `/admin` (excluded from static output)
87
+ - 4 seed posts, 3 categories, and an about page
88
+
89
+ ## License
90
+
91
+ MIT
@@ -0,0 +1,11 @@
1
+ module Rails2static
2
+ class InstallGenerator < Rails::Generators::Base
3
+ source_root File.expand_path("templates", __dir__)
4
+
5
+ desc "Creates a Rails2static initializer"
6
+
7
+ def copy_initializer
8
+ template "rails2static.rb", "config/initializers/rails2static.rb"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,23 @@
1
+ Rails2static.configure do |config|
2
+ # Output directory for the generated static site
3
+ # config.output_dir = "_site"
4
+
5
+ # Starting paths for the crawl
6
+ # config.entry_paths = ["/"]
7
+
8
+ # Host header sent with requests
9
+ # config.host = "www.example.com"
10
+
11
+ # URL style: true -> /about/index.html (works with most static hosts)
12
+ # false -> /about.html
13
+ # config.trailing_slash = true
14
+
15
+ # Regex patterns for paths to skip
16
+ # config.exclude_patterns = [%r{/admin}]
17
+
18
+ # Collect and write CSS, JS, images, and fonts
19
+ # config.include_assets = true
20
+
21
+ # Safety limit on pages crawled
22
+ # config.max_pages = 10_000
23
+ end
@@ -0,0 +1,103 @@
1
+ require "rack/test"
2
+ require "set"
3
+
4
+ module Rails2static
5
+ class AssetCollector
6
+ include Rack::Test::Methods
7
+
8
+ attr_reader :assets
9
+
10
+ def initialize(app:, config: Rails2static.configuration)
11
+ @app = app
12
+ @config = config
13
+ @assets = []
14
+ @fetched = Set.new
15
+ end
16
+
17
+ def app
18
+ @app
19
+ end
20
+
21
+ def collect(pages)
22
+ asset_urls = Set.new
23
+
24
+ pages.each do |page|
25
+ next unless page.html?
26
+
27
+ extractor = LinkExtractor.new(page.body, base_path: page.path)
28
+ extractor.asset_urls.each { |url| asset_urls.add(url) }
29
+ end
30
+
31
+ asset_urls.each { |url| fetch_asset(url) }
32
+
33
+ @assets
34
+ end
35
+
36
+ private
37
+
38
+ def fetch_asset(path)
39
+ return if @fetched.include?(path)
40
+
41
+ @fetched.add(path)
42
+
43
+ header("Host", @config.host)
44
+ get(path)
45
+
46
+ unless last_response.status == 200
47
+ log(" ASSET WARNING: #{last_response.status} #{path}")
48
+ return
49
+ end
50
+
51
+ content_type = last_response.content_type.to_s
52
+ body = last_response.body
53
+
54
+ page = Page.new(path: path, status: 200, content_type: content_type, body: body)
55
+ @assets << page
56
+
57
+ if page.css?
58
+ extract_css_refs(body, path).each { |ref| fetch_asset(ref) }
59
+ end
60
+
61
+ log(" ASSET #{path} (#{content_type})")
62
+ end
63
+
64
+ def extract_css_refs(css_body, base_path)
65
+ refs = []
66
+ dir = File.dirname(base_path)
67
+
68
+ css_body.scan(/url\(\s*['"]?([^'")]+?)['"]?\s*\)/) do |match|
69
+ url = match[0].strip
70
+ next if url.start_with?("data:")
71
+ next if url.match?(%r{\Ahttps?://})
72
+
73
+ resolved = if url.start_with?("/")
74
+ url
75
+ else
76
+ File.join(dir, url)
77
+ end
78
+
79
+ clean = resolved.split("?").first.split("#").first
80
+ refs << clean
81
+ end
82
+
83
+ css_body.scan(/@import\s+['"]([^'"]+)['"]/) do |match|
84
+ url = match[0].strip
85
+ next if url.match?(%r{\Ahttps?://})
86
+
87
+ resolved = if url.start_with?("/")
88
+ url
89
+ else
90
+ File.join(dir, url)
91
+ end
92
+
93
+ refs << resolved.split("?").first
94
+ end
95
+
96
+ refs.uniq
97
+ end
98
+
99
+ def log(message)
100
+ $stdout.puts message
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,22 @@
1
+ module Rails2static
2
+ class Configuration
3
+ attr_accessor :output_dir, :entry_paths, :host, :protocol,
4
+ :trailing_slash, :exclude_patterns, :include_assets,
5
+ :max_pages
6
+
7
+ def initialize
8
+ @output_dir = "_site"
9
+ @entry_paths = ["/"]
10
+ @host = "www.example.com"
11
+ @protocol = "https"
12
+ @trailing_slash = true
13
+ @exclude_patterns = []
14
+ @include_assets = true
15
+ @max_pages = 10_000
16
+ end
17
+
18
+ def excluded?(path)
19
+ exclude_patterns.any? { |pattern| path.match?(pattern) }
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,107 @@
1
+ require "rack/test"
2
+ require "set"
3
+ require "uri"
4
+
5
+ module Rails2static
6
+ class Crawler
7
+ include Rack::Test::Methods
8
+
9
+ attr_reader :pages
10
+
11
+ def initialize(app:, config: Rails2static.configuration)
12
+ @app = app
13
+ @config = config
14
+ @pages = []
15
+ @visited = Set.new
16
+ @queue = []
17
+ end
18
+
19
+ def app
20
+ @app
21
+ end
22
+
23
+ def crawl
24
+ @config.entry_paths.each { |path| enqueue(path) }
25
+
26
+ while (path = @queue.shift)
27
+ break if @pages.size >= @config.max_pages
28
+
29
+ fetch(path)
30
+ end
31
+
32
+ @pages
33
+ end
34
+
35
+ private
36
+
37
+ def enqueue(path)
38
+ normalized = normalize(path)
39
+ return if normalized.nil?
40
+ return if @visited.include?(normalized)
41
+ return if @config.excluded?(normalized)
42
+
43
+ @visited.add(normalized)
44
+ @queue.push(normalized)
45
+ end
46
+
47
+ def fetch(path)
48
+ header("Host", @config.host)
49
+ get(path)
50
+
51
+ status = last_response.status
52
+ content_type = last_response.content_type.to_s
53
+ body = last_response.body
54
+
55
+ if (300..399).cover?(status)
56
+ location = last_response.headers["Location"].to_s
57
+ if !location.empty? && !external?(location)
58
+ redirect_path = URI.parse(location).path
59
+ enqueue(redirect_path)
60
+ end
61
+ log(" REDIRECT #{status} #{path} -> #{location}")
62
+ return
63
+ end
64
+
65
+ unless status == 200
66
+ log(" WARNING: #{status} #{path}")
67
+ return
68
+ end
69
+
70
+ page = Page.new(path: path, status: status, content_type: content_type, body: body)
71
+ @pages << page
72
+
73
+ if page.html?
74
+ extractor = LinkExtractor.new(body, base_path: path)
75
+ extractor.page_links.each { |link| enqueue(link) }
76
+ end
77
+
78
+ log(" #{status} #{path} (#{content_type})")
79
+ end
80
+
81
+ def normalize(path)
82
+ return nil if path.nil? || path.empty?
83
+
84
+ uri = URI.parse(path)
85
+ return nil if uri.scheme && !%w[http https].include?(uri.scheme)
86
+ return nil if uri.host && uri.host != @config.host
87
+
88
+ clean = uri.path.to_s
89
+ clean = "/" if clean.empty?
90
+ clean = clean.chomp("/") unless clean == "/"
91
+ clean
92
+ rescue URI::InvalidURIError
93
+ nil
94
+ end
95
+
96
+ def external?(url)
97
+ uri = URI.parse(url)
98
+ uri.host && uri.host != @config.host
99
+ rescue URI::InvalidURIError
100
+ false
101
+ end
102
+
103
+ def log(message)
104
+ $stdout.puts message
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,88 @@
1
+ require "fileutils"
2
+
3
+ module Rails2static
4
+ class Generator
5
+ def initialize(app: nil, config: Rails2static.configuration)
6
+ @app = app || default_app
7
+ @config = config
8
+ end
9
+
10
+ def run
11
+ log("Rails2static: generating static site to #{@config.output_dir}/")
12
+
13
+ prepare_output_dir
14
+ pages = crawl
15
+ rewrite_links(pages)
16
+ write_pages(pages)
17
+ collect_and_write_assets(pages)
18
+ copy_public
19
+
20
+ log("Rails2static: done! #{pages.size} pages written to #{@config.output_dir}/")
21
+ end
22
+
23
+ private
24
+
25
+ def default_app
26
+ defined?(Rails) ? Rails.application : raise("No app provided and Rails is not defined")
27
+ end
28
+
29
+ def prepare_output_dir
30
+ FileUtils.rm_rf(@config.output_dir)
31
+ FileUtils.mkdir_p(@config.output_dir)
32
+ log(" Cleaned #{@config.output_dir}/")
33
+ end
34
+
35
+ def crawl
36
+ log(" Crawling...")
37
+ crawler = Crawler.new(app: @app, config: @config)
38
+ crawler.crawl
39
+ end
40
+
41
+ def rewrite_links(pages)
42
+ log(" Rewriting links...")
43
+ rewriter = LinkRewriter.new(@config)
44
+ pages.each do |page|
45
+ next unless page.html?
46
+
47
+ rewritten = rewriter.rewrite(page.body)
48
+ page.instance_variable_set(:@body, rewritten)
49
+ end
50
+ end
51
+
52
+ def write_pages(pages)
53
+ log(" Writing pages...")
54
+ writer = Writer.new(config: @config)
55
+ writer.write_pages(pages)
56
+ end
57
+
58
+ def collect_and_write_assets(pages)
59
+ return unless @config.include_assets
60
+
61
+ log(" Collecting assets...")
62
+ collector = AssetCollector.new(app: @app, config: @config)
63
+ assets = collector.collect(pages)
64
+
65
+ log(" Writing #{assets.size} assets...")
66
+ writer = Writer.new(config: @config)
67
+ writer.write_assets(assets)
68
+ end
69
+
70
+ def copy_public
71
+ public_dir = if defined?(Rails)
72
+ Rails.public_path.to_s
73
+ else
74
+ "public"
75
+ end
76
+
77
+ return unless File.directory?(public_dir)
78
+
79
+ log(" Copying public/ files...")
80
+ writer = Writer.new(config: @config)
81
+ writer.copy_public(public_dir)
82
+ end
83
+
84
+ def log(message)
85
+ $stdout.puts message
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,103 @@
1
+ require "nokogiri"
2
+
3
+ module Rails2static
4
+ class LinkExtractor
5
+ attr_reader :page_links, :asset_urls
6
+
7
+ def initialize(html, base_path: "/")
8
+ @html = html
9
+ @base_path = base_path
10
+ @page_links = []
11
+ @asset_urls = []
12
+ extract
13
+ end
14
+
15
+ private
16
+
17
+ def extract
18
+ doc = Nokogiri::HTML(@html)
19
+
20
+ doc.css("a[href]").each do |a|
21
+ href = a["href"].to_s.strip
22
+ next if href.empty?
23
+
24
+ resolved = resolve(href)
25
+ @page_links << resolved if resolved
26
+ end
27
+
28
+ doc.css("link[href]").each do |link|
29
+ href = link["href"].to_s.strip
30
+ resolved = resolve(href)
31
+ @asset_urls << resolved if resolved
32
+ end
33
+
34
+ doc.css("script[src]").each do |script|
35
+ src = script["src"].to_s.strip
36
+ resolved = resolve(src)
37
+ @asset_urls << resolved if resolved
38
+ end
39
+
40
+ doc.css("img[src]").each do |img|
41
+ src = img["src"].to_s.strip
42
+ resolved = resolve(src)
43
+ @asset_urls << resolved if resolved
44
+ end
45
+
46
+ doc.css("img[srcset], source[srcset]").each do |el|
47
+ parse_srcset(el["srcset"].to_s).each do |url|
48
+ resolved = resolve(url)
49
+ @asset_urls << resolved if resolved
50
+ end
51
+ end
52
+
53
+ doc.css("video[src], audio[src], video source[src], audio source[src]").each do |el|
54
+ src = el["src"].to_s.strip
55
+ resolved = resolve(src)
56
+ @asset_urls << resolved if resolved
57
+ end
58
+
59
+ doc.css("video[poster]").each do |el|
60
+ poster = el["poster"].to_s.strip
61
+ resolved = resolve(poster)
62
+ @asset_urls << resolved if resolved
63
+ end
64
+
65
+ @page_links.uniq!
66
+ @asset_urls.uniq!
67
+ end
68
+
69
+ def resolve(href)
70
+ return nil if external?(href)
71
+ return nil if non_http?(href)
72
+
73
+ path = if href.start_with?("/")
74
+ href
75
+ else
76
+ File.join(File.dirname(@base_path), href)
77
+ end
78
+
79
+ normalize(path)
80
+ end
81
+
82
+ def external?(href)
83
+ href.match?(%r{\A(https?://|//|mailto:|tel:|javascript:)})
84
+ end
85
+
86
+ def non_http?(href)
87
+ href.start_with?("#") || href.start_with?("data:")
88
+ end
89
+
90
+ def normalize(path)
91
+ uri = URI.parse(path)
92
+ clean = uri.path
93
+ clean = clean.chomp("/") unless clean == "/"
94
+ clean.empty? ? "/" : clean
95
+ rescue URI::InvalidURIError
96
+ nil
97
+ end
98
+
99
+ def parse_srcset(srcset)
100
+ srcset.split(",").map { |entry| entry.strip.split(/\s+/).first }.compact
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,56 @@
1
+ require "nokogiri"
2
+
3
+ module Rails2static
4
+ class LinkRewriter
5
+ def initialize(config = Rails2static.configuration)
6
+ @config = config
7
+ end
8
+
9
+ def rewrite(html)
10
+ doc = Nokogiri::HTML(html)
11
+
12
+ doc.css("a[href]").each do |a|
13
+ href = a["href"].to_s.strip
14
+ next if skip?(href)
15
+
16
+ a["href"] = rewrite_href(href)
17
+ end
18
+
19
+ doc.to_html
20
+ end
21
+
22
+ private
23
+
24
+ def skip?(href)
25
+ return true if href.empty?
26
+ return true if href.match?(%r{\A(https?://|//|mailto:|tel:|javascript:|data:)})
27
+ return true if href.start_with?("#")
28
+ return true if has_extension?(href)
29
+
30
+ false
31
+ end
32
+
33
+ def has_extension?(href)
34
+ path = href.split("?").first.split("#").first
35
+ basename = File.basename(path)
36
+ basename.include?(".") && !basename.start_with?(".")
37
+ end
38
+
39
+ def rewrite_href(href)
40
+ path = href.split("?").first.split("#").first
41
+ fragment = href.include?("#") ? "#" + href.split("#").last : ""
42
+
43
+ if @config.trailing_slash
44
+ path = path.chomp("/")
45
+ path = "/" if path.empty?
46
+ rewritten = path == "/" ? "/" : "#{path}/"
47
+ else
48
+ path = path.chomp("/")
49
+ path = "/" if path.empty?
50
+ rewritten = path == "/" ? "/" : "#{path}.html"
51
+ end
52
+
53
+ "#{rewritten}#{fragment}"
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,32 @@
1
+ module Rails2static
2
+ class Page
3
+ attr_reader :path, :status, :content_type, :body
4
+
5
+ def initialize(path:, status:, content_type:, body:)
6
+ @path = path
7
+ @status = status
8
+ @content_type = content_type.to_s
9
+ @body = body
10
+ end
11
+
12
+ def html?
13
+ content_type.include?("text/html")
14
+ end
15
+
16
+ def css?
17
+ content_type.include?("text/css")
18
+ end
19
+
20
+ def ok?
21
+ status == 200
22
+ end
23
+
24
+ def redirect?
25
+ (300..399).cover?(status)
26
+ end
27
+
28
+ def binary?
29
+ !content_type.start_with?("text/") && !content_type.include?("json") && !content_type.include?("xml") && !content_type.include?("javascript")
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,7 @@
1
+ module Rails2static
2
+ class Railtie < Rails::Railtie
3
+ rake_tasks do
4
+ load File.expand_path("../tasks/rails2static.rake", __dir__)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,3 @@
1
+ module Rails2static
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,75 @@
1
+ require "fileutils"
2
+
3
+ module Rails2static
4
+ class Writer
5
+ def initialize(config: Rails2static.configuration)
6
+ @config = config
7
+ end
8
+
9
+ def write_pages(pages)
10
+ pages.each { |page| write_page(page) }
11
+ end
12
+
13
+ def write_assets(assets)
14
+ assets.each { |asset| write_asset(asset) }
15
+ end
16
+
17
+ def copy_public(public_dir)
18
+ return unless File.directory?(public_dir)
19
+
20
+ Dir.glob(File.join(public_dir, "**", "*")).each do |file|
21
+ next if File.directory?(file)
22
+
23
+ relative = file.sub("#{public_dir}/", "")
24
+ dest = File.join(@config.output_dir, relative)
25
+
26
+ next if File.exist?(dest)
27
+
28
+ FileUtils.mkdir_p(File.dirname(dest))
29
+ FileUtils.cp(file, dest)
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def write_page(page)
36
+ dest = destination_for(page)
37
+ FileUtils.mkdir_p(File.dirname(dest))
38
+
39
+ if page.binary?
40
+ File.binwrite(dest, page.body)
41
+ else
42
+ File.write(dest, page.body)
43
+ end
44
+ end
45
+
46
+ def write_asset(asset)
47
+ dest = File.join(@config.output_dir, asset.path)
48
+ FileUtils.mkdir_p(File.dirname(dest))
49
+
50
+ if asset.binary?
51
+ File.binwrite(dest, asset.body)
52
+ else
53
+ File.write(dest, asset.body)
54
+ end
55
+ end
56
+
57
+ def destination_for(page)
58
+ path = page.path
59
+
60
+ if path == "/"
61
+ return File.join(@config.output_dir, "index.html")
62
+ end
63
+
64
+ if page.html?
65
+ if @config.trailing_slash
66
+ File.join(@config.output_dir, path, "index.html")
67
+ else
68
+ File.join(@config.output_dir, "#{path}.html")
69
+ end
70
+ else
71
+ File.join(@config.output_dir, path)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,30 @@
1
+ require "rails2static/version"
2
+ require "rails2static/configuration"
3
+ require "rails2static/page"
4
+ require "rails2static/link_extractor"
5
+ require "rails2static/link_rewriter"
6
+ require "rails2static/crawler"
7
+ require "rails2static/asset_collector"
8
+ require "rails2static/writer"
9
+ require "rails2static/generator"
10
+ require "rails2static/railtie" if defined?(Rails::Railtie)
11
+
12
+ module Rails2static
13
+ class << self
14
+ def configuration
15
+ @configuration ||= Configuration.new
16
+ end
17
+
18
+ def configure
19
+ yield(configuration)
20
+ end
21
+
22
+ def generate!(app: nil)
23
+ Generator.new(app: app).run
24
+ end
25
+
26
+ def reset_configuration!
27
+ @configuration = Configuration.new
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,23 @@
1
+ desc "Generate a static site from this Rails app"
2
+ task rails2static: :environment do
3
+ Rails2static.generate!
4
+ end
5
+
6
+ namespace :static do
7
+ desc "Generate a static site from this Rails app"
8
+ task generate: :environment do
9
+ Rails2static.generate!
10
+ end
11
+
12
+ desc "Preview the generated static site on http://localhost:8000"
13
+ task preview: :environment do
14
+ dir = Rails.root.join(Rails2static.configuration.output_dir)
15
+ abort "No #{Rails2static.configuration.output_dir}/ found. Run `rake rails2static` first." unless dir.exist?
16
+
17
+ require "webrick"
18
+ server = WEBrick::HTTPServer.new(Port: 8000, DocumentRoot: dir.to_s)
19
+ trap("INT") { server.shutdown }
20
+ puts "Serving #{dir}/ at http://localhost:8000 — Ctrl-C to stop"
21
+ server.start
22
+ end
23
+ end
metadata ADDED
@@ -0,0 +1,116 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails2static
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ian Terrell
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-02-21 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rack-test
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: nokogiri
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.15'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.15'
41
+ - !ruby/object:Gem::Dependency
42
+ name: webrick
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.8'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.8'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.12'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.12'
69
+ description: Crawls a server-rendered Rails app via Rack::Test and outputs a deployable
70
+ static site.
71
+ email:
72
+ - ian.terrell@gmail.com
73
+ executables: []
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - LICENSE
78
+ - README.md
79
+ - lib/generators/rails2static/install_generator.rb
80
+ - lib/generators/rails2static/templates/rails2static.rb
81
+ - lib/rails2static.rb
82
+ - lib/rails2static/asset_collector.rb
83
+ - lib/rails2static/configuration.rb
84
+ - lib/rails2static/crawler.rb
85
+ - lib/rails2static/generator.rb
86
+ - lib/rails2static/link_extractor.rb
87
+ - lib/rails2static/link_rewriter.rb
88
+ - lib/rails2static/page.rb
89
+ - lib/rails2static/railtie.rb
90
+ - lib/rails2static/version.rb
91
+ - lib/rails2static/writer.rb
92
+ - lib/tasks/rails2static.rake
93
+ homepage: https://github.com/ianterrell/rails2static
94
+ licenses:
95
+ - MIT
96
+ metadata: {}
97
+ post_install_message:
98
+ rdoc_options: []
99
+ require_paths:
100
+ - lib
101
+ required_ruby_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: '3.0'
106
+ required_rubygems_version: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ requirements: []
112
+ rubygems_version: 3.5.23
113
+ signing_key:
114
+ specification_version: 4
115
+ summary: Generate a static site from a Rails app
116
+ test_files: []