tildy/main.cr

368 lines
8.1 KiB
Crystal

require "crinja"
require "file_utils"
require "http/server"
require "markd"
require "mime"
require "option_parser"
require "yaml"
require "xml"
class Config
def self.new(path : String)
self.new(YAML.parse(File.read(path)))
end
def initialize(@config : YAML::Any)
end
def address : String
@config["address"].as_s
end
def content_dir : String
@config["content_dir"].as_s
end
def description : String
@config["description"].as_s
end
def formatted_date : String
@config["formatted_date"].as_s
end
def html_dir : String
@config["html_dir"].as_s
end
def language : String
@config["language"].as_s
end
def theme_dir : String
@config["theme_dir"].as_s
end
def title : String
@config["title"].as_s
end
end
class Post
def initialize(@file : String)
end
def content : String
Markd.to_html(File.read(@file).split("---")[2])
end
def date : String
frontmatter["date"].as_time.to_utc.to_s("%a, %d %b %Y %H:%M:%S GMT")
end
def slug : String
File.basename(@file, ".md")
end
def tags : Array(String)
frontmatter["tags"].as_a.map(&.as_s)
end
def title : String
if frontmatter["title"]?
frontmatter["title"].as_s
else
slug
end
end
def to_hash
{
"title" => title,
"date" => date,
"tags" => tags,
"content" => content,
"slug" => slug,
}
end
private def frontmatter
YAML.parse(File.read(@file).split("---")[1])
end
end
class PostRepository
def initialize(@config : Config)
end
def all : Array(Post)
posts = Dir.glob("#{@config.content_dir}/*.md").sort.map do |file|
Post.new(file)
end
posts.reverse
end
end
abstract class Medium
abstract def compile : Void
end
class Blog < Medium
def initialize(@config : Config, @posts : Array(Post))
end
def compile : Void
save_index
save_posts
save_tags
end
private def params
{
"title" => @config.title,
}
end
private def render_html(template : String, params) : String
crinja = Crinja.new
crinja.filters << FormattedDate.new(@config.formatted_date)
template = crinja.from_string(File.read("#{@config.theme_dir}/#{template}.html"))
template.render(params)
end
private def render_post(post : Post) : String
render_html("post", params.merge(post.to_hash))
end
private def render_template(template : String, posts : Array(Post)) : String
render_html(
template,
params.merge({"posts" => posts.map(&.to_hash)})
)
end
private def save_html(dest : String, content : String) : Void
FileUtils.mkdir_p(dest)
File.write("#{dest}/index.html", content)
end
private def save_index : Void
save_html(
"#{@config.html_dir}",
render_template("index", @posts)
)
end
private def save_posts : Void
@posts.each do |post|
save_html(
"#{@config.html_dir}/posts/#{post.slug}",
render_post(post)
)
end
end
private def save_tags : Void
tags = Hash(String, Array(Post)).new
@posts.each do |post|
post.tags.each do |tag|
tags[tag] = [] of Post unless tags.has_key?(tag)
tags[tag] << post
end
end
tags.each do |tag, posts|
tag_dir = "#{@config.html_dir}/tags/#{tag}"
save_html(
tag_dir,
render_html("tag", params.merge({"title" => tag, "posts" => posts.map(&.to_hash)}))
)
end
end
end
class RSSFeed < Medium
def initialize(@config : Config, @posts : Array(Post))
end
def compile : Void
save_file(
XML.build(indent: " ") do |xml|
xml.element("rss", version: 2.0, "xmlns:atom": "http://www.w3.org/2005/Atom") do
xml.element("channel") do
xml.element("atom:link", href: "#{@config.address}/rss.xml", rel: "self", type: "application/rss+xml")
xml.element("title") { xml.text @config.title }
xml.element("link") { xml.text @config.address }
xml.element("description") { xml.text @config.description }
xml.element("language") { xml.text @config.language }
@posts.each do |post|
xml.element("item") do
xml.element("link") { xml.text "#{@config.address}/posts/#{post.slug}" }
xml.element("description") { xml.text post.content }
xml.element("pubDate") { xml.text post.date }
xml.element("guid") { xml.text "#{@config.address}/posts/#{post.slug}" }
end
end
end
end
end
)
end
private def save_file(content : String) : Void
FileUtils.mkdir_p(@config.html_dir)
File.write("#{@config.html_dir}/rss.xml", content)
end
end
class FormattedDate
include Crinja::Callable
getter name = "formatted_date"
def initialize(@format : String)
end
def call(arguments)
date = Time.parse_utc(arguments.target.to_s, "%a, %d %b %Y %H:%M:%S GMT")
date.to_s(@format)
end
end
class Site < Medium
def initialize(@media : Array(Medium))
end
def compile : Void
@media.each do |m|
m.compile
end
end
end
abstract class Output
abstract def with(key : String, value : String) : Output
abstract def with(key : String, value : Hash(String, String)) : Output
abstract def write : Void
end
class Response
def initialize(@content_type : String, @content : String)
end
def print(output : Output) : Output
output
.with("content", @content)
.with("content_type", @content_type)
end
end
abstract class Resource
abstract def response(context : HTTP::Server::Context) : Response
end
class Server
def initialize(@resource : Resource)
end
def run
server = HTTP::Server.new do |context|
@resource
.response(context)
.print(ServerOutput.new(context))
.write
end
address = server.bind_tcp "0.0.0.0", 8000
puts "Listening on http://#{address}"
server.listen
end
end
class ServerOutput < Output
def self.new(context : HTTP::Server::Context)
self.new(context, 200, "text/html", {} of String => String, "")
end
def initialize(
@context : HTTP::Server::Context,
@status : Int32,
@content_type : String,
@headers : Hash(String, String),
@content : String
)
end
def with(key : String, value : String | Hash(String, String)) : Output
with_value(key, value)
end
def write : Void
@context.response.status = HTTP::Status.new(@status)
@context.response.content_type = @content_type
@headers.each do |key, value|
@context.response.headers[key] = value
end
@context.response.print @content
end
private def status : Int32
@status
end
private def with_value(key : String, value : String) : Output
status = @status
content_type = @content_type
content = @content
case key
when "status"
status = value.to_i32
when "content_type"
content_type = value
when "content"
content = value
end
ServerOutput.new(@context, status, content_type, @headers, content)
end
private def with_value(key : String, value : Hash(String, String)) : Output
key == "headers" ? Output.new(@context, @status, @content_type, value, @content) : self
end
end
class StaticDirectory < Resource
def initialize(@directory : String, @site : Site)
end
def response(context : HTTP::Server::Context) : Response
requested_file = "#{@directory}#{context.request.path}"
if File.directory?(requested_file)
@site.compile
return Response.new("text/html", File.read("#{requested_file}/index.html"))
end
Response.new(MIME.from_filename(requested_file), File.read(requested_file))
end
end
config = Config.new("./tildy.yml")
posts = PostRepository.new(config).all
site = Site.new(
[
Blog.new(config, posts),
RSSFeed.new(config, posts),
]
)
OptionParser.parse do |parser|
parser.banner = "Tildy ~ nice static blog generator"
parser.on "-b", "--build", "Build blog" do
site.compile
exit
end
parser.on "-d", "--development", "Run development server" do
Server.new(StaticDirectory.new(config.html_dir, site)).run
exit
end
parser.on "-h", "--help", "Show help" do
puts parser
exit
end
end