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,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
@@ -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
@@ -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
@@ -0,0 +1,3 @@
1
+ module Kiln
2
+ VERSION = "0.1.0"
3
+ end
@@ -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