Implement first version (#1)
This is the first version that works to some extent. Reviewed-on: #1
This commit is contained in:
parent
094d4f7db1
commit
b68d72f5c6
|
@ -0,0 +1,8 @@
|
|||
/content
|
||||
lib
|
||||
/public
|
||||
/tags
|
||||
/theme
|
||||
tildy.yml
|
||||
shard.lock
|
||||
spec/public
|
2
LICENSE
2
LICENSE
|
@ -1,6 +1,6 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2023 kyan
|
||||
Copyright (c) 2023 kyan [kyan@ctrl-c.club]
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
|
|
|
@ -0,0 +1,371 @@
|
|||
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 title : String
|
||||
if frontmatter["title"]?
|
||||
frontmatter["title"].as_s
|
||||
else
|
||||
slug
|
||||
end
|
||||
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 content : String
|
||||
Markd.to_html(File.read(@file).split("---")[2])
|
||||
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").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 save_index : Void
|
||||
save_html(
|
||||
"#{@config.html_dir}",
|
||||
render_template("index")
|
||||
)
|
||||
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_template("tag", posts)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private def render_template(template : String) : String
|
||||
render_template(template, @posts)
|
||||
end
|
||||
|
||||
private def render_template(template : String, posts : Array(Post)) : String
|
||||
render_html(
|
||||
template,
|
||||
params.merge({"posts" => posts.map(&.to_hash)})
|
||||
)
|
||||
end
|
||||
|
||||
private def render_post(post : Post) : String
|
||||
render_html("post", params.merge(post.to_hash))
|
||||
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 save_html(dest : String, content : String) : Void
|
||||
FileUtils.mkdir_p(dest)
|
||||
File.write("#{dest}/index.html", content)
|
||||
end
|
||||
|
||||
private def params
|
||||
{
|
||||
"title" => @config.title,
|
||||
}
|
||||
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 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
|
||||
|
||||
private def status : Int32
|
||||
@status
|
||||
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
|
|
@ -0,0 +1,19 @@
|
|||
name: tildy
|
||||
version: 0.1
|
||||
|
||||
authors:
|
||||
- kyan <kyan@ctrl-c.club>
|
||||
|
||||
targets:
|
||||
tildy:
|
||||
main: main.cr
|
||||
|
||||
dependencies:
|
||||
markd:
|
||||
github: icyleaf/markd
|
||||
crinja:
|
||||
github: straight-shoota/crinja
|
||||
|
||||
crystal: '>= 1.10.1'
|
||||
|
||||
license: MIT
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
title: Test post
|
||||
date: 2022-02-02
|
||||
tags:
|
||||
- tag1
|
||||
- tag2
|
||||
---
|
||||
Post test content p1
|
||||
|
||||
Post test content p2
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
title: Test post
|
||||
date: 2022-02-02
|
||||
tags:
|
||||
- tag1
|
||||
- tag2
|
||||
---
|
||||
Post test content p1
|
||||
|
||||
Post test content p2
|
|
@ -0,0 +1,8 @@
|
|||
<title>testing</title>
|
||||
<h2>Test post</h2>
|
||||
<p>Post test content p1</p>
|
||||
<p>Post test content p2</p>
|
||||
|
||||
<h2>Test post</h2>
|
||||
<p>Post test content p1</p>
|
||||
<p>Post test content p2</p>
|
|
@ -0,0 +1,3 @@
|
|||
<h2>Test post</h2>
|
||||
<p>Post test content p1</p>
|
||||
<p>Post test content p2</p>
|
|
@ -0,0 +1,26 @@
|
|||
<?xml version="1.0"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<atom:link href="https://test.dev/rss.xml" rel="self" type="application/rss+xml"/>
|
||||
<title>testing</title>
|
||||
<link>https://test.dev</link>
|
||||
<description>Test description</description>
|
||||
<language>en_us</language>
|
||||
<item>
|
||||
<link>https://test.dev/posts/20200203</link>
|
||||
<description><p>Post test content p1</p>
|
||||
<p>Post test content p2</p>
|
||||
</description>
|
||||
<pubDate>Wed, 02 Feb 2022 00:00:00 GMT</pubDate>
|
||||
<guid>https://test.dev/posts/20200203</guid>
|
||||
</item>
|
||||
<item>
|
||||
<link>https://test.dev/posts/20200202</link>
|
||||
<description><p>Post test content p1</p>
|
||||
<p>Post test content p2</p>
|
||||
</description>
|
||||
<pubDate>Wed, 02 Feb 2022 00:00:00 GMT</pubDate>
|
||||
<guid>https://test.dev/posts/20200202</guid>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
|
@ -0,0 +1,11 @@
|
|||
<title>testing</title>
|
||||
|
||||
<h2>Test post</h2>
|
||||
<p>Post test content p1</p>
|
||||
<p>Post test content p2</p>
|
||||
|
||||
|
||||
<h2>Test post</h2>
|
||||
<p>Post test content p1</p>
|
||||
<p>Post test content p2</p>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<title>testing</title>
|
||||
|
||||
<h2>Test post</h2>
|
||||
<p>Post test content p1</p>
|
||||
<p>Post test content p2</p>
|
||||
|
||||
|
||||
<h2>Test post</h2>
|
||||
<p>Post test content p1</p>
|
||||
<p>Post test content p2</p>
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
require "spec"
|
||||
require "../main"
|
||||
|
||||
describe Blog do
|
||||
it "generates correct content to index" do
|
||||
Blog.new(
|
||||
Config.new("./spec/test_config.yml"),
|
||||
PostRepository.new(
|
||||
Config.new("./spec/test_config.yml")
|
||||
).all
|
||||
).compile
|
||||
File.read("./spec/public/index.html").should eq File.read("./spec/expected/index.html")
|
||||
File.read("./spec/public/posts/20200202/index.html").should eq File.read("./spec/expected/posts/20200202/index.html")
|
||||
File.read("./spec/public/tags/tag1/index.html").should eq File.read("./spec/expected/tags/tag1/index.html")
|
||||
File.read("./spec/public/tags/tag2/index.html").should eq File.read("./spec/expected/tags/tag2/index.html")
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
describe RSSFeed do
|
||||
it "generates correct content for rss" do
|
||||
RSSFeed.new(
|
||||
Config.new("./spec/test_config.yml"),
|
||||
PostRepository.new(
|
||||
Config.new("./spec/test_config.yml")
|
||||
).all
|
||||
).compile
|
||||
File.read("./spec/public/rss.xml").should eq File.read("./spec/expected/rss.xml")
|
||||
end
|
||||
end
|
|
@ -0,0 +1,16 @@
|
|||
require "spec"
|
||||
require "../main"
|
||||
|
||||
describe Blog do
|
||||
it "generates correct content to index" do
|
||||
Blog.new(
|
||||
Config.new("./spec/test_config.yml"),
|
||||
PostRepository.new(
|
||||
Config.new("./spec/test_config.yml")
|
||||
)).compie
|
||||
File.read("./spec/public/index.html").should eq File.read("./spec/expected/index.html")
|
||||
File.read("./spec/public/posts/20200202/index.html").should eq File.read("./spec/expected/posts/20200202/index.html")
|
||||
File.read("./spec/public/tags/tag1/index.html").should eq File.read("./spec/expected/tags/tag1/index.html")
|
||||
File.read("./spec/public/tags/tag2/index.html").should eq File.read("./spec/expected/tags/tag2/index.html")
|
||||
end
|
||||
end
|
|
@ -0,0 +1,9 @@
|
|||
address: "https://test.dev"
|
||||
content_dir: "./spec/content"
|
||||
description: "Test description"
|
||||
footer: testing footer
|
||||
formatted_date: "%B %d, %Y"
|
||||
html_dir: "./spec/public"
|
||||
language: "en_us"
|
||||
theme_dir: "./spec/theme"
|
||||
title: testing
|
|
@ -0,0 +1,3 @@
|
|||
<title>{{ title }}</title>{% for post in posts %}
|
||||
<h2>{{ post.title }}</h2>
|
||||
{{ post.content }}{% endfor %}
|
|
@ -0,0 +1,2 @@
|
|||
<h2>{{ title }}</h2>
|
||||
{{ content }}
|
|
@ -0,0 +1,5 @@
|
|||
<title>{{ title }}</title>
|
||||
{% for post in posts %}
|
||||
<h2>{{ post.title }}</h2>
|
||||
{{ post.content }}
|
||||
{% endfor %}
|
Loading…
Reference in New Issue