kiln 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE +20 -0
  4. data/README.md +164 -0
  5. data/lib/kiln/assets.rb +162 -0
  6. data/lib/kiln/build.rb +146 -0
  7. data/lib/kiln/build_cache.rb +23 -0
  8. data/lib/kiln/check.rb +22 -0
  9. data/lib/kiln/configuration.rb +65 -0
  10. data/lib/kiln/content/collection.rb +43 -0
  11. data/lib/kiln/content/entry.rb +36 -0
  12. data/lib/kiln/content/frontmatter.rb +18 -0
  13. data/lib/kiln/content/handlers.rb +16 -0
  14. data/lib/kiln/content/markdown.rb +19 -0
  15. data/lib/kiln/content.rb +103 -0
  16. data/lib/kiln/manifest.rb +43 -0
  17. data/lib/kiln/page.rb +8 -0
  18. data/lib/kiln/pages.rb +35 -0
  19. data/lib/kiln/processors/absolute_urls.rb +26 -0
  20. data/lib/kiln/processors/external_links.rb +26 -0
  21. data/lib/kiln/processors/strip_csrf.rb +15 -0
  22. data/lib/kiln/processors/strip_source_maps.rb +14 -0
  23. data/lib/kiln/processors.rb +22 -0
  24. data/lib/kiln/railtie.rb +22 -0
  25. data/lib/kiln/redirects.rb +78 -0
  26. data/lib/kiln/renderer.rb +39 -0
  27. data/lib/kiln/report.rb +55 -0
  28. data/lib/kiln/response.rb +13 -0
  29. data/lib/kiln/router.rb +142 -0
  30. data/lib/kiln/server.rb +38 -0
  31. data/lib/kiln/sitemap.rb +38 -0
  32. data/lib/kiln/static_resolver.rb +28 -0
  33. data/lib/kiln/test_helper.rb +59 -0
  34. data/lib/kiln/tree_diff.rb +25 -0
  35. data/lib/kiln/verifier.rb +97 -0
  36. data/lib/kiln/version.rb +3 -0
  37. data/lib/kiln/writer.rb +62 -0
  38. data/lib/kiln.rb +62 -0
  39. data/lib/tasks/kiln/build.rake +33 -0
  40. data/lib/tasks/kiln/routes.rake +12 -0
  41. data/lib/tasks/kiln/serve.rake +11 -0
  42. data/lib/tasks/kiln/verify.rake +15 -0
  43. metadata +144 -0
