368 lines
8.1 KiB
Crystal
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
|