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
data/lib/kiln/router.rb
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
module Kiln
|
|
2
|
+
# Walks the app's GET routes and turns them into concrete pages, using the param sources and skips
|
|
3
|
+
# from Kiln.pages. Routes that need params but have no source are reported as uncovered.
|
|
4
|
+
class Router
|
|
5
|
+
# status is :built (count pages), :skipped (reason) or :uncovered.
|
|
6
|
+
Row = Data.define(:name, :path, :status, :count, :reason)
|
|
7
|
+
|
|
8
|
+
# Framework routes skipped by default, keyed by "controller#action" or a whole "controller".
|
|
9
|
+
DEFAULT_SKIPS = {
|
|
10
|
+
"rails/health#show" => "Rails health check",
|
|
11
|
+
"turbo/native/navigation" => "turbo-rails native navigation"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def initialize(routes, pages, report:)
|
|
15
|
+
@routes = routes
|
|
16
|
+
@dsl = pages
|
|
17
|
+
@report = report
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def pages
|
|
21
|
+
analyze
|
|
22
|
+
@pages
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def rows
|
|
26
|
+
analyze
|
|
27
|
+
@rows
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Text table for `kiln:routes`.
|
|
31
|
+
def table
|
|
32
|
+
lines = rows.map do |row|
|
|
33
|
+
status = case row.status
|
|
34
|
+
when :built then "built #{row.count}"
|
|
35
|
+
when :skipped then "skipped: #{row.reason}"
|
|
36
|
+
else "uncovered"
|
|
37
|
+
end
|
|
38
|
+
[ row.name || "-", row.path, status ]
|
|
39
|
+
end
|
|
40
|
+
widths = [ 0, 1 ].map { |i| lines.map { it[i].length }.max.to_i }
|
|
41
|
+
lines.map { |name, path, status| "#{name.ljust(widths[0])} #{path.ljust(widths[1])} #{status}".rstrip }.join("\n")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
attr_reader :dsl, :report
|
|
46
|
+
|
|
47
|
+
def analyze
|
|
48
|
+
return if @rows
|
|
49
|
+
|
|
50
|
+
@pages = []
|
|
51
|
+
@rows = []
|
|
52
|
+
seen = Set.new
|
|
53
|
+
check_declared_names
|
|
54
|
+
get_routes.each do |route|
|
|
55
|
+
status, detail = classify(route)
|
|
56
|
+
count = 0
|
|
57
|
+
if status == :built
|
|
58
|
+
pages_for(route, detail).each do |page|
|
|
59
|
+
next unless seen.add?(page.path)
|
|
60
|
+
@pages << page
|
|
61
|
+
count += 1
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
@rows << Row.new(name: route.name, path: route.path.spec.to_s, status:, count:, reason: status == :skipped ? detail : nil)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def get_routes
|
|
69
|
+
@get_routes ||= @routes.routes.select { it.verb.empty? || it.verb.split("|").include?("GET") }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Explicit declarations win over the defaults. For :built, detail is the param list source.
|
|
73
|
+
def classify(route)
|
|
74
|
+
name = route.name&.to_sym
|
|
75
|
+
if name && dsl.skips.key?(name)
|
|
76
|
+
[ :skipped, dsl.skips[name] || "skipped in config/kiln.rb" ]
|
|
77
|
+
elsif name && dsl.sources.key?(name)
|
|
78
|
+
[ :built, dsl.sources[name] ]
|
|
79
|
+
elsif route.internal || route.path.spec.to_s.start_with?("/rails/")
|
|
80
|
+
[ :skipped, "Rails internal route" ]
|
|
81
|
+
elsif (reason = default_skip(route))
|
|
82
|
+
[ :skipped, reason ]
|
|
83
|
+
elsif route.app.engine?
|
|
84
|
+
[ :skipped, "mounted engine" ]
|
|
85
|
+
elsif route.required_parts.empty?
|
|
86
|
+
[ :built, -> { [ {} ] } ]
|
|
87
|
+
else
|
|
88
|
+
report_uncovered(route)
|
|
89
|
+
[ :uncovered, nil ]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def default_skip(route)
|
|
94
|
+
controller, action = route.requirements.values_at(:controller, :action)
|
|
95
|
+
DEFAULT_SKIPS["#{controller}##{action}"] || DEFAULT_SKIPS[controller] if controller
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def pages_for(route, source)
|
|
99
|
+
list = source.call
|
|
100
|
+
unless list.is_a?(Enumerable) && !list.is_a?(Hash)
|
|
101
|
+
return source_error(route, "must return a list of hashes, got #{list.class}")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
list.flat_map do |params|
|
|
105
|
+
next source_error(route, "returned #{params.inspect}, expected a hash") unless params.is_a?(Hash)
|
|
106
|
+
|
|
107
|
+
params = params.symbolize_keys.compact
|
|
108
|
+
missing = route.required_parts - params.keys
|
|
109
|
+
next source_error(route, "returned #{params.inspect}, missing #{missing.map(&:inspect).join(", ")}") if missing.any?
|
|
110
|
+
|
|
111
|
+
[ Page.new(path: route.format(params.transform_values(&:to_param)), route_name: route.name, params:) ]
|
|
112
|
+
end
|
|
113
|
+
rescue StandardError => error
|
|
114
|
+
source_error(route, "raised #{error.class}: #{error.message}")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def source_error(route, message)
|
|
118
|
+
report.error(:other, "Param source for :#{route.name} #{message}")
|
|
119
|
+
[]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def report_uncovered(route)
|
|
123
|
+
spec = route.path.spec.to_s
|
|
124
|
+
message =
|
|
125
|
+
if route.name
|
|
126
|
+
keys = route.required_parts.map { "#{it}: ..." }.join(", ")
|
|
127
|
+
"GET #{spec} (:#{route.name}) needs params. Add `params :#{route.name}, -> { [ { #{keys} } ] }` " \
|
|
128
|
+
"or `skip :#{route.name}, reason: \"...\"` to config/kiln.rb."
|
|
129
|
+
else
|
|
130
|
+
"GET #{spec} needs params but has no name. Add `as:` to the route in config/routes.rb, " \
|
|
131
|
+
"then declare its params or skip it in config/kiln.rb."
|
|
132
|
+
end
|
|
133
|
+
report.error(:uncovered_route, message)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def check_declared_names
|
|
137
|
+
(dsl.declared_names - get_routes.filter_map { it.name&.to_sym }).each do |name|
|
|
138
|
+
report.error(:other, "config/kiln.rb declares :#{name}, but there is no GET route with that name")
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
data/lib/kiln/server.rb
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
require "rack/mime"
|
|
2
|
+
|
|
3
|
+
module Kiln
|
|
4
|
+
# Rack app that serves the built output the way a static host would.
|
|
5
|
+
class Server
|
|
6
|
+
def self.start(root, port:)
|
|
7
|
+
require "rackup"
|
|
8
|
+
Rackup::Handler.default.run(new(root), Host: "localhost", Port: port)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(root)
|
|
12
|
+
@root = Pathname(root)
|
|
13
|
+
@resolver = StaticResolver.new(@root)
|
|
14
|
+
@redirects = Redirects.load(@root.join(Redirects::FILENAME)).index_by(&:from)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(env)
|
|
18
|
+
path = env["PATH_INFO"].presence || "/"
|
|
19
|
+
|
|
20
|
+
if (redirect = @redirects[path])
|
|
21
|
+
[ redirect.status, { "location" => redirect.to }, [] ]
|
|
22
|
+
elsif (file = @resolver.resolve(path))
|
|
23
|
+
serve(200, file)
|
|
24
|
+
elsif (not_found = @resolver.resolve("/404.html"))
|
|
25
|
+
serve(404, not_found)
|
|
26
|
+
else
|
|
27
|
+
[ 404, { "content-type" => "text/plain" }, [ "Not found\n" ] ]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
def serve(status, file)
|
|
33
|
+
body = file.binread
|
|
34
|
+
type = Rack::Mime.mime_type(file.extname, "application/octet-stream")
|
|
35
|
+
[ status, { "content-type" => type, "content-length" => body.bytesize.to_s }, [ body ] ]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/kiln/sitemap.rb
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
require "erb"
|
|
2
|
+
|
|
3
|
+
module Kiln
|
|
4
|
+
# sitemap.xml with every indexable HTML page in the manifest.
|
|
5
|
+
class Sitemap
|
|
6
|
+
FILENAME = "sitemap.xml"
|
|
7
|
+
|
|
8
|
+
def self.noindex?(response, body)
|
|
9
|
+
return false unless response.html?
|
|
10
|
+
return true if response.headers["x-robots-tag"].to_s.downcase.include?("noindex")
|
|
11
|
+
return false unless body.match?(/noindex/i)
|
|
12
|
+
|
|
13
|
+
Nokogiri::HTML5(body).css("meta[name]").any? do |meta|
|
|
14
|
+
meta["name"].casecmp?("robots") && meta["content"].to_s.downcase.include?("noindex")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(manifest, url:)
|
|
19
|
+
@manifest = manifest
|
|
20
|
+
@url = url.chomp("/")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def write(output)
|
|
24
|
+
urls = @manifest.entries.filter_map do |path, entry|
|
|
25
|
+
next if entry[:noindex] || entry[:content_type] != "text/html"
|
|
26
|
+
|
|
27
|
+
" <url><loc>#{ERB::Util.html_escape(@url + path)}</loc></url>\n"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
File.write(Pathname(output).join(FILENAME), <<~XML)
|
|
31
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
32
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
33
|
+
#{urls.join}</urlset>
|
|
34
|
+
XML
|
|
35
|
+
FILENAME
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Kiln
|
|
2
|
+
# Maps a URL path to a file the way Cloudflare Pages and GitHub Pages do: exact file, then x.html,
|
|
3
|
+
# then x/index.html. A trailing slash only matches x/index.html. Never returns a file outside root.
|
|
4
|
+
class StaticResolver
|
|
5
|
+
def initialize(root)
|
|
6
|
+
@root = Pathname(root).expand_path
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def resolve(url_path)
|
|
10
|
+
path = Rack::Utils.unescape_path(url_path.to_s.split(/[?#]/, 2).first.to_s)
|
|
11
|
+
return if path.include?("\0")
|
|
12
|
+
|
|
13
|
+
segments = path.split("/").reject { it.empty? || it == "." }
|
|
14
|
+
return if segments.include?("..")
|
|
15
|
+
|
|
16
|
+
base = @root.join(*segments)
|
|
17
|
+
candidates = path.end_with?("/") || segments.empty? ? [ base.join("index.html") ] : [ base, Pathname("#{base}.html"), base.join("index.html") ]
|
|
18
|
+
candidates.find { it.file? && inside_root?(it) }
|
|
19
|
+
rescue ArgumentError, Encoding::CompatibilityError
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
def inside_root?(file)
|
|
25
|
+
file.realpath.to_s.start_with?("#{@root.realpath}/")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
require "kiln"
|
|
2
|
+
require "tmpdir"
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
module Kiln
|
|
6
|
+
# Minitest assertions for apps built with Kiln.
|
|
7
|
+
#
|
|
8
|
+
# require "kiln/test_helper"
|
|
9
|
+
#
|
|
10
|
+
# class StaticSiteTest < ActiveSupport::TestCase
|
|
11
|
+
# include Kiln::TestHelper
|
|
12
|
+
#
|
|
13
|
+
# test("builds the same output twice") { assert_repeatable_build }
|
|
14
|
+
# test("home page") { assert_page "/", selector: "h1", text: "Home" }
|
|
15
|
+
# end
|
|
16
|
+
module TestHelper
|
|
17
|
+
# Builds into a temp dir, fails with the report on any error, and yields the output path.
|
|
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")
|
|
21
|
+
build = Build.new(app:, config:, output: dir)
|
|
22
|
+
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|
|
|
35
|
+
kiln_build(app:, config:) do |second|
|
|
36
|
+
diff = TreeDiff.call(first, second)
|
|
37
|
+
assert diff.empty?, "Kiln output differs between two builds:\n#{diff}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Renders one path the way a build does (processors included) and asserts it succeeds.
|
|
43
|
+
# Optional `selector` must match, and contain `text` when given. Returns the processed body.
|
|
44
|
+
def assert_page(path, status: 200, selector: nil, text: nil, app: Rails.application, config: Kiln.config)
|
|
45
|
+
response = Kiln.building! { Renderer.new(app, host: config.host).get(path) }
|
|
46
|
+
assert_equal status, response.status, "Expected #{path} to respond with #{status}"
|
|
47
|
+
|
|
48
|
+
body = response.body
|
|
49
|
+
body = Processors.apply(body, Processors::Context.new(path:, config:, response:)) if response.html?
|
|
50
|
+
|
|
51
|
+
if selector
|
|
52
|
+
nodes = Nokogiri::HTML5(body).css(selector)
|
|
53
|
+
assert nodes.any?, "Expected #{path} to contain #{selector}"
|
|
54
|
+
assert nodes.any? { it.text.include?(text) }, "Expected #{selector} on #{path} to contain #{text.inspect}" if text
|
|
55
|
+
end
|
|
56
|
+
body
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Kiln
|
|
2
|
+
# Compares two directory trees byte for byte. Returns relative paths.
|
|
3
|
+
module TreeDiff
|
|
4
|
+
Result = Data.define(:added, :removed, :changed) do
|
|
5
|
+
def empty? = added.empty? && removed.empty? && changed.empty?
|
|
6
|
+
|
|
7
|
+
def to_s
|
|
8
|
+
[ added.map { "+ #{it}" }, removed.map { "- #{it}" }, changed.map { "~ #{it}" } ].flatten.join("\n")
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.call(before, after)
|
|
13
|
+
old_files = files(before)
|
|
14
|
+
new_files = files(after)
|
|
15
|
+
changed = (old_files & new_files).reject { FileUtils.identical?(File.join(before, it), File.join(after, it)) }
|
|
16
|
+
Result.new(added: new_files - old_files, removed: old_files - new_files, changed:)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.files(root)
|
|
20
|
+
return [] unless File.directory?(root)
|
|
21
|
+
|
|
22
|
+
Dir.glob("**/*", File::FNM_DOTMATCH, base: root).reject { File.directory?(File.join(root, it)) }.sort
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
module Kiln
|
|
2
|
+
# Checks the built output: internal links, anchors and importmap modules.
|
|
3
|
+
class Verifier
|
|
4
|
+
LINK_ATTRIBUTES = %w[href src srcset].freeze
|
|
5
|
+
|
|
6
|
+
def initialize(output, config:)
|
|
7
|
+
@output = Pathname(output)
|
|
8
|
+
@host = config.host.downcase
|
|
9
|
+
@resolver = StaticResolver.new(@output)
|
|
10
|
+
@redirect_sources = Redirects.load(@output.join(Redirects::FILENAME)).map(&:from).to_set
|
|
11
|
+
@anchors = {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def verify(report)
|
|
15
|
+
@errors = Set.new
|
|
16
|
+
Dir.glob("**/*.html", base: @output).sort.each { check_page(it) }
|
|
17
|
+
@errors.each { |kind, message, path| report.error(kind, message, path:) }
|
|
18
|
+
report
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
def check_page(file)
|
|
23
|
+
page = page_path(file)
|
|
24
|
+
doc = parse(@output.join(file))
|
|
25
|
+
|
|
26
|
+
doc.xpath("//*[@href or @src or @srcset]").each do |element|
|
|
27
|
+
urls_in(element).each { check_link(it, page) }
|
|
28
|
+
end
|
|
29
|
+
doc.css('script[type="importmap"]').each { check_importmap(it.text, page) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def urls_in(element)
|
|
33
|
+
LINK_ATTRIBUTES.flat_map do |name|
|
|
34
|
+
value = element[name]
|
|
35
|
+
next [] if value.nil?
|
|
36
|
+
|
|
37
|
+
name == "srcset" ? value.split(",").filter_map { it.split.first } : [ value ]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def check_link(url, page)
|
|
42
|
+
uri = internal_uri(url, page) or return
|
|
43
|
+
target = @resolver.resolve(uri.path)
|
|
44
|
+
|
|
45
|
+
if target.nil?
|
|
46
|
+
error(:broken_link, url, page) unless @redirect_sources.include?(uri.path)
|
|
47
|
+
elsif uri.fragment.present? && uri.fragment != "top" && target.extname == ".html"
|
|
48
|
+
fragment = Rack::Utils.unescape_path(uri.fragment)
|
|
49
|
+
error(:missing_anchor, url, page) unless anchors(target).include?(fragment)
|
|
50
|
+
end
|
|
51
|
+
rescue URI::Error
|
|
52
|
+
error(:broken_link, url, page)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def check_importmap(json, page)
|
|
56
|
+
map = JSON.parse(json)
|
|
57
|
+
urls = map.fetch("imports", {}).values + map.fetch("scopes", {}).values.flat_map(&:values)
|
|
58
|
+
urls.each do |url|
|
|
59
|
+
uri = internal_uri(url, page)
|
|
60
|
+
error(:importmap, url, page) if uri && @resolver.resolve(uri.path).nil?
|
|
61
|
+
rescue URI::Error
|
|
62
|
+
error(:importmap, url, page)
|
|
63
|
+
end
|
|
64
|
+
rescue JSON::ParserError, NoMethodError, TypeError
|
|
65
|
+
error(:importmap, "invalid importmap JSON", page)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The URL resolved against the page, or nil when it points somewhere Kiln doesn't serve.
|
|
69
|
+
def internal_uri(url, page)
|
|
70
|
+
url = url.strip
|
|
71
|
+
return if url.empty?
|
|
72
|
+
|
|
73
|
+
uri = URI.join("https://#{@host}#{page}", url)
|
|
74
|
+
uri if uri.is_a?(URI::HTTP) && uri.host.to_s.downcase == @host
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def anchors(file)
|
|
78
|
+
@anchors[file] ||= parse(file).xpath("//*[@id]/@id | //a[@name]/@name").to_set(&:value)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def parse(file)
|
|
82
|
+
Nokogiri::HTML5(file.read)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The URL a file is served at: index.html -> /, now/index.html -> /now/, components.html -> /components.
|
|
86
|
+
def page_path(file)
|
|
87
|
+
path = "/#{file}"
|
|
88
|
+
return path.delete_suffix("index.html") if File.basename(file) == "index.html"
|
|
89
|
+
|
|
90
|
+
path.delete_suffix(".html")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def error(kind, message, page)
|
|
94
|
+
@errors << [ kind, message, page ]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
data/lib/kiln/version.rb
ADDED
data/lib/kiln/writer.rb
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Kiln
|
|
4
|
+
# Maps request paths to files under the output directory and writes them.
|
|
5
|
+
class Writer
|
|
6
|
+
def initialize(output, mode:)
|
|
7
|
+
raise Kiln::Error, "Unknown paths mode #{mode.inspect} (expected :directory or :file)" unless %i[directory file].include?(mode)
|
|
8
|
+
|
|
9
|
+
@output = Pathname(output).expand_path
|
|
10
|
+
@mode = mode
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Relative file for a request path: "/now" -> "now/index.html" (or "now.html" in :file mode).
|
|
14
|
+
def file_for(path, content_type)
|
|
15
|
+
segments = segments_for(path)
|
|
16
|
+
content_type = content_type.to_s.split(";").first.to_s.strip.downcase
|
|
17
|
+
return "index.html" if segments.empty?
|
|
18
|
+
|
|
19
|
+
name = segments.last
|
|
20
|
+
if File.extname(name).empty?
|
|
21
|
+
if content_type == "text/html"
|
|
22
|
+
segments = @mode == :directory ? [ *segments, "index.html" ] : [ *segments[...-1], "#{name}.html" ]
|
|
23
|
+
elsif (extension = extension_for(content_type))
|
|
24
|
+
segments = [ *segments[...-1], "#{name}.#{extension}" ]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
segments.join("/")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def write(path, content_type, body)
|
|
31
|
+
write_file(file_for(path, content_type), body)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Writes a file at a path relative to the output directory.
|
|
35
|
+
def write_file(relative, body)
|
|
36
|
+
target = @output.join(relative).expand_path
|
|
37
|
+
unless target.to_s.start_with?("#{@output}/")
|
|
38
|
+
raise Kiln::Error, "Refusing to write #{relative} outside #{@output}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
FileUtils.mkdir_p(target.dirname)
|
|
42
|
+
File.binwrite(target, body)
|
|
43
|
+
relative.to_s
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
def extension_for(content_type)
|
|
48
|
+
symbol = Mime::Type.lookup(content_type).symbol if content_type.present?
|
|
49
|
+
symbol == :text ? "txt" : symbol
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def segments_for(path)
|
|
53
|
+
segments = path.to_s.split("?").first.to_s.split("/").reject(&:empty?).map do
|
|
54
|
+
Rack::Utils.unescape_path(it).force_encoding(Encoding::UTF_8)
|
|
55
|
+
end
|
|
56
|
+
if segments.any? { it == ".." || it == "." || it.include?("/") || it.include?("\0") }
|
|
57
|
+
raise Kiln::Error, "Invalid path #{path.inspect}"
|
|
58
|
+
end
|
|
59
|
+
segments
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/lib/kiln.rb
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
require "zeitwerk"
|
|
2
|
+
require "active_support"
|
|
3
|
+
require "active_support/core_ext"
|
|
4
|
+
|
|
5
|
+
loader = Zeitwerk::Loader.for_gem
|
|
6
|
+
loader.ignore("#{__dir__}/tasks")
|
|
7
|
+
loader.ignore("#{__dir__}/kiln/railtie.rb")
|
|
8
|
+
# Optional parts, loaded with an explicit require so eager loading never pulls in their gems.
|
|
9
|
+
loader.ignore("#{__dir__}/kiln/content.rb", "#{__dir__}/kiln/content")
|
|
10
|
+
loader.ignore("#{__dir__}/kiln/test_helper.rb")
|
|
11
|
+
loader.inflector.inflect("strip_csrf" => "StripCSRF")
|
|
12
|
+
loader.setup
|
|
13
|
+
|
|
14
|
+
module Kiln
|
|
15
|
+
class Error < StandardError; end
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def config
|
|
19
|
+
@config ||= Configuration.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def configure
|
|
23
|
+
yield config
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Kiln.pages { params :post, -> { ... }; skip :up }
|
|
27
|
+
def pages(&block)
|
|
28
|
+
@pages ||= Pages.new
|
|
29
|
+
@pages.instance_eval(&block) if block
|
|
30
|
+
@pages
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# True while a build is rendering pages. Views use it to hide dev-only things.
|
|
34
|
+
def building?
|
|
35
|
+
@building == true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def building!
|
|
39
|
+
previous = @building
|
|
40
|
+
@building = true
|
|
41
|
+
build_cache.clear
|
|
42
|
+
yield
|
|
43
|
+
ensure
|
|
44
|
+
@building = previous
|
|
45
|
+
build_cache.clear
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Memoization store that lives for one build. Cleared before and after every build.
|
|
49
|
+
def build_cache
|
|
50
|
+
@build_cache ||= BuildCache.new
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def reset!
|
|
54
|
+
@config = nil
|
|
55
|
+
@pages = nil
|
|
56
|
+
@building = nil
|
|
57
|
+
@build_cache = nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
require "kiln/railtie" if defined?(Rails::Railtie)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
namespace :kiln do
|
|
2
|
+
desc "Render every page into the output directory"
|
|
3
|
+
task build: :environment do
|
|
4
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
5
|
+
build = Kiln::Build.new
|
|
6
|
+
# One log line per request buries the report. Warnings and errors still show; VERBOSE=1 shows all.
|
|
7
|
+
report = ENV["VERBOSE"] ? build.run : Rails.logger.silence(Logger::WARN) { build.run }
|
|
8
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
9
|
+
|
|
10
|
+
puts report
|
|
11
|
+
if (changes = build.changes) && report.success?
|
|
12
|
+
summary = changes.map { |kind, paths| "#{paths.size} #{kind}" }.join(", ")
|
|
13
|
+
puts "Pages since last build: #{summary}"
|
|
14
|
+
changes.each { |kind, paths| paths.each { puts " #{kind}: #{it}" } }
|
|
15
|
+
end
|
|
16
|
+
puts format("Output: %s (%.1fs)", build.output, elapsed)
|
|
17
|
+
exit 1 if report.failure?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
desc "Fail if the committed output differs from a fresh build"
|
|
21
|
+
task check: :environment do
|
|
22
|
+
result = Kiln::Check.new.run
|
|
23
|
+
if result.report.failure?
|
|
24
|
+
puts result.report
|
|
25
|
+
elsif result.diff.empty?
|
|
26
|
+
puts "Kiln check: #{Kiln.config.output_path} is up to date."
|
|
27
|
+
else
|
|
28
|
+
puts "Kiln check: #{Kiln.config.output_path} is out of date. Run kiln:build and commit the result."
|
|
29
|
+
puts result.diff
|
|
30
|
+
end
|
|
31
|
+
exit 1 unless result.success?
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
namespace :kiln do
|
|
2
|
+
desc "List every GET route and whether Kiln builds, skips, or misses it"
|
|
3
|
+
task routes: :environment do
|
|
4
|
+
report = Kiln::Report.new
|
|
5
|
+
router = Kiln::Router.new(Rails.application.routes, Kiln.pages, report:)
|
|
6
|
+
puts router.table
|
|
7
|
+
if report.failure?
|
|
8
|
+
puts "", report
|
|
9
|
+
exit 1
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
namespace :kiln do
|
|
2
|
+
desc "Serve the built output like a static host (PORT, default 4000)"
|
|
3
|
+
task serve: :environment do
|
|
4
|
+
output = Kiln.config.output_path
|
|
5
|
+
abort "Kiln: #{output} does not exist. Run kiln:build first." unless output.directory?
|
|
6
|
+
|
|
7
|
+
port = Integer(ENV.fetch("PORT", 4000))
|
|
8
|
+
puts "Kiln: serving #{output} at http://localhost:#{port}"
|
|
9
|
+
Kiln::Server.start(output, port:)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
namespace :kiln do
|
|
2
|
+
desc "Check internal links, anchors and importmap modules in the built output"
|
|
3
|
+
task verify: :environment do
|
|
4
|
+
output = Kiln.config.output_path
|
|
5
|
+
abort "Kiln: #{output} does not exist. Run kiln:build first." unless output.directory?
|
|
6
|
+
|
|
7
|
+
report = Kiln::Verifier.new(output, config: Kiln.config).verify(Kiln::Report.new)
|
|
8
|
+
if report.success?
|
|
9
|
+
puts "Kiln verify: no problems found in #{output}."
|
|
10
|
+
else
|
|
11
|
+
puts report.to_s(title: "Kiln verify")
|
|
12
|
+
exit 1
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|