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.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE +20 -0
  4. data/README.md +164 -0
  5. data/lib/kiln/assets.rb +162 -0
  6. data/lib/kiln/build.rb +146 -0
  7. data/lib/kiln/build_cache.rb +23 -0
  8. data/lib/kiln/check.rb +22 -0
  9. data/lib/kiln/configuration.rb +65 -0
  10. data/lib/kiln/content/collection.rb +43 -0
  11. data/lib/kiln/content/entry.rb +36 -0
  12. data/lib/kiln/content/frontmatter.rb +18 -0
  13. data/lib/kiln/content/handlers.rb +16 -0
  14. data/lib/kiln/content/markdown.rb +19 -0
  15. data/lib/kiln/content.rb +103 -0
  16. data/lib/kiln/manifest.rb +43 -0
  17. data/lib/kiln/page.rb +8 -0
  18. data/lib/kiln/pages.rb +35 -0
  19. data/lib/kiln/processors/absolute_urls.rb +26 -0
  20. data/lib/kiln/processors/external_links.rb +26 -0
  21. data/lib/kiln/processors/strip_csrf.rb +15 -0
  22. data/lib/kiln/processors/strip_source_maps.rb +14 -0
  23. data/lib/kiln/processors.rb +22 -0
  24. data/lib/kiln/railtie.rb +22 -0
  25. data/lib/kiln/redirects.rb +78 -0
  26. data/lib/kiln/renderer.rb +39 -0
  27. data/lib/kiln/report.rb +55 -0
  28. data/lib/kiln/response.rb +13 -0
  29. data/lib/kiln/router.rb +142 -0
  30. data/lib/kiln/server.rb +38 -0
  31. data/lib/kiln/sitemap.rb +38 -0
  32. data/lib/kiln/static_resolver.rb +28 -0
  33. data/lib/kiln/test_helper.rb +59 -0
  34. data/lib/kiln/tree_diff.rb +25 -0
  35. data/lib/kiln/verifier.rb +97 -0
  36. data/lib/kiln/version.rb +3 -0
  37. data/lib/kiln/writer.rb +62 -0
  38. data/lib/kiln.rb +62 -0
  39. data/lib/tasks/kiln/build.rake +33 -0
  40. data/lib/tasks/kiln/routes.rake +12 -0
  41. data/lib/tasks/kiln/serve.rake +11 -0
  42. data/lib/tasks/kiln/verify.rake +15 -0
  43. 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
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-11-07)
4
+
5
+ - First release.
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).
@@ -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