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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8ba761c39b8d2ffe60d03a4cdf48fdbb6ab1a7d4717b9716ab7107423dd2b2f1
4
- data.tar.gz: 29f9eb12df579857663e8a44b1713928c7b7bbd4106da3cd6b52e645b2791a0f
3
+ metadata.gz: 2108cdc748f14c10c06ab2edbf346622c13887cac5ec16bc2fe6823dfb3d4b53
4
+ data.tar.gz: f5aabfc64b54bd944d8d2bb3de6a57d980204e4f4070dd23f3340376980b8326
5
5
  SHA512:
6
- metadata.gz: 6597f99b761b60cdc3dcae600c18ebd8beab61e8ab82cd4fec4c57d797ba7f03213669ba5aa458338f68e508d7bf67a6dd582fb0d97e45ec7ac3b7c1b3725aac
7
- data.tar.gz: c2036e9a40ba3458de6ecedb40da6a84b2e4a36775ef003e711d2a198fa1f566a5574660913df4fc5f168b553c03d04b46f32bd73df801c4b5d4895f0d2e8875
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, -> { %w[en es ja].map { { locale: it } } + [ {} ] } # scope "(:locale)"
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, for the sitemap and absolute URLs. |
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
- | `assets` | `:referenced` | Assets the output references (HTML, importmap, CSS `url()`). `:all` exports everything. |
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
- Hooks receive a context with `output`, `env`, `manifest` and `report`:
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
- - Builds fetch assets through the app, so keep `config.public_file_server.enabled = true` for the build environment.
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
- Entries with `draft: true` or a future `published_at` are hidden, except in development when Kiln is not building. A server-rendered app can share a hidden entry with `posts.find(slug, preview_token: params[:token])`.
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("builds the same output twice") { assert_repeatable_build }
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 fetches them through the app, so the same code works
8
- # with dynamic Propshaft (development, test) and with precompiled assets served from public/assets.
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. Returns a callable that
15
- # removes what the precompile created, or nil when nothing was compiled. When assets are served
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
- output_path = Pathname(app.config.assets.output_path)
22
- created = !output_path.exist?
23
- run_precompile(app)
24
-
25
- -> { created ? FileUtils.rm_rf(output_path) : FileUtils.rm_f(app.config.assets.manifest_path) }
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
- response = @renderer.get(path)
109
- unless response.success?
110
- @report.error(:asset, "responded with #{response.status}", path:)
111
- return []
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 = response.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
- run_hooks(config.before_build_hooks)
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) if config.copy_public
52
- write_redirects if config.redirects && @redirects.any?
53
- Sitemap.new(manifest, url: config.url).write(output) if config.sitemap && config.url
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) if config.verify
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.apply(response.body, Processors::Context.new(path: page.path, config:, response:)) : response.body
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.apply(response.body, Processors::Context.new(path: config.not_found, config:, response:))
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, mode: config.redirects, writer:, host: config.host, url: config.url).write(output).each { report.wrote(it) }
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
- report.wrote(writer.write_file("CNAME", "#{config.cname}\n"))
148
- end
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
- def run_hooks(hooks)
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(" ")
@@ -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
@@ -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"). Used by the sitemap and absolute_urls processor.
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
- # :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.
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. Derived from `url`, falls back to "localhost".
29
+ # Host sent with every request: the host of `url`, or "localhost".
61
30
  def host
62
- @host || (url && URI(url).host) || "localhost"
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
- @entries = files.map { Entry.new(@name, it) }
12
- .sort_by { [ it.published_at&.in_time_zone || Time.at(0), it.slug ] }.reverse
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, 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
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 stamp = files.map { [ it, File.mtime(it) ] }
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
- def preview?(entry, token)
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
@@ -1,20 +1,36 @@
1
1
  module Kiln
2
2
  module Content
3
3
  class Entry
4
- FILENAME = /\A(?:(?<date>\d{4}-\d{2}-\d{2})-)?(?<slug>.+?)\.md\z/
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
- @slug = match[:slug]
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"] || (Date.iso8601(match[:date]) if match[:date])
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. A server-rendered app can share a hidden entry with
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.preview_secret (default secret_key_base), Kiln::Content.source_positions.
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, :preview_secret, :source_positions
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 = @preview_secret = @source_positions = nil
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
- def params(route_name, source)
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, _context = nil)
10
+ def self.call(html)
11
11
  html.gsub(TOKEN_INPUT, "").gsub(META_TAG, "")
12
12
  end
13
13
  end
@@ -1,8 +1,5 @@
1
- require "erb"
2
-
3
1
  module Kiln
4
- # Writes redirect responses as a _redirects file (Cloudflare Pages, Netlify) and/or meta refresh pages
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, mode:, writer:, host:, url: nil)
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
- files = []
32
- files << write_file(output) if %i[file both].include?(@mode)
33
- files.concat(write_pages) if %i[meta_refresh both].include?(@mode)
34
- files
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 hook other].freeze
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
 
@@ -10,43 +10,39 @@ module Kiln
10
10
  # class StaticSiteTest < ActiveSupport::TestCase
11
11
  # include Kiln::TestHelper
12
12
  #
13
- # test("builds the same output twice") { assert_repeatable_build }
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
- def kiln_build(app: Rails.application, config: Kiln.config)
20
- dir = Dir.mktmpdir("kiln-build")
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
- return build unless block_given?
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(first, second)
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 (processors included) and asserts it succeeds.
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.apply(body, Processors::Context.new(path:, config:, response:)) if response.html?
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
@@ -1,3 +1,3 @@
1
1
  module Kiln
2
- VERSION = "0.1.1"
2
+ VERSION = "0.1.2"
3
3
  end
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.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
@@ -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