kiln 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE +20 -0
- data/README.md +164 -0
- data/lib/kiln/assets.rb +162 -0
- data/lib/kiln/build.rb +146 -0
- data/lib/kiln/build_cache.rb +23 -0
- data/lib/kiln/check.rb +22 -0
- data/lib/kiln/configuration.rb +65 -0
- data/lib/kiln/content/collection.rb +43 -0
- data/lib/kiln/content/entry.rb +36 -0
- data/lib/kiln/content/frontmatter.rb +18 -0
- data/lib/kiln/content/handlers.rb +16 -0
- data/lib/kiln/content/markdown.rb +19 -0
- data/lib/kiln/content.rb +103 -0
- data/lib/kiln/manifest.rb +43 -0
- data/lib/kiln/page.rb +8 -0
- data/lib/kiln/pages.rb +35 -0
- data/lib/kiln/processors/absolute_urls.rb +26 -0
- data/lib/kiln/processors/external_links.rb +26 -0
- data/lib/kiln/processors/strip_csrf.rb +15 -0
- data/lib/kiln/processors/strip_source_maps.rb +14 -0
- data/lib/kiln/processors.rb +22 -0
- data/lib/kiln/railtie.rb +22 -0
- data/lib/kiln/redirects.rb +78 -0
- data/lib/kiln/renderer.rb +39 -0
- data/lib/kiln/report.rb +55 -0
- data/lib/kiln/response.rb +13 -0
- data/lib/kiln/router.rb +142 -0
- data/lib/kiln/server.rb +38 -0
- data/lib/kiln/sitemap.rb +38 -0
- data/lib/kiln/static_resolver.rb +28 -0
- data/lib/kiln/test_helper.rb +59 -0
- data/lib/kiln/tree_diff.rb +25 -0
- data/lib/kiln/verifier.rb +97 -0
- data/lib/kiln/version.rb +3 -0
- data/lib/kiln/writer.rb +62 -0
- data/lib/kiln.rb +62 -0
- data/lib/tasks/kiln/build.rake +33 -0
- data/lib/tasks/kiln/routes.rake +12 -0
- data/lib/tasks/kiln/serve.rake +11 -0
- data/lib/tasks/kiln/verify.rake +15 -0
- metadata +144 -0
|
@@ -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
|
data/lib/kiln/content.rb
ADDED
|
@@ -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
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
|
data/lib/kiln/railtie.rb
ADDED
|
@@ -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
|
data/lib/kiln/report.rb
ADDED
|
@@ -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
|