kiln 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/CHANGELOG.md +5 -0
- data/LICENSE +20 -0
- data/README.md +164 -0
- data/lib/kiln/assets.rb +162 -0
- data/lib/kiln/build.rb +146 -0
- data/lib/kiln/build_cache.rb +23 -0
- data/lib/kiln/check.rb +22 -0
- data/lib/kiln/configuration.rb +65 -0
- data/lib/kiln/content/collection.rb +43 -0
- data/lib/kiln/content/entry.rb +36 -0
- data/lib/kiln/content/frontmatter.rb +18 -0
- data/lib/kiln/content/handlers.rb +16 -0
- data/lib/kiln/content/markdown.rb +19 -0
- data/lib/kiln/content.rb +103 -0
- data/lib/kiln/manifest.rb +43 -0
- data/lib/kiln/page.rb +8 -0
- data/lib/kiln/pages.rb +35 -0
- data/lib/kiln/processors/absolute_urls.rb +26 -0
- data/lib/kiln/processors/external_links.rb +26 -0
- data/lib/kiln/processors/strip_csrf.rb +15 -0
- data/lib/kiln/processors/strip_source_maps.rb +14 -0
- data/lib/kiln/processors.rb +22 -0
- data/lib/kiln/railtie.rb +22 -0
- data/lib/kiln/redirects.rb +78 -0
- data/lib/kiln/renderer.rb +39 -0
- data/lib/kiln/report.rb +55 -0
- data/lib/kiln/response.rb +13 -0
- data/lib/kiln/router.rb +142 -0
- data/lib/kiln/server.rb +38 -0
- data/lib/kiln/sitemap.rb +38 -0
- data/lib/kiln/static_resolver.rb +28 -0
- data/lib/kiln/test_helper.rb +59 -0
- data/lib/kiln/tree_diff.rb +25 -0
- data/lib/kiln/verifier.rb +97 -0
- data/lib/kiln/version.rb +3 -0
- data/lib/kiln/writer.rb +62 -0
- data/lib/kiln.rb +62 -0
- data/lib/tasks/kiln/build.rake +33 -0
- data/lib/tasks/kiln/routes.rake +12 -0
- data/lib/tasks/kiln/serve.rake +11 -0
- data/lib/tasks/kiln/verify.rake +15 -0
- metadata +144 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 651c0899f42d04129a81c15dffe03cf24d95cdfb1cf7de0ec04ca4474c7e62e8
|
|
4
|
+
data.tar.gz: 23e810402d542846992a347836560c327e05d719a178301a4c76ccd77fdbb755
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 606f816dc7ff50268542d41386d744d621cbe28faeea446cf8f60b2fe4fe7115d6c54e08d6099105a831c881f8ac59acd0c2600d63f77b4b4591cbc53b9b6fd9
|
|
7
|
+
data.tar.gz: 49a7c794bf38f681e794e8141da259ba3d0699ec257e9f32fe8dd16677bc4d6ed9b126cc914a3e569d91f2372de4acf5b6b0b192ee168589ba997c1897b4c7b1
|
data/CHANGELOG.md
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright TODO: Write your name
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Kiln
|
|
2
|
+
|
|
3
|
+
Kiln fires a Rails app into a static site. Build pages with normal Rails (routes, controllers, views,
|
|
4
|
+
helpers, Turbo, Stimulus), then run:
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
RAILS_ENV=production bin/rails kiln:build
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Kiln renders every GET route through Rack, the same way a browser request goes through your app,
|
|
11
|
+
and writes plain files to `dist/` that any static host can serve.
|
|
12
|
+
|
|
13
|
+
- **Routes are the page list.** A GET route with no params is built automatically. A route with
|
|
14
|
+
params needs a param source or an explicit `skip`, or the build fails and names it.
|
|
15
|
+
- **Builds fail loudly.** Every render error, missing asset and broken link is collected and printed
|
|
16
|
+
as one report, and the task exits non-zero.
|
|
17
|
+
- **Kiln knows nothing about your data.** Models and repositories stay ordinary app code.
|
|
18
|
+
|
|
19
|
+
Requires Ruby 4.0+ and Rails 8.1+.
|
|
20
|
+
|
|
21
|
+
Releases: push a `vX.Y.Z` tag matching `Kiln::VERSION`. CI runs, then the release workflow publishes
|
|
22
|
+
to RubyGems through trusted publishing.
|
|
23
|
+
|
|
24
|
+
## Setup
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
# Gemfile
|
|
28
|
+
gem "kiln"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
# config/kiln.rb
|
|
33
|
+
Kiln.configure do |config|
|
|
34
|
+
config.url = "https://example.com" # sitemap, absolute URLs, request host
|
|
35
|
+
config.not_found = "/404" # rendered to 404.html
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
Kiln.pages do
|
|
39
|
+
params :post, -> { Post.published.map { { slug: it.slug } } }
|
|
40
|
+
params :page, -> { %w[en es ja].map { { locale: it } } + [ {} ] } # scope "(:locale)"
|
|
41
|
+
paginate :blog_page, per: 10, total: -> { Post.count }, start: 2 # /blog/page/2 ...
|
|
42
|
+
skip :search, reason: "dynamic"
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`bin/rails kiln:routes` shows every GET route and whether it is built, skipped or uncovered.
|
|
47
|
+
|
|
48
|
+
Rails internals (`/rails/*`), the health check, mounted engines and turbo-rails' native navigation
|
|
49
|
+
routes are skipped by default. Redirect routes become redirects, and mounted Rack apps are built
|
|
50
|
+
like any other route.
|
|
51
|
+
|
|
52
|
+
## Tasks
|
|
53
|
+
|
|
54
|
+
| Task | What it does |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `kiln:build` | Renders every page, exports assets, copies `public/`, writes redirects, sitemap and manifest, then verifies the output. Lists pages changed since the last build. `VERBOSE=1` shows request logs. |
|
|
57
|
+
| `kiln:routes` | The route table. Exits 1 if any route is uncovered. |
|
|
58
|
+
| `kiln:verify` | Checks internal links, anchors and importmap modules in the existing output. |
|
|
59
|
+
| `kiln:check` | Builds into a temp dir and fails if the committed output differs. For CI. |
|
|
60
|
+
| `kiln:serve` | Serves the output with static-host path resolution (`PORT`, default 4000). |
|
|
61
|
+
|
|
62
|
+
## Configuration
|
|
63
|
+
|
|
64
|
+
| Option | Default | |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| `output` | `"dist"` | Relative to `Rails.root`, or absolute. Wiped at the start of every build. |
|
|
67
|
+
| `url` | `nil` | Public base URL. Needed for the sitemap and `absolute_urls`. |
|
|
68
|
+
| `host` | from `url`, else `"localhost"` | Host header sent with every request. |
|
|
69
|
+
| `paths` | `:directory` | `/now` → `now/index.html`. `:file` writes `now.html`. |
|
|
70
|
+
| `assets` | `:referenced` | Only assets the output references (HTML, importmap, CSS `url()`). `:all` exports everything. |
|
|
71
|
+
| `cname` | `nil` | Writes a `CNAME` file. |
|
|
72
|
+
| `not_found` | `nil` | Path rendered to `404.html`. It may respond with 200 or 404. |
|
|
73
|
+
| `redirects` | `:file` | `:file` (`_redirects`), `:meta_refresh` (HTML pages, for GitHub Pages), `:both`, or `false`. |
|
|
74
|
+
| `sitemap` | `true` | Writes `sitemap.xml` (needs `url`). Pages marked `noindex` are left out. |
|
|
75
|
+
| `strip_source_maps` | `true` | Removes `sourceMappingURL` comments from JS and CSS. |
|
|
76
|
+
| `absolute_urls` | `false` | Makes image URLs (img, srcset, og:/twitter: images) absolute. |
|
|
77
|
+
| `external_links` | `false` | Adds `target="_blank" rel="noopener"` to links to other hosts. |
|
|
78
|
+
| `processors` | `[]` | Your own HTML processors: `call(html, context)` returning a String. |
|
|
79
|
+
| `verify` | `true` | Runs the verifier after every build. |
|
|
80
|
+
| `copy_public` | `true` | Copies `public/` (minus `public/assets`) into the output. |
|
|
81
|
+
|
|
82
|
+
Hooks get a context with `output`, `env`, `manifest` and `report`:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
Kiln.configure do |config|
|
|
86
|
+
config.before_build { |context| SearchIndex.generate! }
|
|
87
|
+
config.after_build { |context| puts "#{context.manifest.entries.size} pages" }
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
CSRF tokens and meta tags are always stripped. Everything else in the HTML is left byte for byte as
|
|
92
|
+
the app rendered it.
|
|
93
|
+
|
|
94
|
+
## In your app
|
|
95
|
+
|
|
96
|
+
- `Kiln.building?` is true while a build renders pages. Use it to hide dev-only things.
|
|
97
|
+
- `Kiln.build_cache.fetch(key) { ... }` memoizes for one build and is cleared before and after.
|
|
98
|
+
- Builds run in production. Kiln runs `assets:precompile` in-process when needed and removes
|
|
99
|
+
`public/assets` afterwards. Assets are fetched through the app, so keep
|
|
100
|
+
`config.public_file_server.enabled = true` for builds.
|
|
101
|
+
- Use root-absolute asset paths so every page's `<head>` stays identical and Turbo visits never
|
|
102
|
+
force a full reload.
|
|
103
|
+
- Feeds, `robots.txt`, `llms.txt` or JSON files are ordinary routes with views
|
|
104
|
+
(`get "feed.xml"`). The file is written under the route's path.
|
|
105
|
+
|
|
106
|
+
## Testing
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
require "kiln/test_helper"
|
|
110
|
+
|
|
111
|
+
class StaticSiteTest < ActiveSupport::TestCase
|
|
112
|
+
include Kiln::TestHelper
|
|
113
|
+
|
|
114
|
+
test("builds the same output twice") { assert_repeatable_build }
|
|
115
|
+
test("home page") { assert_page "/", selector: "h1", text: "Welcome" }
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`kiln_build { |output| ... }` builds into a temp dir, fails with the report on any error and
|
|
120
|
+
yields the output path.
|
|
121
|
+
|
|
122
|
+
## Content layer
|
|
123
|
+
|
|
124
|
+
Optional, and it works in server-rendered apps too:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
# Gemfile
|
|
128
|
+
gem "redcarpet"
|
|
129
|
+
gem "rouge"
|
|
130
|
+
|
|
131
|
+
# config/application.rb, after Bundler.require
|
|
132
|
+
require "kiln/content"
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
- **Markdown views:** `app/views/pages/about.html.md` renders through the layout like any view.
|
|
136
|
+
`.md.erb` runs ERB first. Code blocks get Rouge CSS classes (`rougify style github`).
|
|
137
|
+
- **Collections:** `content/blog/2024-05-01-hello.md` with YAML frontmatter.
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
posts = Kiln::Content.collection("blog")
|
|
141
|
+
posts.all # visible entries, newest first
|
|
142
|
+
posts.find("hello") # raises Kiln::Content::NotFound (a 404)
|
|
143
|
+
post.title, post.published_at, post.html, post[:tags]
|
|
144
|
+
|
|
145
|
+
# config/kiln.rb
|
|
146
|
+
params :post, -> { Kiln::Content.collection("blog").map { { slug: it.slug } } }
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
- **Publishing:** `draft: true` and future `published_at` entries are hidden, except in
|
|
150
|
+
development when not building. `Kiln::Content.show_drafts` overrides this. Server-rendered apps
|
|
151
|
+
can share a hidden entry with `posts.find(slug, preview_token: params[:token])`.
|
|
152
|
+
|
|
153
|
+
## Limitations in 0.1
|
|
154
|
+
|
|
155
|
+
- Pages render one at a time. Parallel rendering is planned.
|
|
156
|
+
- Only exact `_redirects` rules count for the verifier and `kiln:serve`. Splat and placeholder
|
|
157
|
+
rules in your own `public/_redirects` are written out but not followed.
|
|
158
|
+
- JS modules are found through the importmap. Relative `import "./x.js"` between files is not
|
|
159
|
+
followed.
|
|
160
|
+
- Collections read `*.md` files only (`.md.erb` works for views).
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT. See [LICENSE](LICENSE).
|
data/lib/kiln/assets.rb
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "json"
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
module Kiln
|
|
7
|
+
# Finds the /assets/* files the output uses and fetches them through the app, so the same code works
|
|
8
|
+
# with dynamic Propshaft (development, test) and with precompiled assets served from public/assets.
|
|
9
|
+
class Assets
|
|
10
|
+
CSS_REFERENCE = /url\(\s*["']?([^"')]+?)["']?\s*\)|@import\s+["']([^"']+)["']/
|
|
11
|
+
EXTERNAL = %r{\A(?:[a-z][a-z0-9+.-]*:|//|#)}i
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# Compiles assets in-process when the app does not serve them dynamically. Returns a callable that
|
|
15
|
+
# removes what the precompile created, or nil when nothing was compiled.
|
|
16
|
+
def precompile(app)
|
|
17
|
+
return unless precompile?(app)
|
|
18
|
+
|
|
19
|
+
output_path = Pathname(app.config.assets.output_path)
|
|
20
|
+
created = !output_path.exist?
|
|
21
|
+
run_precompile(app)
|
|
22
|
+
|
|
23
|
+
-> { created ? FileUtils.rm_rf(output_path) : FileUtils.rm_f(app.config.assets.manifest_path) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def precompile?(app)
|
|
27
|
+
assets = app.config.respond_to?(:assets) && app.config.assets
|
|
28
|
+
return false unless assets.respond_to?(:server) && assets.manifest_path
|
|
29
|
+
|
|
30
|
+
!assets.server && !Pathname(assets.manifest_path).exist?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Copies public/ into the output, minus public/assets. Files already in the output win, with a
|
|
34
|
+
# warning unless they are listed in `generated` (files Kiln writes on purpose, like 404.html).
|
|
35
|
+
def copy_public(from:, to:, report:, generated: [])
|
|
36
|
+
from = Pathname(from)
|
|
37
|
+
return unless from.directory?
|
|
38
|
+
|
|
39
|
+
Dir.glob("**/*", File::FNM_DOTMATCH, base: from).sort.each do |relative|
|
|
40
|
+
source = from.join(relative)
|
|
41
|
+
next if source.directory? || relative.start_with?("assets/")
|
|
42
|
+
|
|
43
|
+
target = Pathname(to).join(relative)
|
|
44
|
+
if target.exist?
|
|
45
|
+
next if generated.include?(relative)
|
|
46
|
+
|
|
47
|
+
report.warn(:write, "public/#{relative} not copied, a rendered file already exists", path: "/#{relative}")
|
|
48
|
+
else
|
|
49
|
+
FileUtils.mkdir_p(target.dirname)
|
|
50
|
+
FileUtils.cp(source, target)
|
|
51
|
+
report.wrote(relative)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
def run_precompile(app)
|
|
58
|
+
require "rake"
|
|
59
|
+
app.load_tasks unless Rake::Task.task_defined?("assets:precompile")
|
|
60
|
+
task = Rake::Task["assets:precompile"]
|
|
61
|
+
task.reenable
|
|
62
|
+
task.invoke
|
|
63
|
+
rescue StandardError => error
|
|
64
|
+
raise Kiln::Error, "assets:precompile failed: #{error.class}: #{error.message}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def initialize(renderer:, output:, config:, report:, app: Rails.application)
|
|
69
|
+
@renderer = renderer
|
|
70
|
+
@output = Pathname(output)
|
|
71
|
+
@config = config
|
|
72
|
+
@report = report
|
|
73
|
+
@app = app
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def export
|
|
77
|
+
queue = referenced_paths
|
|
78
|
+
queue.concat(all_paths) if @config.assets == :all
|
|
79
|
+
seen = Set.new
|
|
80
|
+
|
|
81
|
+
while (path = queue.shift)
|
|
82
|
+
next unless seen.add?(path)
|
|
83
|
+
queue.concat(fetch(path))
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
# Writes one asset and returns the asset paths it references (CSS url() and @import).
|
|
89
|
+
def fetch(path)
|
|
90
|
+
response = @renderer.get(path)
|
|
91
|
+
unless response.success?
|
|
92
|
+
@report.error(:asset, "responded with #{response.status}", path:)
|
|
93
|
+
return []
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
extension = File.extname(path)
|
|
97
|
+
body = response.body
|
|
98
|
+
body = Processors::StripSourceMaps.call(body) if @config.strip_source_maps && %w[.js .css].include?(extension)
|
|
99
|
+
write(path, body)
|
|
100
|
+
extension == ".css" ? css_references(path, body) : []
|
|
101
|
+
rescue StandardError => error
|
|
102
|
+
@report.error(:asset, "#{error.class}: #{error.message}", path:)
|
|
103
|
+
[]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def write(path, body)
|
|
107
|
+
relative = path.delete_prefix("/")
|
|
108
|
+
file = @output.join(relative)
|
|
109
|
+
FileUtils.mkdir_p(file.dirname)
|
|
110
|
+
File.binwrite(file, body)
|
|
111
|
+
@report.wrote(relative)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def referenced_paths
|
|
115
|
+
Dir.glob("**/*.html", base: @output).sort.flat_map do |relative|
|
|
116
|
+
html_references(Nokogiri::HTML(File.read(@output.join(relative))))
|
|
117
|
+
end.filter_map { asset_path(it) }.uniq
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def html_references(doc)
|
|
121
|
+
urls = doc.xpath("//@href | //@src").map(&:value)
|
|
122
|
+
urls += doc.xpath("//@srcset").flat_map { |attr| attr.value.split(",").map { it.strip.split(/\s+/).first } }
|
|
123
|
+
doc.css('script[type="importmap"]').each do |script|
|
|
124
|
+
map = JSON.parse(script.text)
|
|
125
|
+
urls += map.fetch("imports", {}).values
|
|
126
|
+
urls += map.fetch("scopes", {}).values.flat_map(&:values)
|
|
127
|
+
rescue JSON::ParserError
|
|
128
|
+
@report.error(:asset, "invalid importmap JSON")
|
|
129
|
+
end
|
|
130
|
+
urls.compact
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def css_references(css_path, body)
|
|
134
|
+
body.to_s.scan(CSS_REFERENCE).filter_map do |url, import|
|
|
135
|
+
ref = (url || import).strip
|
|
136
|
+
next if ref.empty? || ref.match?(EXTERNAL)
|
|
137
|
+
|
|
138
|
+
asset_path(URI.join("http://kiln#{css_path}", ref).path)
|
|
139
|
+
rescue URI::Error
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def all_paths
|
|
145
|
+
@app.assets.load_path.assets.map { "#{prefix}/#{it.digested_path}" }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Normalizes a URL to an asset path ("/assets/app-abc.css"), or nil when it isn't one of ours.
|
|
149
|
+
def asset_path(url)
|
|
150
|
+
uri = URI.parse(url.to_s.strip)
|
|
151
|
+
return if uri.host && uri.host != @config.host
|
|
152
|
+
|
|
153
|
+
uri.path if uri.path&.start_with?("#{prefix}/")
|
|
154
|
+
rescue URI::Error
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def prefix
|
|
159
|
+
@prefix ||= @app.config.assets.prefix.to_s.chomp("/")
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
data/lib/kiln/build.rb
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Kiln
|
|
4
|
+
# Runs the whole pipeline and returns a Report. Never raises for page-level problems: those go in
|
|
5
|
+
# the report so the build can show every failure at once.
|
|
6
|
+
class Build
|
|
7
|
+
# Passed to before_build / after_build hooks.
|
|
8
|
+
Context = Data.define(:output, :env, :manifest, :report)
|
|
9
|
+
|
|
10
|
+
attr_reader :app, :config, :pages_dsl, :output, :report, :manifest, :previous_manifest
|
|
11
|
+
|
|
12
|
+
def initialize(app: Rails.application, config: Kiln.config, pages: Kiln.pages, output: nil)
|
|
13
|
+
@app = app
|
|
14
|
+
@config = config
|
|
15
|
+
@pages_dsl = pages
|
|
16
|
+
@output = Pathname(output || config.output_path(app.root))
|
|
17
|
+
@report = Report.new
|
|
18
|
+
@manifest = Manifest.new
|
|
19
|
+
@redirects = []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Pages added, removed and changed since the previous build in the same output, or nil.
|
|
23
|
+
def changes
|
|
24
|
+
previous_manifest&.diff(manifest)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run
|
|
28
|
+
Kiln.building! do
|
|
29
|
+
guard_output!
|
|
30
|
+
guard_static_files!
|
|
31
|
+
cleanup = Assets.precompile(app)
|
|
32
|
+
begin
|
|
33
|
+
run_hooks(config.before_build_hooks)
|
|
34
|
+
prepare_output
|
|
35
|
+
|
|
36
|
+
pages = Router.new(app.routes, pages_dsl, report:).pages
|
|
37
|
+
pages.reject { it.path == config.not_found }.each { render_page(it) }
|
|
38
|
+
render_not_found if config.not_found
|
|
39
|
+
|
|
40
|
+
Assets.new(renderer:, output:, config:, report:).export
|
|
41
|
+
Assets.copy_public(from: app.root.join("public"), to: output, report:, generated: generated_files) if config.copy_public
|
|
42
|
+
write_redirects if config.redirects && @redirects.any?
|
|
43
|
+
Sitemap.new(manifest, url: config.url).write(output) if config.sitemap && config.url
|
|
44
|
+
write_cname if config.cname
|
|
45
|
+
manifest.write(output)
|
|
46
|
+
|
|
47
|
+
Verifier.new(output, config:).verify(report) if config.verify
|
|
48
|
+
run_hooks(config.after_build_hooks)
|
|
49
|
+
ensure
|
|
50
|
+
cleanup&.call
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
report
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
def renderer
|
|
58
|
+
@renderer ||= Renderer.new(app, host: config.host)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def writer
|
|
62
|
+
@writer ||= Writer.new(output, mode: config.paths)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def render_page(page)
|
|
66
|
+
response = renderer.get(page.path)
|
|
67
|
+
|
|
68
|
+
if response.redirect?
|
|
69
|
+
@redirects << Redirects::Entry.new(from: page.path, to: response.location, status: response.status)
|
|
70
|
+
elsif response.success?
|
|
71
|
+
body = response.html? ? Processors.apply(response.body, Processors::Context.new(path: page.path, config:, response:)) : response.body
|
|
72
|
+
file = writer.write(page.path, response.content_type, body)
|
|
73
|
+
manifest.add(page.path, file:, body:, content_type: response.content_type, noindex: Sitemap.noindex?(response, body))
|
|
74
|
+
report.wrote(file)
|
|
75
|
+
else
|
|
76
|
+
report.error(:render, "responded with #{response.status}", path: page.path)
|
|
77
|
+
end
|
|
78
|
+
rescue StandardError => error
|
|
79
|
+
report.error(:render, describe(error), path: page.path)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def render_not_found
|
|
83
|
+
response = renderer.get(config.not_found)
|
|
84
|
+
unless [ 200, 404 ].include?(response.status)
|
|
85
|
+
return report.error(:render, "404 page responded with #{response.status}", path: config.not_found)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
body = Processors.apply(response.body, Processors::Context.new(path: config.not_found, config:, response:))
|
|
89
|
+
report.wrote(writer.write_file("404.html", body))
|
|
90
|
+
rescue StandardError => error
|
|
91
|
+
report.error(:render, describe(error), path: config.not_found)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def write_redirects
|
|
95
|
+
Redirects.new(@redirects, mode: config.redirects, writer:, host: config.host, url: config.url).write(output).each { report.wrote(it) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Files Kiln writes on purpose that may also exist in public/ (Rails ships a public/404.html).
|
|
99
|
+
def generated_files
|
|
100
|
+
[ ("404.html" if config.not_found), ("CNAME" if config.cname) ].compact
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def write_cname
|
|
104
|
+
report.wrote(writer.write_file("CNAME", "#{config.cname}\n"))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def run_hooks(hooks)
|
|
108
|
+
context = Context.new(output:, env: Rails.env, manifest:, report:)
|
|
109
|
+
hooks.each do |hook|
|
|
110
|
+
hook.call(context)
|
|
111
|
+
rescue StandardError => error
|
|
112
|
+
report.error(:hook, describe(error))
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def prepare_output
|
|
117
|
+
previous = output.join(Manifest::FILENAME)
|
|
118
|
+
@previous_manifest = Manifest.load(previous) if previous.file?
|
|
119
|
+
FileUtils.rm_rf(output)
|
|
120
|
+
FileUtils.mkdir_p(output)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Refuse to wipe anything that contains the app.
|
|
124
|
+
def guard_output!
|
|
125
|
+
root = app.root.expand_path
|
|
126
|
+
out = output.expand_path
|
|
127
|
+
if out == root || root.to_s.start_with?("#{out}/") || out.root?
|
|
128
|
+
raise Kiln::Error, "Refusing to use #{out} as the output directory"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# After a precompile, assets are fetched from public/assets through ActionDispatch::Static.
|
|
133
|
+
def guard_static_files!
|
|
134
|
+
return unless app.config.respond_to?(:assets) && !app.config.assets.server
|
|
135
|
+
return if app.config.public_file_server.enabled
|
|
136
|
+
|
|
137
|
+
raise Kiln::Error, "Kiln fetches compiled assets through the public file server. " \
|
|
138
|
+
"Set config.public_file_server.enabled = true for the build environment."
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def describe(error)
|
|
142
|
+
where = error.backtrace&.find { it.start_with?(app.root.to_s) }&.delete_prefix("#{app.root}/")
|
|
143
|
+
[ "#{error.class}: #{error.message}", where && "(#{where})" ].compact.join(" ")
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Kiln
|
|
2
|
+
# A plain in-memory store that returns the cached objects themselves (no copies), so memoized
|
|
3
|
+
# repositories and collections keep their state for the whole build.
|
|
4
|
+
class BuildCache
|
|
5
|
+
def initialize
|
|
6
|
+
@store = {}
|
|
7
|
+
@mutex = Monitor.new
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def fetch(key)
|
|
11
|
+
@mutex.synchronize do
|
|
12
|
+
return @store[key] if @store.key?(key)
|
|
13
|
+
|
|
14
|
+
@store[key] = yield
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def read(key) = @mutex.synchronize { @store[key] }
|
|
19
|
+
def write(key, value) = @mutex.synchronize { @store[key] = value }
|
|
20
|
+
def delete(key) = @mutex.synchronize { @store.delete(key) }
|
|
21
|
+
def clear = @mutex.synchronize { @store.clear }
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/kiln/check.rb
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
require "tmpdir"
|
|
2
|
+
|
|
3
|
+
module Kiln
|
|
4
|
+
# Builds into a temp dir and compares the result with the committed output.
|
|
5
|
+
class Check
|
|
6
|
+
Result = Data.define(:report, :diff) do
|
|
7
|
+
def success? = report.success? && diff.empty?
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(app: Rails.application, config: Kiln.config)
|
|
11
|
+
@app = app
|
|
12
|
+
@config = config
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run
|
|
16
|
+
Dir.mktmpdir("kiln-check") do |dir|
|
|
17
|
+
report = Build.new(app: @app, config: @config, output: dir).run
|
|
18
|
+
Result.new(report:, diff: TreeDiff.call(@config.output_path(@app.root), dir))
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module Kiln
|
|
2
|
+
class Configuration
|
|
3
|
+
# Output directory, relative to Rails.root unless absolute.
|
|
4
|
+
attr_accessor :output
|
|
5
|
+
# Public base URL ("https://davafons.com"). Used by the sitemap and absolute_urls processor.
|
|
6
|
+
attr_accessor :url
|
|
7
|
+
# :directory ("/now" -> now/index.html) or :file ("/now" -> now.html).
|
|
8
|
+
attr_accessor :paths
|
|
9
|
+
# :referenced (only assets the output uses) or :all (everything under /assets).
|
|
10
|
+
attr_accessor :assets
|
|
11
|
+
# Writes a CNAME file with this domain when set.
|
|
12
|
+
attr_accessor :cname
|
|
13
|
+
# Removes `sourceMappingURL` comments from JS and CSS.
|
|
14
|
+
attr_accessor :strip_source_maps
|
|
15
|
+
# Extra HTML processors: objects responding to call(html, context) -> html.
|
|
16
|
+
attr_accessor :processors
|
|
17
|
+
# Built-in optional processors, off by default.
|
|
18
|
+
attr_accessor :absolute_urls, :external_links
|
|
19
|
+
# Path rendered to 404.html (expected to respond with 404 or 200), nil to skip.
|
|
20
|
+
attr_accessor :not_found
|
|
21
|
+
# :file (_redirects), :meta_refresh (HTML pages), :both, or false.
|
|
22
|
+
attr_accessor :redirects
|
|
23
|
+
# Writes sitemap.xml when true and `url` is set.
|
|
24
|
+
attr_accessor :sitemap
|
|
25
|
+
# Runs the verifier (links, anchors, importmap) after every build.
|
|
26
|
+
attr_accessor :verify
|
|
27
|
+
# Copy public/ into the output (minus public/assets).
|
|
28
|
+
attr_accessor :copy_public
|
|
29
|
+
|
|
30
|
+
attr_reader :before_build_hooks, :after_build_hooks
|
|
31
|
+
attr_writer :host
|
|
32
|
+
|
|
33
|
+
def initialize
|
|
34
|
+
@output = "dist"
|
|
35
|
+
@url = nil
|
|
36
|
+
@host = nil
|
|
37
|
+
@paths = :directory
|
|
38
|
+
@assets = :referenced
|
|
39
|
+
@cname = nil
|
|
40
|
+
@strip_source_maps = true
|
|
41
|
+
@processors = []
|
|
42
|
+
@absolute_urls = false
|
|
43
|
+
@external_links = false
|
|
44
|
+
@not_found = nil
|
|
45
|
+
@redirects = :file
|
|
46
|
+
@sitemap = true
|
|
47
|
+
@verify = true
|
|
48
|
+
@copy_public = true
|
|
49
|
+
@before_build_hooks = []
|
|
50
|
+
@after_build_hooks = []
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Host sent with every request. Derived from `url`, falls back to "localhost".
|
|
54
|
+
def host
|
|
55
|
+
@host || (url && URI(url).host) || "localhost"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def output_path(root = Rails.root)
|
|
59
|
+
Pathname(output).absolute? ? Pathname(output) : Pathname(root).join(output)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def before_build(&block) = @before_build_hooks << block
|
|
63
|
+
def after_build(&block) = @after_build_hooks << block
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module Kiln
|
|
2
|
+
module Content
|
|
3
|
+
class Collection
|
|
4
|
+
include Enumerable
|
|
5
|
+
|
|
6
|
+
attr_reader :name
|
|
7
|
+
|
|
8
|
+
def initialize(name, dir)
|
|
9
|
+
@name, @dir = name.to_s, Pathname(dir)
|
|
10
|
+
@stamp = stamp
|
|
11
|
+
@entries = files.map { Entry.new(@name, it) }
|
|
12
|
+
.sort_by { [ it.published_at&.in_time_zone || Time.at(0), it.slug ] }.reverse
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Visible entries, newest first.
|
|
16
|
+
def all
|
|
17
|
+
Content.show_drafts ? @entries : @entries.select(&:published?)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def each(&) = all.each(&)
|
|
21
|
+
|
|
22
|
+
def find(slug, preview_token: nil)
|
|
23
|
+
entry = @entries.find { it.slug == slug.to_s }
|
|
24
|
+
return entry if entry && (Content.show_drafts || entry.published? || preview?(entry, preview_token))
|
|
25
|
+
raise NotFound, "no #{name} entry with slug #{slug.inspect}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def find_by(slug:) = all.find { it.slug == slug.to_s }
|
|
29
|
+
|
|
30
|
+
# True when files were added, removed or changed since loading.
|
|
31
|
+
def stale? = stamp != @stamp
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
def files = Dir[@dir.join("*.md")].sort
|
|
35
|
+
|
|
36
|
+
def stamp = files.map { [ it, File.mtime(it) ] }
|
|
37
|
+
|
|
38
|
+
def preview?(entry, token)
|
|
39
|
+
token.present? && ActiveSupport::SecurityUtils.secure_compare(entry.preview_token, token.to_s)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|