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 +7 -0
- data/LICENSE +21 -0
- data/README.md +91 -0
- data/lib/generators/rails2static/install_generator.rb +11 -0
- data/lib/generators/rails2static/templates/rails2static.rb +23 -0
- data/lib/rails2static/asset_collector.rb +103 -0
- data/lib/rails2static/configuration.rb +22 -0
- data/lib/rails2static/crawler.rb +107 -0
- data/lib/rails2static/generator.rb +88 -0
- data/lib/rails2static/link_extractor.rb +103 -0
- data/lib/rails2static/link_rewriter.rb +56 -0
- data/lib/rails2static/page.rb +32 -0
- data/lib/rails2static/railtie.rb +7 -0
- data/lib/rails2static/version.rb +3 -0
- data/lib/rails2static/writer.rb +75 -0
- data/lib/rails2static.rb +30 -0
- data/lib/tasks/rails2static.rake +23 -0
- metadata +116 -0
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,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
|
data/lib/rails2static.rb
ADDED
|
@@ -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: []
|