@@ -0,0 +1,36 @@
1
+ module Kiln
2
+ module Content
3
+ class Entry
4
+ FILENAME = /\A(?:(?<date>\d{4}-\d{2}-\d{2})-)?(?<slug>.+?)\.md\z/
5
+
6
+ attr_reader :collection, :path, :slug, :attributes, :body, :published_at
7
+
8
+ def initialize(collection, path)
9
+ @collection, @path = collection.to_s, Pathname(path)
10
+ match = FILENAME.match(@path.basename.to_s)
11
+ @slug = match[:slug]
12
+ @attributes, @body = Frontmatter.parse(@path.read)
13
+ @published_at = attributes["published_at"] || (Date.iso8601(match[:date]) if match[:date])
14
+ end
15
+
16
+ def title = attributes["title"] || slug.titleize
17
+ def draft? = attributes["draft"] == true
18
+ def scheduled? = published_at.present? && published_at.in_time_zone.future?
19
+ def published? = !draft? && !scheduled?
20
+ def html = @html ||= Markdown.render(body).html_safe
21
+ def to_param = slug
22
+ def [](key) = attributes[key.to_s]
23
+ def to_h = attributes
24
+
25
+ def preview_token
26
+ OpenSSL::HMAC.hexdigest("SHA256", Content.preview_secret, "#{collection}/#{slug}")
27
+ end
28
+
29
+ def respond_to_missing?(name, include_private = false) = attributes.key?(name.to_s) || super
30
+
31
+ def method_missing(name, *args)
32
+ attributes.key?(name.to_s) && args.empty? ? attributes[name.to_s] : super
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,18 @@
1
+ module Kiln
2
+ module Content
3
+ module Frontmatter
4
+ PATTERN = /\A---[ \t]*\r?\n(.*?)^---[ \t]*(?:\r?\n|\z)/m
5
+
6
+ # Returns [attributes, body]. Attributes have string keys.
7
+ def self.parse(text)
8
+ match = PATTERN.match(text)
9
+ return [ {}, text ] unless match
10
+
11
+ attributes = YAML.safe_load(match[1], permitted_classes: [ Date, Time, Symbol ]) || {}
12
+ raise ArgumentError, "frontmatter must be a YAML mapping" unless attributes.is_a?(Hash)
13
+
14
+ [ attributes.transform_keys(&:to_s), match.post_match ]
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,16 @@
1
+ module Kiln
2
+ module Content
3
+ # ActionView template handlers. A handler returns Ruby source that evaluates to the output.
4
+ module Handlers
5
+ # Plain Markdown never changes between renders, so convert it once at compile time.
6
+ Markdown = ->(_template, source) { "#{Content::Markdown.render(source).inspect}.html_safe" }
7
+
8
+ # ERB fills a fresh output buffer, then its result goes through Markdown on every render.
9
+ # "begin;" keeps the ERB source on its original line numbers.
10
+ MarkdownERB = ->(template, source) do
11
+ erb = ActionView::Template.registered_template_handler(:erb).call(template, source)
12
+ "Kiln::Content::Markdown.render(begin;#{erb}\nend.to_s).html_safe"
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,19 @@
1
+ module Kiln
2
+ module Content
3
+ module Markdown
4
+ EXTENSIONS = { fenced_code_blocks: true, tables: true, autolink: true, strikethrough: true }.freeze
5
+
6
+ class Renderer < Redcarpet::Render::HTML
7
+ def block_code(code, language)
8
+ lexer = Rouge::Lexer.find_fancy(language.to_s, code) || Rouge::Lexers::PlainText.new
9
+ %(<pre class="highlight #{lexer.tag}"><code>#{Rouge::Formatters::HTML.new.format(lexer.lex(code))}</code></pre>\n)
10
+ end
11
+ end
12
+
13
+ # A new parser per call: Redcarpet parsers aren't safe to share between threads.
14
+ def self.render(text)
15
+ Redcarpet::Markdown.new(Renderer.new(with_toc_data: true), EXTENSIONS).render(text.to_s)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,103 @@
1
+ # Kiln content layer: Markdown views and Markdown collections with YAML frontmatter.
2
+ # Optional and independent of the exporter, so server-rendered Rails apps can use it too.
3
+ #
4
+ # # Gemfile
5
+ # gem "redcarpet"
6
+ # gem "rouge"
7
+ #
8
+ # # config/initializers/kiln.rb
9
+ # require "kiln/content"
10
+ #
11
+ # Markdown views. app/views/pages/about.html.md renders through the layout like any view.
12
+ # about.html.md.erb runs ERB first, then Markdown. Code blocks are highlighted by Rouge with CSS
13
+ # classes, so add a Rouge theme to your stylesheet (`rougify style github`).
14
+ #
15
+ # Collections. content/blog/2024-05-01-hello.md:
16
+ #
17
+ # ---
18
+ # title: Hello
19
+ # tags: [rails]
20
+ # ---
21
+ # # Hello
22
+ #
23
+ # posts = Kiln::Content.collection("blog")
24
+ # posts.all # visible entries, newest first
25
+ # posts.find("hello") # raises Kiln::Content::NotFound (a 404 in Rails)
26
+ # posts.find_by(slug: "hello") # or nil
27
+ # post.slug, post.title, post.published_at, post.draft?, post.body, post.html, post[:tags], post.tags
28
+ #
29
+ # # config/kiln.rb
30
+ # params :post, -> { Kiln::Content.collection("blog").map { { slug: it.slug } } }
31
+ #
32
+ # Publishing. Entries with `draft: true` or a future `published_at` (frontmatter or filename date)
33
+ # are hidden. They show in development unless Kiln is building; set Kiln::Content.show_drafts to
34
+ # override. A server-rendered app can share a hidden entry with
35
+ # `posts.find(params[:slug], preview_token: params[:token])`, where the token is `post.preview_token`.
36
+ #
37
+ # Settings: Kiln::Content.root (default Rails.root/content), Kiln::Content.show_drafts,
38
+ # Kiln::Content.preview_secret (default secret_key_base).
39
+ begin
40
+ require "redcarpet"
41
+ require "rouge"
42
+ rescue LoadError => error
43
+ raise LoadError, "kiln/content needs the redcarpet and rouge gems. " \
44
+ "Add `gem \"redcarpet\"` and `gem \"rouge\"` to your Gemfile. (#{error.message})"
45
+ end
46
+
47
+ require "kiln"
48
+ require "action_dispatch"
49
+ require_relative "content/markdown"
50
+ require_relative "content/frontmatter"
51
+ require_relative "content/handlers"
52
+ require_relative "content/entry"
53
+ require_relative "content/collection"
54
+
55
+ module Kiln
56
+ module Content
57
+ class NotFound < Kiln::Error; end
58
+
59
+ class << self
60
+ attr_writer :root, :show_drafts, :preview_secret
61
+
62
+ def root
63
+ Pathname(@root || Rails.root.join("content"))
64
+ end
65
+
66
+ def show_drafts
67
+ return @show_drafts unless @show_drafts.nil?
68
+ Rails.env.development? && !Kiln.building?
69
+ end
70
+
71
+ def preview_secret
72
+ @preview_secret || Rails.application.secret_key_base
73
+ end
74
+
75
+ def collection(name)
76
+ dir = root.join(name.to_s)
77
+ if Kiln.building?
78
+ Kiln.build_cache.fetch("kiln/content/#{dir}") { Collection.new(name, dir) }
79
+ else
80
+ cached = collections[dir]
81
+ cached.nil? || cached.stale? ? collections[dir] = Collection.new(name, dir) : cached
82
+ end
83
+ end
84
+
85
+ def reset!
86
+ @root = @show_drafts = @preview_secret = nil
87
+ @collections = {}
88
+ end
89
+
90
+ private
91
+ def collections
92
+ @collections ||= {}
93
+ end
94
+ end
95
+ end
96
+ end
97
+
98
+ ActionDispatch::ExceptionWrapper.rescue_responses["Kiln::Content::NotFound"] = :not_found
99
+
100
+ ActiveSupport.on_load(:action_view) do
101
+ ActionView::Template.register_template_handler :md, Kiln::Content::Handlers::Markdown
102
+ ActionView::Template.register_template_handler :"md.erb", Kiln::Content::Handlers::MarkdownERB
103
+ end
@@ -0,0 +1,43 @@
1
+ require "digest"
2
+ require "json"
3
+
4
+ module Kiln
5
+ # Every page written by a build: path => file, content digest, content type and noindex flag.
6
+ class Manifest
7
+ FILENAME = "kiln-manifest.json"
8
+
9
+ def self.load(file)
10
+ new(JSON.parse(File.read(file)).transform_values(&:symbolize_keys))
11
+ end
12
+
13
+ def initialize(entries = {})
14
+ @entries = entries
15
+ @mutex = Mutex.new
16
+ end
17
+
18
+ def add(path, file:, body:, content_type:, noindex: false)
19
+ entry = { content_type:, digest: Digest::SHA256.hexdigest(body), file: file.to_s, noindex: }
20
+ @mutex.synchronize { @entries[path] = entry }
21
+ end
22
+
23
+ def entries
24
+ @entries.sort.to_h { |path, entry| [ path, entry.sort.to_h ] }
25
+ end
26
+
27
+ def write(output)
28
+ File.write(Pathname(output).join(FILENAME), "#{JSON.pretty_generate(entries)}\n")
29
+ FILENAME
30
+ end
31
+
32
+ # Changes from this manifest to `other` (old.diff(new)).
33
+ def diff(other)
34
+ mine = entries
35
+ theirs = other.entries
36
+ {
37
+ added: theirs.keys - mine.keys,
38
+ removed: mine.keys - theirs.keys,
39
+ changed: (mine.keys & theirs.keys).reject { mine[it][:digest] == theirs[it][:digest] }
40
+ }
41
+ end
42
+ end
43
+ end
data/lib/kiln/page.rb ADDED
@@ -0,0 +1,8 @@
1
+ module Kiln
2
+ # One concrete path to render. `route_name` is the Rails route name (nil for unnamed routes).
3
+ Page = Data.define(:path, :route_name, :params) do
4
+ def initialize(path:, route_name: nil, params: {})
5
+ super
6
+ end
7
+ end
8
+ end
data/lib/kiln/pages.rb ADDED
@@ -0,0 +1,35 @@
1
+ module Kiln
2
+ # Declarations from config/kiln.rb: param sources and skips, keyed by route name.
3
+ class Pages
4
+ attr_reader :sources, :skips
5
+
6
+ def initialize
7
+ @sources = {}
8
+ @skips = {}
9
+ end
10
+
11
+ # params :post, -> { Post.all.map { { slug: it.slug } } }
12
+ def params(route_name, source)
13
+ raise ArgumentError, "params :#{route_name} needs a callable source" unless source.respond_to?(:call)
14
+ @sources[route_name.to_sym] = source
15
+ end
16
+
17
+ def skip(route_name, reason: nil)
18
+ @skips[route_name.to_sym] = reason
19
+ end
20
+
21
+ # paginate :posts_page, per: 10, total: -> { Post.count }
22
+ # Builds pages start..last, where last is ceil(total / per). The start page is always built, so an
23
+ # empty list still gets its first page. Use start: 2 when page 1 lives at another route.
24
+ def paginate(route_name, per:, total:, param: :page, start: 1)
25
+ raise ArgumentError, "paginate :#{route_name} needs a positive per:" unless per.is_a?(Integer) && per.positive?
26
+
27
+ params route_name, -> do
28
+ last = Integer(total.call).fdiv(per).ceil
29
+ (start..[ start, last ].max).map { { param => it } }
30
+ end
31
+ end
32
+
33
+ def declared_names = (sources.keys + skips.keys).uniq
34
+ end
35
+ end
@@ -0,0 +1,26 @@
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
@@ -0,0 +1,26 @@
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
@@ -0,0 +1,15 @@
1
+ module Kiln
2
+ module Processors
3
+ # Removes authenticity_token inputs and the csrf-param / csrf-token meta tags, leaving the page as
4
+ # Rails renders it with forgery protection off. A static page has no session to check tokens against.
5
+ module StripCSRF
6
+ TOKEN_INPUT = /<input\b(?=[^>]*\bname="authenticity_token")[^>]*>/
7
+ # The newline csrf_meta_tags puts between its two tags goes with the first one.
8
+ META_TAG = /<meta\b(?=[^>]*\sname="csrf-(?:param|token)")[^>]*>(?:\n(?=<meta\b[^>]*\sname="csrf-))?/
9
+
10
+ def self.call(html, _context = nil)
11
+ html.gsub(TOKEN_INPUT, "").gsub(META_TAG, "")
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ module Kiln
2
+ module Processors
3
+ # Removes sourceMappingURL comments from JavaScript and CSS.
4
+ module StripSourceMaps
5
+ COMMENT = %r{//[#@] sourceMappingURL=[^\r\n]*|/\*[#@] sourceMappingURL=[^*]*\*/}
6
+ # A comment on its own line goes with its line break; one after code leaves the line intact.
7
+ PATTERN = /^[ \t]*(?:#{COMMENT})[ \t]*(?:\r?\n|\z)|(?:#{COMMENT})/
8
+
9
+ def self.call(text)
10
+ text.gsub(PATTERN, "")
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,22 @@
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
@@ -0,0 +1,22 @@
1
+ require "rails/railtie"
2
+
3
+ module Kiln
4
+ class Railtie < ::Rails::Railtie
5
+ # config/kiln.rb holds Kiln.configure and Kiln.pages. Param sources are lambdas, so loading it
6
+ # early (before the app's models exist) is fine.
7
+ initializer "kiln.load_config", before: :load_config_initializers do |app|
8
+ file = app.root.join("config/kiln.rb")
9
+ load file if file.exist?
10
+ end
11
+
12
+ # Requests carry the configured host. Allow it when the app restricts hosts.
13
+ initializer "kiln.hosts", after: "kiln.load_config" do |app|
14
+ hosts = app.config.hosts
15
+ hosts << Kiln.config.host if hosts.any? && !hosts.include?(Kiln.config.host)
16
+ end
17
+
18
+ rake_tasks do
19
+ Dir[File.expand_path("../tasks/kiln/*.rake", __dir__)].sort.each { load it }
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,78 @@
1
+ require "erb"
2
+
3
+ module Kiln
4
+ # Writes redirect responses as a _redirects file (Cloudflare Pages, Netlify) and/or meta refresh pages
5
+ # (GitHub Pages).
6
+ class Redirects
7
+ Entry = Data.define(:from, :to, :status)
8
+
9
+ FILENAME = "_redirects"
10
+
11
+ # Reads a _redirects file. Missing file means no rules.
12
+ def self.load(file)
13
+ return [] unless File.file?(file)
14
+
15
+ File.readlines(file, chomp: true).filter_map do |line|
16
+ from, to, status = line.strip.split(/\s+/)
17
+ next if from.nil? || from.start_with?("#") || to.nil?
18
+
19
+ Entry.new(from:, to:, status: Integer(status || 302, exception: false) || 302)
20
+ end
21
+ end
22
+
23
+ def initialize(entries, mode:, writer:, host:, url: nil)
24
+ @entries = entries.map { it.with(to: relativize(it.to, host)) }
25
+ @mode = mode
26
+ @writer = writer
27
+ @url = url
28
+ end
29
+
30
+ 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
35
+ end
36
+
37
+ 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
+ def relativize(location, host)
70
+ uri = URI.parse(location)
71
+ return location unless uri.is_a?(URI::HTTP) && uri.host&.casecmp?(host)
72
+
73
+ "#{uri.path.presence || "/"}#{"?#{uri.query}" if uri.query}#{"##{uri.fragment}" if uri.fragment}"
74
+ rescue URI::InvalidURIError
75
+ location
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,39 @@
1
+ module Kiln
2
+ # Renders one path through the app's full Rack stack, the same way a browser request would.
3
+ class Renderer
4
+ TEXT_TYPE = %r{\Atext/|json|xml|javascript}
5
+
6
+ def initialize(app, host:)
7
+ @app = app
8
+ @host = host
9
+ end
10
+
11
+ # Never follows redirects. App exceptions raise instead of rendering an error page.
12
+ def get(path)
13
+ response = raising_exceptions { Rack::MockRequest.new(@app).get(path, "HTTP_HOST" => @host, "HTTPS" => "on", "rack.url_scheme" => "https", "REMOTE_ADDR" => "127.0.0.1") }
14
+ headers = response.headers.to_h.transform_keys(&:downcase)
15
+ Response.new(path:, status: response.status, headers:, body: encode(response.body, headers["content-type"]))
16
+ end
17
+
18
+ private
19
+ # Rails merges its env_config over the request env, so "action_dispatch.show_exceptions" can't be
20
+ # set per request. Switch it on the app for the duration of the call instead.
21
+ def raising_exceptions
22
+ return yield unless @app.respond_to?(:env_config)
23
+
24
+ config = @app.env_config
25
+ previous = config["action_dispatch.show_exceptions"]
26
+ config["action_dispatch.show_exceptions"] = :none
27
+ begin
28
+ yield
29
+ ensure
30
+ config["action_dispatch.show_exceptions"] = previous
31
+ end
32
+ end
33
+
34
+ def encode(body, content_type)
35
+ body = +body
36
+ content_type.to_s.match?(TEXT_TYPE) ? body.force_encoding(Encoding::UTF_8) : body.force_encoding(Encoding::BINARY)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,55 @@
1
+ module Kiln
2
+ # Collects every problem found during a build so they can be printed together.
3
+ class Report
4
+ Entry = Data.define(:kind, :message, :path)
5
+
6
+ KINDS = %i[uncovered_route render asset broken_link missing_anchor importmap redirect write hook other].freeze
7
+
8
+ attr_reader :errors, :warnings, :written
9
+
10
+ def initialize
11
+ @errors = []
12
+ @warnings = []
13
+ @written = []
14
+ @mutex = Mutex.new
15
+ end
16
+
17
+ def error(kind, message, path: nil)
18
+ @mutex.synchronize { @errors << Entry.new(kind:, message:, path:) }
19
+ end
20
+
21
+ def warn(kind, message, path: nil)
22
+ @mutex.synchronize { @warnings << Entry.new(kind:, message:, path:) }
23
+ end
24
+
25
+ # Records a file written to the output (relative path).
26
+ def wrote(file)
27
+ @mutex.synchronize { @written << file.to_s }
28
+ end
29
+
30
+ def success? = errors.empty?
31
+ def failure? = !success?
32
+
33
+ def to_s(title: "Kiln build")
34
+ lines = []
35
+ warnings.each { lines << "warning #{format_entry(it)}" }
36
+ if errors.any?
37
+ lines << "" if warnings.any?
38
+ lines << "#{title} failed with #{errors.size} #{"error".pluralize(errors.size)}:"
39
+ errors.group_by(&:kind).each do |kind, entries|
40
+ lines << ""
41
+ lines << " #{kind.to_s.humanize} (#{entries.size})"
42
+ entries.each { lines << " #{format_entry(it)}" }
43
+ end
44
+ else
45
+ lines << "#{title} succeeded: #{written.size} #{"file".pluralize(written.size)} written."
46
+ end
47
+ lines.join("\n")
48
+ end
49
+
50
+ private
51
+ def format_entry(entry)
52
+ entry.path ? "#{entry.path}: #{entry.message}" : entry.message
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,13 @@
1
+ module Kiln
2
+ # What the app returned for one path.
3
+ Response = Data.define(:path, :status, :headers, :body) do
4
+ def content_type
5
+ headers["content-type"].to_s.split(";").first.to_s.strip.downcase
6
+ end
7
+
8
+ def html? = content_type == "text/html"
9
+ def success? = status.between?(200, 299)
10
+ def redirect? = [ 301, 302, 303, 307, 308 ].include?(status)
11
+ def location = headers["location"]
12
+ end
13
+ end