kiln 0.1.1 → 0.1.2
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 +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +30 -26
- data/lib/kiln/assets.rb +51 -20
- data/lib/kiln/build.rb +13 -32
- data/lib/kiln/build_cache.rb +4 -4
- data/lib/kiln/configuration.rb +4 -38
- data/lib/kiln/content/collection.rb +10 -10
- data/lib/kiln/content/entry.rb +27 -8
- data/lib/kiln/content/markdown.rb +10 -0
- data/lib/kiln/content.rb +9 -9
- data/lib/kiln/pages.rb +15 -1
- data/lib/kiln/processors/strip_csrf.rb +1 -1
- data/lib/kiln/redirects.rb +7 -43
- data/lib/kiln/report.rb +1 -1
- data/lib/kiln/test_helper.rb +14 -18
- data/lib/kiln/version.rb +1 -1
- metadata +1 -4
- data/lib/kiln/processors/absolute_urls.rb +0 -26
- data/lib/kiln/processors/external_links.rb +0 -26
- data/lib/kiln/processors.rb +0 -22
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2108cdc748f14c10c06ab2edbf346622c13887cac5ec16bc2fe6823dfb3d4b53
|
|
4
|
+
data.tar.gz: f5aabfc64b54bd944d8d2bb3de6a57d980204e4f4070dd23f3340376980b8326
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6ec17dee2f07143628dbd3892c50ee258bde2aa1a2e0c4e5306b7ecd9d30856e2d0df0d50b78da273f658b7d0607e3ec4a4bd00fc5f8834c4aa7002e7938d814
|
|
7
|
+
data.tar.gz: 9eafdc861711a6f733e9bc376a6c4f0c3519c104451fbaed5140c37642ea0d21edd6649793309ec64fb02e26b8b60ec8892398b5e0bc20f7e2c21a725940a943
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.2 (2026-11-07)
|
|
4
|
+
|
|
5
|
+
Breaking:
|
|
6
|
+
|
|
7
|
+
- `Kiln.build_cache.fetch` caches only while `Kiln.building?`. Outside a build it runs the block
|
|
8
|
+
every time. `read`, `write` and `delete` are removed.
|
|
9
|
+
- `assert_repeatable_build` is removed. Use `kiln_build(repeatable: true) { |output| ... }`.
|
|
10
|
+
- In collections, `SLUG.LOCALE.md` (such as `2026-08.es.md`) is a translation of `SLUG.md` instead
|
|
11
|
+
of its own entry. Translations stay out of `all`, `find` and `each`, and a translation without
|
|
12
|
+
its entry fails when the collection loads.
|
|
13
|
+
- A collection filename that is only a date, such as `2026-08-01.md`, now sets `published_at`.
|
|
14
|
+
- Removed options: `config.verify` (every build verifies), `config.absolute_urls`,
|
|
15
|
+
`config.external_links`, `config.processors`, `config.before_build`, `config.after_build`,
|
|
16
|
+
`config.strip_source_maps` (source maps are always stripped), `config.sitemap` (written whenever
|
|
17
|
+
`url` is set), `config.host=` (the host comes from `url`, else `localhost`), `config.assets`
|
|
18
|
+
(only referenced assets are exported), `config.redirects` (always `_redirects`; the
|
|
19
|
+
`:meta_refresh`, `:both` and `false` modes are gone) and `config.copy_public` (`public/` is always
|
|
20
|
+
copied).
|
|
21
|
+
- Removed `Kiln::Processors.apply`, `Kiln::Processors::Context`, `Kiln::Build::Context`, and the
|
|
22
|
+
`AbsoluteUrls` and `ExternalLinks` processors.
|
|
23
|
+
- The content layer drops preview tokens: `Collection#find` takes only a slug, and
|
|
24
|
+
`Entry#preview_token` and `Kiln::Content.preview_secret` are removed. Drafts and scheduled
|
|
25
|
+
entries still show in development when Kiln is not building.
|
|
26
|
+
|
|
27
|
+
Added:
|
|
28
|
+
|
|
29
|
+
- `params :page, locale: [ nil, "es", "ja" ]` builds every combination of value lists. `nil`
|
|
30
|
+
leaves the key out, for optional segments. A list may be a lambda, called at build time.
|
|
31
|
+
- `kiln_build(repeatable: true)` builds twice, fails with the diff when the outputs differ, and
|
|
32
|
+
yields the first output.
|
|
33
|
+
- `entry.translation(:es)` and `entry.locale` in the content layer.
|
|
34
|
+
- Collection filenames may start with a `YYYY-MM` date, which means the 1st of that month.
|
|
35
|
+
- `Kiln::Content::Markdown.render_inline(text)` renders a single paragraph without its `<p>`.
|
|
36
|
+
- `config.cname = true` writes `CNAME` with the host from `config.url`.
|
|
37
|
+
|
|
38
|
+
Fixed:
|
|
39
|
+
|
|
40
|
+
- A production build precompiles assets into a temporary directory instead of `public/assets`, so
|
|
41
|
+
a development server that boots during the build no longer loads the production manifest.
|
|
42
|
+
Compiled assets are exported from that directory, and the build no longer needs
|
|
43
|
+
`config.public_file_server.enabled`.
|
|
44
|
+
- When `app/assets/builds` is created after boot (a fresh checkout without
|
|
45
|
+
`app/assets/builds/.keep`), the build stops with one error that says to track the `.keep` file,
|
|
46
|
+
instead of one missing-asset error per page.
|
|
47
|
+
|
|
3
48
|
## 0.1.1 (2026-11-07)
|
|
4
49
|
|
|
5
50
|
- Kiln has two tasks, `kiln:build` and `kiln:serve`. `kiln:verify`, `kiln:routes` and `kiln:check` are removed: every build verifies and reports uncovered routes, and committed output can be checked with `git status --porcelain` after a build.
|
data/README.md
CHANGED
|
@@ -53,12 +53,15 @@ A GET route with no required params is built automatically. A route with params
|
|
|
53
53
|
```ruby
|
|
54
54
|
Kiln.pages do
|
|
55
55
|
params :post, -> { Post.published.map { { slug: it.slug } } }
|
|
56
|
-
params :page,
|
|
56
|
+
params :page, locale: [ nil, "es", "ja" ] # scope "(:locale)"
|
|
57
|
+
params :archive, year: -> { Post.years }, locale: [ nil, "es" ] # every combination
|
|
57
58
|
paginate :blog_page, per: 10, total: -> { Post.count }, start: 2 # /blog/page/2, /blog/page/3, ...
|
|
58
59
|
skip :search, reason: "dynamic"
|
|
59
60
|
end
|
|
60
61
|
```
|
|
61
62
|
|
|
63
|
+
A hash of value lists builds every combination of the values. A `nil` value leaves the key out, which fills an optional segment such as `(:locale)` with nothing. A list may be a lambda, called at build time.
|
|
64
|
+
|
|
62
65
|
A route that needs params and has neither a param source nor a skip fails the build, and the error names the line to add. Rails internals, the health check, mounted engines and turbo-rails' native navigation routes are skipped by default. Redirect routes become redirects, and mounted Rack apps are built like any other route.
|
|
63
66
|
|
|
64
67
|
Feeds, `robots.txt`, `llms.txt` and JSON files are ordinary routes with views. The content type picks the extension, so `get "api"` rendering JSON is written as `api.json`.
|
|
@@ -81,39 +84,23 @@ bin/rails kiln:serve # look at the output as a static host
|
|
|
81
84
|
| Option | Default | |
|
|
82
85
|
|---|---|---|
|
|
83
86
|
| `output` | `"dist"` | Relative to `Rails.root`, or absolute. Wiped at the start of every build. |
|
|
84
|
-
| `url` | `nil` | Public base URL
|
|
85
|
-
| `host` | from `url`, else `"localhost"` | Host header sent with every request. |
|
|
87
|
+
| `url` | `nil` | Public base URL. Its host is sent with every request (`localhost` without it), and `sitemap.xml` is written only when it is set. |
|
|
86
88
|
| `paths` | `:directory` | `/now` becomes `now/index.html`. `:file` writes `now.html`. |
|
|
87
|
-
| `
|
|
88
|
-
| `cname` | `nil` | Writes a `CNAME` file. |
|
|
89
|
+
| `cname` | `nil` | Writes a `CNAME` file with this host. `true` takes the host from `url`. |
|
|
89
90
|
| `not_found` | `nil` | Path rendered to `404.html`. It may respond with 200 or 404. |
|
|
90
|
-
| `redirects` | `:file` | `:file` (`_redirects`), `:meta_refresh` (HTML pages for GitHub Pages), `:both`, or `false`. |
|
|
91
|
-
| `sitemap` | `true` | Writes `sitemap.xml` when `url` is set. Pages marked `noindex` stay out. |
|
|
92
91
|
| `files` | `{}` | Files that aren't routes: `{ "octicons-license.txt" => "vendor/octicons/LICENSE" }`. |
|
|
93
|
-
| `strip_source_maps` | `true` | Removes `sourceMappingURL` comments from JS and CSS. |
|
|
94
|
-
| `absolute_urls` | `false` | Makes image URLs absolute (img, srcset, og: and twitter: images). |
|
|
95
|
-
| `external_links` | `false` | Adds `target="_blank" rel="noopener"` to links to other hosts. |
|
|
96
|
-
| `processors` | `[]` | Your HTML processors: `call(html, context)` returning a String. |
|
|
97
|
-
| `verify` | `true` | Runs the verifier after every build. |
|
|
98
92
|
| `verify_skip` | `[]` | CSS selectors for sample markup whose links aren't checked, such as `"[data-example-preview]"`. |
|
|
99
|
-
| `copy_public` | `true` | Copies `public/` (minus `public/assets`) into the output. |
|
|
100
93
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
```ruby
|
|
104
|
-
Kiln.configure do |config|
|
|
105
|
-
config.before_build { |context| SearchIndex.generate! }
|
|
106
|
-
config.after_build { |context| puts "#{context.manifest.entries.size} pages" }
|
|
107
|
-
end
|
|
108
|
-
```
|
|
94
|
+
Every build exports the assets the output references (HTML, importmap, CSS `url()`), removes `sourceMappingURL` comments from JS and CSS, copies `public/` (minus `public/assets`), writes `_redirects` when a route redirects, and runs the verifier. The sitemap leaves out pages marked `noindex`.
|
|
109
95
|
|
|
110
96
|
CSRF tokens and meta tags are always stripped. The rest of the HTML stays byte for byte as the app rendered it.
|
|
111
97
|
|
|
112
98
|
## Write views for a build
|
|
113
99
|
|
|
114
100
|
- `Kiln.building?` is true while a build renders pages. Use it to hide development-only markup.
|
|
115
|
-
- `Kiln.build_cache.fetch(key) { ... }` memoizes for one build and is cleared before and after it.
|
|
116
|
-
-
|
|
101
|
+
- `Kiln.build_cache.fetch(key) { ... }` memoizes for one build and is cleared before and after it. Outside a build it runs the block every time, so a development server never serves stale data.
|
|
102
|
+
- A production build precompiles assets into a temporary directory and exports them from there. `public/assets` is left alone, so a development server of the same app can run at the same time.
|
|
103
|
+
- Propshaft only serves asset folders that exist when the app boots. Track `app/assets/builds/.keep`, as a new Rails app does, or the build stops with an error on a fresh checkout.
|
|
117
104
|
- A build in development skips the precompile but still runs the steps hooked into `assets:precompile`, such as `tailwindcss:build`.
|
|
118
105
|
- Development middleware that injects markup (Hotwire Spark, web-console, rack-mini-profiler) fails the build. Load it only under `bin/rails server`.
|
|
119
106
|
- Root-absolute asset paths keep every page's `<head>` identical, so Turbo visits never force a full reload.
|
|
@@ -133,6 +120,8 @@ require "kiln/content"
|
|
|
133
120
|
|
|
134
121
|
`app/views/pages/about.html.md` renders through the layout like any view, and `.md.erb` runs ERB first. commonmarker renders GitHub Flavored Markdown with tables, strikethrough, autolinks and heading ids; raw HTML passes through. Code blocks get Rouge CSS classes.
|
|
135
122
|
|
|
123
|
+
`Kiln::Content::Markdown.render(text)` returns HTML. `Kiln::Content::Markdown.render_inline(text)` drops the `<p>` around text that is a single paragraph, for titles and captions; other text renders as with `render`.
|
|
124
|
+
|
|
136
125
|
With `Kiln::Content.source_positions` on, each rendered block of a `.md` view or collection entry carries `data-tensaku-md="path:startLine:startCol-endLine:endCol"` for the Tensaku editor. The format is a draft. `.md.erb` views get none, because ERB shifts the line numbers. The setting defaults to on in development when Tensaku is loaded, and is always off while Kiln is building.
|
|
137
126
|
|
|
138
127
|
Collections read Markdown files with YAML frontmatter, such as `content/blog/2024-05-01-hello.md`:
|
|
@@ -144,7 +133,18 @@ posts.find("hello") # raises Kiln::Content::NotFound, which Rails maps to a 404
|
|
|
144
133
|
post.title, post.published_at, post.html, post[:tags]
|
|
145
134
|
```
|
|
146
135
|
|
|
147
|
-
|
|
136
|
+
A filename date may be `YYYY-MM-DD` or `YYYY-MM`. A month-only date sets `published_at` to the 1st of that month, and a filename that is only a date, such as `2026-08.md`, keeps the date as its slug.
|
|
137
|
+
|
|
138
|
+
`SLUG.LOCALE.md` is a translation of the entry with that slug, such as `2026-08.es.md` for `2026-08.md`. A locale is two lowercase letters, optionally followed by a dash and two uppercase letters (`es`, `pt-BR`). Translations stay out of `all`, `find` and `each`:
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
post.locale # nil for SLUG.md, "es" for SLUG.es.md
|
|
142
|
+
post.translation(:es) # the visible SLUG.es.md entry, or nil
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
A translation whose entry does not exist fails when the collection loads.
|
|
146
|
+
|
|
147
|
+
Entries with `draft: true` or a future `published_at` are hidden, except in development when Kiln is not building.
|
|
148
148
|
|
|
149
149
|
## Test the build
|
|
150
150
|
|
|
@@ -154,16 +154,20 @@ require "kiln/test_helper"
|
|
|
154
154
|
class StaticSiteTest < ActiveSupport::TestCase
|
|
155
155
|
include Kiln::TestHelper
|
|
156
156
|
|
|
157
|
-
test
|
|
157
|
+
test "the site builds repeatably" do
|
|
158
|
+
kiln_build(repeatable: true) { |output| assert output.join("index.html").file? }
|
|
159
|
+
end
|
|
160
|
+
|
|
158
161
|
test("home page") { assert_page "/", selector: "h1", text: "Welcome" }
|
|
159
162
|
end
|
|
160
163
|
```
|
|
161
164
|
|
|
162
|
-
`kiln_build { |output| ... }` builds into a temp dir, fails with the report on any error, and yields the output path.
|
|
165
|
+
`kiln_build { |output| ... }` builds into a temp dir, fails with the report on any error, and yields the output path. With `repeatable: true` it builds twice, fails with the list of differing files when the outputs differ, and yields the first output.
|
|
163
166
|
|
|
164
167
|
## Known limits
|
|
165
168
|
|
|
166
169
|
- Pages render one at a time.
|
|
170
|
+
- Redirects are written only as `_redirects`, which Cloudflare Pages and Netlify read. GitHub Pages ignores the file, so redirect routes do not redirect there.
|
|
167
171
|
- Wildcard rules in your own `public/_redirects` are written out, but the verifier and `kiln:serve` only follow exact rules.
|
|
168
172
|
- JS modules are found through the importmap. Relative `import "./x.js"` between files is not followed.
|
|
169
173
|
- Collections read `*.md` files. `.md.erb` works for views only.
|
data/lib/kiln/assets.rb
CHANGED
|
@@ -4,25 +4,37 @@ require "nokogiri"
|
|
|
4
4
|
require "set"
|
|
5
5
|
|
|
6
6
|
module Kiln
|
|
7
|
-
# Finds the /assets/* files the output uses and
|
|
8
|
-
#
|
|
7
|
+
# Finds the /assets/* files the output uses and exports them. Compiled assets are read from the
|
|
8
|
+
# compile directory; dynamic Propshaft (development, test) serves them through the app.
|
|
9
9
|
class Assets
|
|
10
10
|
CSS_REFERENCE = /url\(\s*["']?([^"')]+?)["']?\s*\)|@import\s+["']([^"']+)["']/
|
|
11
11
|
EXTERNAL = %r{\A(?:[a-z][a-z0-9+.-]*:|//|#)}i
|
|
12
12
|
|
|
13
13
|
class << self
|
|
14
|
-
# Compiles assets in-process when the app does not serve them dynamically.
|
|
15
|
-
#
|
|
14
|
+
# Compiles assets in-process when the app does not serve them dynamically. The compile goes to a
|
|
15
|
+
# temporary directory, never public/assets, so a development server of the same app that boots
|
|
16
|
+
# meanwhile never sees a production manifest. Returns a callable that restores the asset config
|
|
17
|
+
# and removes the directory, or nil when nothing was compiled. When assets are served
|
|
16
18
|
# dynamically, still runs the build steps hooked into assets:precompile (tailwindcss:build,
|
|
17
19
|
# javascript:build, css:build), so a development build never ships stale bundles.
|
|
18
20
|
def precompile(app)
|
|
19
21
|
return build_bundles(app) unless precompile?(app)
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
assets = app.config.assets
|
|
24
|
+
original = assets.slice(:output_path, :manifest_path)
|
|
25
|
+
dir = Pathname(Dir.mktmpdir("kiln-assets"))
|
|
26
|
+
cleanup = -> do
|
|
27
|
+
assets.merge!(original)
|
|
28
|
+
FileUtils.rm_rf(dir)
|
|
29
|
+
end
|
|
30
|
+
assets.merge!(output_path: dir, manifest_path: dir.join(".manifest.json"))
|
|
31
|
+
begin
|
|
32
|
+
run_precompile(app)
|
|
33
|
+
rescue Kiln::Error
|
|
34
|
+
cleanup.call
|
|
35
|
+
raise
|
|
36
|
+
end
|
|
37
|
+
cleanup
|
|
26
38
|
end
|
|
27
39
|
|
|
28
40
|
def precompile?(app)
|
|
@@ -32,6 +44,17 @@ module Kiln
|
|
|
32
44
|
!assets.server && !Pathname(assets.manifest_path).exist?
|
|
33
45
|
end
|
|
34
46
|
|
|
47
|
+
# Propshaft only adds load paths that exist at boot. On a fresh checkout, a bundler such as
|
|
48
|
+
# tailwindcss:build creates app/assets/builds during the build, too late to be served.
|
|
49
|
+
def check_builds!(app)
|
|
50
|
+
builds = app.root.join("app/assets/builds")
|
|
51
|
+
paths = app.config.respond_to?(:assets) && app.config.assets.respond_to?(:paths) && app.config.assets.paths
|
|
52
|
+
return unless paths && builds.directory? && paths.none? { Pathname(it) == builds }
|
|
53
|
+
|
|
54
|
+
raise Kiln::Error, "app/assets/builds did not exist when the app booted, so its assets are not served. " \
|
|
55
|
+
"Track app/assets/builds/.keep in version control, as a new Rails app does, and build again."
|
|
56
|
+
end
|
|
57
|
+
|
|
35
58
|
# Copies public/ into the output, minus public/assets. Files already in the output win, with a
|
|
36
59
|
# warning unless they are listed in `generated` (files Kiln writes on purpose, like 404.html).
|
|
37
60
|
def copy_public(from:, to:, report:, generated: [])
|
|
@@ -93,7 +116,6 @@ module Kiln
|
|
|
93
116
|
|
|
94
117
|
def export
|
|
95
118
|
queue = referenced_paths
|
|
96
|
-
queue.concat(all_paths) if @config.assets == :all
|
|
97
119
|
seen = Set.new
|
|
98
120
|
|
|
99
121
|
while (path = queue.shift)
|
|
@@ -105,15 +127,18 @@ module Kiln
|
|
|
105
127
|
private
|
|
106
128
|
# Writes one asset and returns the asset paths it references (CSS url() and @import).
|
|
107
129
|
def fetch(path)
|
|
108
|
-
|
|
109
|
-
unless
|
|
110
|
-
@
|
|
111
|
-
|
|
130
|
+
body = compiled(path)
|
|
131
|
+
unless body
|
|
132
|
+
response = @renderer.get(path)
|
|
133
|
+
unless response.success?
|
|
134
|
+
@report.error(:asset, "responded with #{response.status}", path:)
|
|
135
|
+
return []
|
|
136
|
+
end
|
|
137
|
+
body = response.body
|
|
112
138
|
end
|
|
113
139
|
|
|
114
140
|
extension = File.extname(path)
|
|
115
|
-
body =
|
|
116
|
-
body = Processors::StripSourceMaps.call(body) if @config.strip_source_maps && %w[.js .css].include?(extension)
|
|
141
|
+
body = Processors::StripSourceMaps.call(body) if %w[.js .css].include?(extension)
|
|
117
142
|
write(path, body)
|
|
118
143
|
extension == ".css" ? css_references(path, body) : []
|
|
119
144
|
rescue StandardError => error
|
|
@@ -121,6 +146,16 @@ module Kiln
|
|
|
121
146
|
[]
|
|
122
147
|
end
|
|
123
148
|
|
|
149
|
+
# The file from the compile directory when assets are precompiled, or nil.
|
|
150
|
+
def compiled(path)
|
|
151
|
+
assets = @app.config.assets
|
|
152
|
+
return if assets.server
|
|
153
|
+
|
|
154
|
+
dir = Pathname(assets.manifest_path).dirname.expand_path
|
|
155
|
+
file = dir.join(path.delete_prefix("#{prefix}/")).expand_path
|
|
156
|
+
file.binread if file.to_s.start_with?("#{dir}/") && file.file?
|
|
157
|
+
end
|
|
158
|
+
|
|
124
159
|
def write(path, body)
|
|
125
160
|
relative = path.delete_prefix("/")
|
|
126
161
|
file = @output.join(relative)
|
|
@@ -159,10 +194,6 @@ module Kiln
|
|
|
159
194
|
end
|
|
160
195
|
end
|
|
161
196
|
|
|
162
|
-
def all_paths
|
|
163
|
-
@app.assets.load_path.assets.map { "#{prefix}/#{it.digested_path}" }
|
|
164
|
-
end
|
|
165
|
-
|
|
166
197
|
# Normalizes a URL to an asset path ("/assets/app-abc.css"), or nil when it isn't one of ours.
|
|
167
198
|
def asset_path(url)
|
|
168
199
|
uri = URI.parse(url.to_s.strip)
|
data/lib/kiln/build.rb
CHANGED
|
@@ -4,9 +4,6 @@ module Kiln
|
|
|
4
4
|
# Runs the whole pipeline and returns a Report. Never raises for page-level problems: those go in
|
|
5
5
|
# the report so the build can show every failure at once.
|
|
6
6
|
class Build
|
|
7
|
-
# Passed to before_build / after_build hooks.
|
|
8
|
-
Context = Data.define(:output, :env, :manifest, :report)
|
|
9
|
-
|
|
10
7
|
# Development middleware that injects markup into every HTML response.
|
|
11
8
|
DEV_MIDDLEWARE = %w[Hotwire::Spark::Middleware WebConsole::Middleware Rack::MiniProfiler Rack::LiveReload].freeze
|
|
12
9
|
# Source-position attributes from in-page editors (Herb source attribution, Tensaku).
|
|
@@ -35,10 +32,9 @@ module Kiln
|
|
|
35
32
|
guard_output!
|
|
36
33
|
guard_dev_middleware!
|
|
37
34
|
ensure_secret_key_base
|
|
38
|
-
guard_static_files!
|
|
39
35
|
cleanup = Assets.precompile(app)
|
|
40
36
|
begin
|
|
41
|
-
|
|
37
|
+
Assets.check_builds!(app)
|
|
42
38
|
prepare_output
|
|
43
39
|
|
|
44
40
|
pages = Router.new(app.routes, pages_dsl, report:).pages
|
|
@@ -46,16 +42,15 @@ module Kiln
|
|
|
46
42
|
render_not_found if config.not_found
|
|
47
43
|
report_editor_markup
|
|
48
44
|
|
|
49
|
-
Assets.new(renderer:, output:, config:, report:).export
|
|
45
|
+
Assets.new(renderer:, output:, config:, report:, app:).export
|
|
50
46
|
write_files
|
|
51
|
-
Assets.copy_public(from: app.root.join("public"), to: output, report:, generated: generated_files)
|
|
52
|
-
write_redirects if
|
|
53
|
-
Sitemap.new(manifest, url: config.url).write(output) if config.
|
|
47
|
+
Assets.copy_public(from: app.root.join("public"), to: output, report:, generated: generated_files)
|
|
48
|
+
write_redirects if @redirects.any?
|
|
49
|
+
Sitemap.new(manifest, url: config.url).write(output) if config.url
|
|
54
50
|
write_cname if config.cname
|
|
55
51
|
write_manifest
|
|
56
52
|
|
|
57
|
-
Verifier.new(output, config:).verify(report)
|
|
58
|
-
run_hooks(config.after_build_hooks)
|
|
53
|
+
Verifier.new(output, config:).verify(report)
|
|
59
54
|
ensure
|
|
60
55
|
cleanup&.call
|
|
61
56
|
end
|
|
@@ -78,7 +73,7 @@ module Kiln
|
|
|
78
73
|
if response.redirect?
|
|
79
74
|
@redirects << Redirects::Entry.new(from: page.path, to: response.location, status: response.status)
|
|
80
75
|
elsif response.success?
|
|
81
|
-
body = response.html? ? Processors.
|
|
76
|
+
body = response.html? ? Processors::StripCSRF.call(response.body) : response.body
|
|
82
77
|
@editor_markup << page.path if response.html? && body.match?(EDITOR_MARKUP)
|
|
83
78
|
file = writer.write(page.path, response.content_type, body)
|
|
84
79
|
manifest.add(page.path, file:, body:, content_type: response.content_type, noindex: Sitemap.noindex?(response, body))
|
|
@@ -104,14 +99,14 @@ module Kiln
|
|
|
104
99
|
return report.error(:render, "404 page responded with #{response.status}", path: config.not_found)
|
|
105
100
|
end
|
|
106
101
|
|
|
107
|
-
body = Processors.
|
|
102
|
+
body = Processors::StripCSRF.call(response.body)
|
|
108
103
|
report.wrote(writer.write_file("404.html", body))
|
|
109
104
|
rescue StandardError => error
|
|
110
105
|
report.error(:render, describe(error), path: config.not_found)
|
|
111
106
|
end
|
|
112
107
|
|
|
113
108
|
def write_redirects
|
|
114
|
-
Redirects.new(@redirects,
|
|
109
|
+
report.wrote(Redirects.new(@redirects, host: config.host).write(output))
|
|
115
110
|
end
|
|
116
111
|
|
|
117
112
|
# The manifest is build state, not part of the site: it lives in tmp/, and only for the configured
|
|
@@ -143,17 +138,12 @@ module Kiln
|
|
|
143
138
|
[ ("404.html" if config.not_found), ("CNAME" if config.cname), *config.files.keys.map(&:to_s) ].compact
|
|
144
139
|
end
|
|
145
140
|
|
|
141
|
+
# config.cname = true takes the host from config.url.
|
|
146
142
|
def write_cname
|
|
147
|
-
|
|
148
|
-
|
|
143
|
+
host = config.cname == true ? config.url && URI(config.url).host : config.cname
|
|
144
|
+
return report.error(:write, "config.cname = true needs config.url", path: "/CNAME") unless host
|
|
149
145
|
|
|
150
|
-
|
|
151
|
-
context = Context.new(output:, env: Rails.env, manifest:, report:)
|
|
152
|
-
hooks.each do |hook|
|
|
153
|
-
hook.call(context)
|
|
154
|
-
rescue StandardError => error
|
|
155
|
-
report.error(:hook, describe(error))
|
|
156
|
-
end
|
|
146
|
+
report.wrote(writer.write_file("CNAME", "#{host}\n"))
|
|
157
147
|
end
|
|
158
148
|
|
|
159
149
|
def prepare_output
|
|
@@ -188,15 +178,6 @@ module Kiln
|
|
|
188
178
|
app.config.secret_key_base = SecureRandom.hex(64)
|
|
189
179
|
end
|
|
190
180
|
|
|
191
|
-
# After a precompile, assets are fetched from public/assets through ActionDispatch::Static.
|
|
192
|
-
def guard_static_files!
|
|
193
|
-
return unless app.config.respond_to?(:assets) && !app.config.assets.server
|
|
194
|
-
return if app.config.public_file_server.enabled
|
|
195
|
-
|
|
196
|
-
raise Kiln::Error, "Kiln fetches compiled assets through the public file server. " \
|
|
197
|
-
"Set config.public_file_server.enabled = true for the build environment."
|
|
198
|
-
end
|
|
199
|
-
|
|
200
181
|
def describe(error)
|
|
201
182
|
where = error.backtrace&.find { it.start_with?(app.root.to_s) }&.delete_prefix("#{app.root}/")
|
|
202
183
|
[ "#{error.class}: #{error.message}", where && "(#{where})" ].compact.join(" ")
|
data/lib/kiln/build_cache.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
module Kiln
|
|
2
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.
|
|
3
|
+
# repositories and collections keep their state for the whole build. Outside a build, fetch runs
|
|
4
|
+
# the block every time, so a development server never serves stale data.
|
|
4
5
|
class BuildCache
|
|
5
6
|
def initialize
|
|
6
7
|
@store = {}
|
|
@@ -8,6 +9,8 @@ module Kiln
|
|
|
8
9
|
end
|
|
9
10
|
|
|
10
11
|
def fetch(key)
|
|
12
|
+
return yield unless Kiln.building?
|
|
13
|
+
|
|
11
14
|
@mutex.synchronize do
|
|
12
15
|
return @store[key] if @store.key?(key)
|
|
13
16
|
|
|
@@ -15,9 +18,6 @@ module Kiln
|
|
|
15
18
|
end
|
|
16
19
|
end
|
|
17
20
|
|
|
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
21
|
def clear = @mutex.synchronize { @store.clear }
|
|
22
22
|
end
|
|
23
23
|
end
|
data/lib/kiln/configuration.rb
CHANGED
|
@@ -2,71 +2,37 @@ module Kiln
|
|
|
2
2
|
class Configuration
|
|
3
3
|
# Output directory, relative to Rails.root unless absolute.
|
|
4
4
|
attr_accessor :output
|
|
5
|
-
# Public base URL ("https://davafons.com").
|
|
5
|
+
# Public base URL ("https://davafons.com"). Sets the request host and enables sitemap.xml.
|
|
6
6
|
attr_accessor :url
|
|
7
7
|
# :directory ("/now" -> now/index.html) or :file ("/now" -> now.html).
|
|
8
8
|
attr_accessor :paths
|
|
9
|
-
#
|
|
10
|
-
attr_accessor :assets
|
|
11
|
-
# Writes a CNAME file with this domain when set.
|
|
9
|
+
# Writes a CNAME file with this domain when set. true takes the host from `url`.
|
|
12
10
|
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
11
|
# Path rendered to 404.html (expected to respond with 404 or 200), nil to skip.
|
|
20
12
|
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
13
|
# CSS selectors for sample markup whose links the verifier ignores (for example component demos).
|
|
28
14
|
attr_accessor :verify_skip
|
|
29
|
-
# Copy public/ into the output (minus public/assets).
|
|
30
|
-
attr_accessor :copy_public
|
|
31
15
|
# Extra files that aren't routes: { "octicons-license.txt" => "vendor/octicons/LICENSE" }.
|
|
32
16
|
# Sources are relative to Rails.root.
|
|
33
17
|
attr_accessor :files
|
|
34
18
|
|
|
35
|
-
attr_reader :before_build_hooks, :after_build_hooks
|
|
36
|
-
attr_writer :host
|
|
37
|
-
|
|
38
19
|
def initialize
|
|
39
20
|
@output = "dist"
|
|
40
21
|
@url = nil
|
|
41
|
-
@host = nil
|
|
42
22
|
@paths = :directory
|
|
43
|
-
@assets = :referenced
|
|
44
23
|
@cname = nil
|
|
45
|
-
@strip_source_maps = true
|
|
46
|
-
@processors = []
|
|
47
|
-
@absolute_urls = false
|
|
48
|
-
@external_links = false
|
|
49
24
|
@not_found = nil
|
|
50
|
-
@redirects = :file
|
|
51
|
-
@sitemap = true
|
|
52
|
-
@verify = true
|
|
53
25
|
@verify_skip = []
|
|
54
|
-
@copy_public = true
|
|
55
26
|
@files = {}
|
|
56
|
-
@before_build_hooks = []
|
|
57
|
-
@after_build_hooks = []
|
|
58
27
|
end
|
|
59
28
|
|
|
60
|
-
# Host sent with every request
|
|
29
|
+
# Host sent with every request: the host of `url`, or "localhost".
|
|
61
30
|
def host
|
|
62
|
-
|
|
31
|
+
(url && URI(url).host) || "localhost"
|
|
63
32
|
end
|
|
64
33
|
|
|
65
34
|
def output_path(root = Rails.root)
|
|
66
35
|
Pathname(output).absolute? ? Pathname(output) : Pathname(root).join(output)
|
|
67
36
|
end
|
|
68
|
-
|
|
69
|
-
def before_build(&block) = @before_build_hooks << block
|
|
70
|
-
def after_build(&block) = @after_build_hooks << block
|
|
71
37
|
end
|
|
72
38
|
end
|
|
@@ -8,8 +8,9 @@ module Kiln
|
|
|
8
8
|
def initialize(name, dir)
|
|
9
9
|
@name, @dir = name.to_s, Pathname(dir)
|
|
10
10
|
@stamp = stamp
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
translations, entries = files.map { Entry.new(@name, it) }.partition(&:locale)
|
|
12
|
+
@entries = entries.sort_by { [ it.published_at&.in_time_zone || Time.at(0), it.slug ] }.reverse
|
|
13
|
+
translations.each { attach(it) }
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
# Visible entries, newest first.
|
|
@@ -19,11 +20,7 @@ module Kiln
|
|
|
19
20
|
|
|
20
21
|
def each(&) = all.each(&)
|
|
21
22
|
|
|
22
|
-
def find(slug
|
|
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
|
|
23
|
+
def find(slug) = find_by(slug:) || raise(NotFound, "no #{name} entry with slug #{slug.inspect}")
|
|
27
24
|
|
|
28
25
|
def find_by(slug:) = all.find { it.slug == slug.to_s }
|
|
29
26
|
|
|
@@ -33,11 +30,14 @@ module Kiln
|
|
|
33
30
|
private
|
|
34
31
|
def files = Dir[@dir.join("*.md")].sort
|
|
35
32
|
|
|
36
|
-
def
|
|
33
|
+
def attach(translation)
|
|
34
|
+
entry = @entries.find { it.slug == translation.slug }
|
|
35
|
+
raise ArgumentError, "#{translation.path} is a translation, but no entry has the slug #{translation.slug.inspect}" unless entry
|
|
37
36
|
|
|
38
|
-
|
|
39
|
-
token.present? && ActiveSupport::SecurityUtils.secure_compare(entry.preview_token, token.to_s)
|
|
37
|
+
entry.add_translation(translation)
|
|
40
38
|
end
|
|
39
|
+
|
|
40
|
+
def stamp = files.map { [ it, File.mtime(it) ] }
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
43
|
end
|
data/lib/kiln/content/entry.rb
CHANGED
|
@@ -1,20 +1,36 @@
|
|
|
1
1
|
module Kiln
|
|
2
2
|
module Content
|
|
3
3
|
class Entry
|
|
4
|
-
|
|
4
|
+
# SLUG.md, or SLUG.LOCALE.md for a translation. A locale is two lowercase letters, optionally
|
|
5
|
+
# followed by a dash and two uppercase letters: es, ja, pt-BR.
|
|
6
|
+
FILENAME = /\A(?<name>.+?)(?:\.(?<locale>[a-z]{2}(?:-[A-Z]{2})?))?\.md\z/
|
|
7
|
+
# A leading YYYY-MM-DD or YYYY-MM date. The slug is the rest of the name, or the whole name when
|
|
8
|
+
# it is only a date. A month-only date means the 1st of that month.
|
|
9
|
+
DATED = /\A(?<date>\d{4}-\d{2}(?:-\d{2})?)(?:-(?<slug>.+))?\z/
|
|
5
10
|
|
|
6
|
-
attr_reader :collection, :path, :slug, :attributes, :body, :published_at
|
|
11
|
+
attr_reader :collection, :path, :slug, :locale, :attributes, :body, :published_at
|
|
7
12
|
|
|
8
13
|
def initialize(collection, path)
|
|
9
14
|
@collection, @path = collection.to_s, Pathname(path)
|
|
10
15
|
match = FILENAME.match(@path.basename.to_s)
|
|
11
|
-
@
|
|
16
|
+
@locale = match[:locale]
|
|
17
|
+
dated = DATED.match(match[:name])
|
|
18
|
+
@slug = (dated && dated[:slug]) || match[:name]
|
|
12
19
|
text = @path.read
|
|
13
20
|
@attributes, @body = Frontmatter.parse(text)
|
|
14
21
|
@frontmatter_lines = text.delete_suffix(body).count("\n")
|
|
15
|
-
@published_at = attributes["published_at"] || (
|
|
22
|
+
@published_at = attributes["published_at"] || (filename_date(dated[:date]) if dated)
|
|
23
|
+
@translations = {}
|
|
16
24
|
end
|
|
17
25
|
|
|
26
|
+
# The visible translation from SLUG.LOCALE.md, or nil.
|
|
27
|
+
def translation(locale)
|
|
28
|
+
entry = @translations[locale.to_s]
|
|
29
|
+
entry if entry && (Content.show_drafts || entry.published?)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def add_translation(entry) = @translations[entry.locale] = entry
|
|
33
|
+
|
|
18
34
|
def title = attributes["title"] || slug.titleize
|
|
19
35
|
def draft? = attributes["draft"] == true
|
|
20
36
|
def scheduled? = published_at.present? && published_at.in_time_zone.future?
|
|
@@ -29,15 +45,18 @@ module Kiln
|
|
|
29
45
|
def [](key) = attributes[key.to_s]
|
|
30
46
|
def to_h = attributes
|
|
31
47
|
|
|
32
|
-
def preview_token
|
|
33
|
-
OpenSSL::HMAC.hexdigest("SHA256", Content.preview_secret, "#{collection}/#{slug}")
|
|
34
|
-
end
|
|
35
|
-
|
|
36
48
|
def respond_to_missing?(name, include_private = false) = attributes.key?(name.to_s) || super
|
|
37
49
|
|
|
38
50
|
def method_missing(name, *args)
|
|
39
51
|
attributes.key?(name.to_s) && args.empty? ? attributes[name.to_s] : super
|
|
40
52
|
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
def filename_date(date)
|
|
56
|
+
Date.iso8601(date.length == 7 ? "#{date}-01" : date)
|
|
57
|
+
rescue Date::Error
|
|
58
|
+
raise ArgumentError, "#{path} starts with an invalid date: #{date}"
|
|
59
|
+
end
|
|
41
60
|
end
|
|
42
61
|
end
|
|
43
62
|
end
|
|
@@ -20,6 +20,16 @@ module Kiln
|
|
|
20
20
|
path ? SourcePosition.rewrite(html, path, line_offset) : html
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# Renders text that is a single paragraph without the wrapping <p>, for titles and captions.
|
|
24
|
+
# Text with any other block structure renders as with render.
|
|
25
|
+
def self.render_inline(text)
|
|
26
|
+
html = render(text)
|
|
27
|
+
block = Commonmarker.parse(text.to_s, options: OPTIONS).first_child
|
|
28
|
+
return html unless block&.type == :paragraph && block.next_sibling.nil?
|
|
29
|
+
|
|
30
|
+
html.delete_prefix("<p>").delete_suffix("</p>\n")
|
|
31
|
+
end
|
|
32
|
+
|
|
23
33
|
# Swaps a code block for raw HTML highlighted by Rouge, with CSS classes and no inline styles.
|
|
24
34
|
def self.highlight(node, sourcepos:)
|
|
25
35
|
code = node.string_content
|
data/lib/kiln/content.rb
CHANGED
|
@@ -30,10 +30,14 @@
|
|
|
30
30
|
# # config/kiln.rb
|
|
31
31
|
# params :post, -> { Kiln::Content.collection("blog").map { { slug: it.slug } } }
|
|
32
32
|
#
|
|
33
|
+
# Filenames. A leading YYYY-MM-DD or YYYY-MM date sets published_at (a month means its 1st day); a
|
|
34
|
+
# name that is only a date keeps it as the slug. SLUG.LOCALE.md is a translation of the SLUG entry,
|
|
35
|
+
# where LOCALE is two lowercase letters with an optional -XX region (es, pt-BR). Translations stay out
|
|
36
|
+
# of all/find/each: post.translation(:es) returns one or nil, and post.locale is nil or "es".
|
|
37
|
+
#
|
|
33
38
|
# Publishing. Entries with `draft: true` or a future `published_at` (frontmatter or filename date)
|
|
34
39
|
# are hidden. They show in development unless Kiln is building; set Kiln::Content.show_drafts to
|
|
35
|
-
# override.
|
|
36
|
-
# `posts.find(params[:slug], preview_token: params[:token])`, where the token is `post.preview_token`.
|
|
40
|
+
# override.
|
|
37
41
|
#
|
|
38
42
|
# Source positions. With Kiln::Content.source_positions on, each rendered block of a .md view or
|
|
39
43
|
# collection entry carries data-tensaku-md="path:startLine:startCol-endLine:endCol" for the Tensaku
|
|
@@ -41,7 +45,7 @@
|
|
|
41
45
|
# always off while Kiln is building.
|
|
42
46
|
#
|
|
43
47
|
# Settings: Kiln::Content.root (default Rails.root/content), Kiln::Content.show_drafts,
|
|
44
|
-
# Kiln::Content.
|
|
48
|
+
# Kiln::Content.source_positions.
|
|
45
49
|
begin
|
|
46
50
|
require "commonmarker"
|
|
47
51
|
require "rouge"
|
|
@@ -63,7 +67,7 @@ module Kiln
|
|
|
63
67
|
class NotFound < Kiln::Error; end
|
|
64
68
|
|
|
65
69
|
class << self
|
|
66
|
-
attr_writer :root, :show_drafts, :
|
|
70
|
+
attr_writer :root, :show_drafts, :source_positions
|
|
67
71
|
|
|
68
72
|
def root
|
|
69
73
|
Pathname(@root || Rails.root.join("content"))
|
|
@@ -80,10 +84,6 @@ module Kiln
|
|
|
80
84
|
Rails.env.development? && !defined?(::Tensaku).nil?
|
|
81
85
|
end
|
|
82
86
|
|
|
83
|
-
def preview_secret
|
|
84
|
-
@preview_secret || Rails.application.secret_key_base
|
|
85
|
-
end
|
|
86
|
-
|
|
87
87
|
def collection(name)
|
|
88
88
|
dir = root.join(name.to_s)
|
|
89
89
|
if Kiln.building?
|
|
@@ -95,7 +95,7 @@ module Kiln
|
|
|
95
95
|
end
|
|
96
96
|
|
|
97
97
|
def reset!
|
|
98
|
-
@root = @show_drafts = @
|
|
98
|
+
@root = @show_drafts = @source_positions = nil
|
|
99
99
|
@collections = {}
|
|
100
100
|
end
|
|
101
101
|
|
data/lib/kiln/pages.rb
CHANGED
|
@@ -9,7 +9,15 @@ module Kiln
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
# params :post, -> { Post.all.map { { slug: it.slug } } }
|
|
12
|
-
|
|
12
|
+
# params :health, locale: [ nil, "es", "ja" ]
|
|
13
|
+
#
|
|
14
|
+
# Value lists build every combination of their values. A nil value leaves the key out, for
|
|
15
|
+
# optional segments such as "(:locale)". A list may be a lambda, called at build time.
|
|
16
|
+
def params(route_name, source = nil, **values)
|
|
17
|
+
if values.any?
|
|
18
|
+
raise ArgumentError, "params :#{route_name} takes a source or value lists, not both" if source
|
|
19
|
+
source = -> { combinations(values) }
|
|
20
|
+
end
|
|
13
21
|
raise ArgumentError, "params :#{route_name} needs a callable source" unless source.respond_to?(:call)
|
|
14
22
|
@sources[route_name.to_sym] = source
|
|
15
23
|
end
|
|
@@ -31,5 +39,11 @@ module Kiln
|
|
|
31
39
|
end
|
|
32
40
|
|
|
33
41
|
def declared_names = (sources.keys + skips.keys).uniq
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
def combinations(values)
|
|
45
|
+
lists = values.map { |key, list| (list.respond_to?(:call) ? list.call : list).to_a.map { { key => it } } }
|
|
46
|
+
lists.first.product(*lists.drop(1)).map { it.reduce(:merge).compact }
|
|
47
|
+
end
|
|
34
48
|
end
|
|
35
49
|
end
|
|
@@ -7,7 +7,7 @@ module Kiln
|
|
|
7
7
|
# The newline csrf_meta_tags puts between its two tags goes with the first one.
|
|
8
8
|
META_TAG = /<meta\b(?=[^>]*\sname="csrf-(?:param|token)")[^>]*>(?:\n(?=<meta\b[^>]*\sname="csrf-))?/
|
|
9
9
|
|
|
10
|
-
def self.call(html
|
|
10
|
+
def self.call(html)
|
|
11
11
|
html.gsub(TOKEN_INPUT, "").gsub(META_TAG, "")
|
|
12
12
|
end
|
|
13
13
|
end
|
data/lib/kiln/redirects.rb
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
require "erb"
|
|
2
|
-
|
|
3
1
|
module Kiln
|
|
4
|
-
# Writes redirect responses as a _redirects file (Cloudflare Pages, Netlify)
|
|
5
|
-
# (GitHub Pages).
|
|
2
|
+
# Writes redirect responses as a _redirects file (Cloudflare Pages, Netlify).
|
|
6
3
|
class Redirects
|
|
7
4
|
Entry = Data.define(:from, :to, :status)
|
|
8
5
|
|
|
@@ -20,52 +17,19 @@ module Kiln
|
|
|
20
17
|
end
|
|
21
18
|
end
|
|
22
19
|
|
|
23
|
-
def initialize(entries,
|
|
20
|
+
def initialize(entries, host:)
|
|
24
21
|
@entries = entries.map { it.with(to: relativize(it.to, host)) }
|
|
25
|
-
@mode = mode
|
|
26
|
-
@writer = writer
|
|
27
|
-
@url = url
|
|
28
22
|
end
|
|
29
23
|
|
|
24
|
+
# Rules from a copied public/_redirects come first, so hand-written rules win. Returns the filename.
|
|
30
25
|
def write(output)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
26
|
+
file = Pathname(output).join(FILENAME)
|
|
27
|
+
existing = file.exist? ? file.read.chomp.then { it.empty? ? "" : "#{it}\n" } : ""
|
|
28
|
+
File.write(file, existing + @entries.map { "#{it.from} #{it.to} #{it.status}\n" }.join)
|
|
29
|
+
FILENAME
|
|
35
30
|
end
|
|
36
31
|
|
|
37
32
|
private
|
|
38
|
-
# Rules from a copied public/_redirects come first, so hand-written rules win.
|
|
39
|
-
def write_file(output)
|
|
40
|
-
file = Pathname(output).join(FILENAME)
|
|
41
|
-
existing = file.exist? ? file.read.chomp.then { it.empty? ? "" : "#{it}\n" } : ""
|
|
42
|
-
File.write(file, existing + @entries.map { "#{it.from} #{it.to} #{it.status}\n" }.join)
|
|
43
|
-
FILENAME
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def write_pages
|
|
47
|
-
@entries.map do |entry|
|
|
48
|
-
@writer.write_file(@writer.file_for(entry.from, "text/html"), page(entry.to))
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def page(to)
|
|
53
|
-
canonical = ERB::Util.html_escape(@url && to.start_with?("/") ? "#{@url.chomp("/")}#{to}" : to)
|
|
54
|
-
to = ERB::Util.html_escape(to)
|
|
55
|
-
<<~HTML
|
|
56
|
-
<!DOCTYPE html>
|
|
57
|
-
<html>
|
|
58
|
-
<head>
|
|
59
|
-
<meta charset="utf-8">
|
|
60
|
-
<title>Redirecting</title>
|
|
61
|
-
<meta http-equiv="refresh" content="0; url=#{to}">
|
|
62
|
-
<link rel="canonical" href="#{canonical}">
|
|
63
|
-
</head>
|
|
64
|
-
<body><a href="#{to}">#{to}</a></body>
|
|
65
|
-
</html>
|
|
66
|
-
HTML
|
|
67
|
-
end
|
|
68
|
-
|
|
69
33
|
def relativize(location, host)
|
|
70
34
|
uri = URI.parse(location)
|
|
71
35
|
return location unless uri.is_a?(URI::HTTP) && uri.host&.casecmp?(host)
|
data/lib/kiln/report.rb
CHANGED
|
@@ -3,7 +3,7 @@ module Kiln
|
|
|
3
3
|
class Report
|
|
4
4
|
Entry = Data.define(:kind, :message, :path)
|
|
5
5
|
|
|
6
|
-
KINDS = %i[uncovered_route render editor_markup asset broken_link missing_anchor importmap redirect write
|
|
6
|
+
KINDS = %i[uncovered_route render editor_markup asset broken_link missing_anchor importmap redirect write other].freeze
|
|
7
7
|
|
|
8
8
|
attr_reader :errors, :warnings, :written
|
|
9
9
|
|
data/lib/kiln/test_helper.rb
CHANGED
|
@@ -10,43 +10,39 @@ module Kiln
|
|
|
10
10
|
# class StaticSiteTest < ActiveSupport::TestCase
|
|
11
11
|
# include Kiln::TestHelper
|
|
12
12
|
#
|
|
13
|
-
# test("builds
|
|
13
|
+
# test("builds repeatably") { kiln_build(repeatable: true) { assert it.join("index.html").file? } }
|
|
14
14
|
# test("home page") { assert_page "/", selector: "h1", text: "Home" }
|
|
15
15
|
# end
|
|
16
16
|
module TestHelper
|
|
17
17
|
# Builds into a temp dir, fails with the report on any error, and yields the output path.
|
|
18
18
|
# Returns the Build when no block is given (the temp dir is then left for the caller).
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
# With repeatable: true it builds a second time and fails with the diff when the two outputs
|
|
20
|
+
# differ, then yields the first output.
|
|
21
|
+
def kiln_build(app: Rails.application, config: Kiln.config, repeatable: false)
|
|
22
|
+
dir = Pathname(Dir.mktmpdir("kiln-build"))
|
|
21
23
|
build = Build.new(app:, config:, output: dir)
|
|
22
24
|
assert build.run.success?, build.report.to_s
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
begin
|
|
26
|
-
yield Pathname(dir)
|
|
27
|
-
ensure
|
|
28
|
-
FileUtils.rm_rf(dir)
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
# Builds twice and asserts both outputs are byte-identical.
|
|
33
|
-
def assert_repeatable_build(app: Rails.application, config: Kiln.config)
|
|
34
|
-
kiln_build(app:, config:) do |first|
|
|
25
|
+
if repeatable
|
|
35
26
|
kiln_build(app:, config:) do |second|
|
|
36
|
-
diff = TreeDiff.call(
|
|
27
|
+
diff = TreeDiff.call(dir, second)
|
|
37
28
|
assert diff.empty?, "Kiln output differs between two builds:\n#{diff}"
|
|
38
29
|
end
|
|
39
30
|
end
|
|
31
|
+
return build unless block_given?
|
|
32
|
+
|
|
33
|
+
yield dir
|
|
34
|
+
ensure
|
|
35
|
+
FileUtils.rm_rf(dir) if dir && block_given?
|
|
40
36
|
end
|
|
41
37
|
|
|
42
|
-
# Renders one path the way a build does (
|
|
38
|
+
# Renders one path the way a build does (CSRF tokens stripped) and asserts it succeeds.
|
|
43
39
|
# Optional `selector` must match, and contain `text` when given. Returns the processed body.
|
|
44
40
|
def assert_page(path, status: 200, selector: nil, text: nil, app: Rails.application, config: Kiln.config)
|
|
45
41
|
response = Kiln.building! { Renderer.new(app, host: config.host).get(path) }
|
|
46
42
|
assert_equal status, response.status, "Expected #{path} to respond with #{status}"
|
|
47
43
|
|
|
48
44
|
body = response.body
|
|
49
|
-
body = Processors.
|
|
45
|
+
body = Processors::StripCSRF.call(body) if response.html?
|
|
50
46
|
|
|
51
47
|
if selector
|
|
52
48
|
nodes = Nokogiri::HTML5(body).css(selector)
|
data/lib/kiln/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: kiln
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Afonso
|
|
@@ -91,9 +91,6 @@ files:
|
|
|
91
91
|
- lib/kiln/manifest.rb
|
|
92
92
|
- lib/kiln/page.rb
|
|
93
93
|
- lib/kiln/pages.rb
|
|
94
|
-
- lib/kiln/processors.rb
|
|
95
|
-
- lib/kiln/processors/absolute_urls.rb
|
|
96
|
-
- lib/kiln/processors/external_links.rb
|
|
97
94
|
- lib/kiln/processors/strip_csrf.rb
|
|
98
95
|
- lib/kiln/processors/strip_source_maps.rb
|
|
99
96
|
- lib/kiln/railtie.rb
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
module Kiln
|
|
2
|
-
module Processors
|
|
3
|
-
# Prefixes root-relative image URLs with config.url: img src/srcset and og:/twitter: image meta tags.
|
|
4
|
-
# Feed readers and social previews need absolute URLs.
|
|
5
|
-
module AbsoluteUrls
|
|
6
|
-
IMG_TAG = /<img\b[^>]*>/
|
|
7
|
-
IMAGE_META = /<meta\b(?=[^>]*\s(?:property|name)="(?:og|twitter):image[^"]*")[^>]*>/
|
|
8
|
-
ROOT_RELATIVE = %r{\A/(?!/)}
|
|
9
|
-
|
|
10
|
-
def self.call(html, context)
|
|
11
|
-
base = context.config.url.to_s.chomp("/")
|
|
12
|
-
raise Kiln::Error, "config.url is required when absolute_urls is on" if base.empty?
|
|
13
|
-
|
|
14
|
-
html
|
|
15
|
-
.gsub(IMG_TAG) { |tag| tag.gsub(/(?<=\s)(src|srcset)="([^"]*)"/) { %(#{$1}="#{$1 == "src" ? absolute($2, base) : srcset($2, base)}") } }
|
|
16
|
-
.gsub(IMAGE_META) { |tag| tag.sub(/(?<=\s)content="([^"]*)"/) { %(content="#{absolute($1, base)}") } }
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def self.absolute(url, base) = url.match?(ROOT_RELATIVE) ? "#{base}#{url}" : url
|
|
20
|
-
|
|
21
|
-
def self.srcset(value, base)
|
|
22
|
-
value.gsub(/(\A|,)(\s*)([^\s,]+)/) { "#{$1}#{$2}#{absolute($3, base)}" }
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
end
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
module Kiln
|
|
2
|
-
module Processors
|
|
3
|
-
# Adds target="_blank" rel="noopener" to http(s) links that point to another host.
|
|
4
|
-
module ExternalLinks
|
|
5
|
-
LINK_TAG = /<a\b[^>]*\shref="https?:[^"]*"[^>]*>/
|
|
6
|
-
|
|
7
|
-
def self.call(html, context)
|
|
8
|
-
host = context.config.host
|
|
9
|
-
html.gsub(LINK_TAG) { |tag| external?(tag, host) ? mark(tag) : tag }
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
def self.external?(tag, host)
|
|
13
|
-
link_host = URI(tag[/(?<=\s)href="([^"]*)"/, 1]).host
|
|
14
|
-
link_host.present? && !link_host.casecmp?(host)
|
|
15
|
-
rescue URI::InvalidURIError
|
|
16
|
-
false
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def self.mark(tag)
|
|
20
|
-
tag = tag.sub(/(?<=\s)rel="([^"]*)"/) { $1.split.include?("noopener") ? $& : %(rel="#{$1} noopener") }
|
|
21
|
-
additions = [ (' target="_blank"' unless tag.match?(/\starget=/)), (' rel="noopener"' unless tag.match?(/\srel=/)) ]
|
|
22
|
-
tag.delete_suffix(">") + additions.join + ">"
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
end
|
data/lib/kiln/processors.rb
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
module Kiln
|
|
2
|
-
# HTML transforms applied to every rendered HTML response, in order: built-ins, then config.processors.
|
|
3
|
-
# Processors edit the HTML as a string and leave everything they don't match byte-for-byte intact.
|
|
4
|
-
module Processors
|
|
5
|
-
Context = Data.define(:path, :config, :response)
|
|
6
|
-
|
|
7
|
-
def self.apply(html, context)
|
|
8
|
-
config = context.config
|
|
9
|
-
html = StripCSRF.call(html, context)
|
|
10
|
-
html = AbsoluteUrls.call(html, context) if config.absolute_urls
|
|
11
|
-
html = ExternalLinks.call(html, context) if config.external_links
|
|
12
|
-
|
|
13
|
-
config.processors.reduce(html) do |current, processor|
|
|
14
|
-
result = processor.call(current, context)
|
|
15
|
-
unless result.is_a?(String)
|
|
16
|
-
raise Kiln::Error, "Processor #{processor.inspect} returned #{result.class} for #{context.path}, expected a String"
|
|
17
|
-
end
|
|
18
|
-
result
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
end
|