Implement first version (#1)

This is the first version that works to some extent.

Reviewed-on: #1
This commit is contained in:
kyan 2023-11-25 15:15:44 +00:00
parent 094d4f7db1
commit b68d72f5c6
18 changed files with 546 additions and 3 deletions

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
/content
lib
/public
/tags
/theme
tildy.yml
shard.lock
spec/public

View File

@ -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:

View File

@ -1,2 +1,3 @@
# tildy
# Tildy
Tildy is a nice small static blog generator.

371
main.cr Normal file
View File

@ -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

19
shard.yml Normal file
View File

@ -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

10
spec/content/20200202.md Normal file
View File

@ -0,0 +1,10 @@
---
title: Test post
date: 2022-02-02
tags:
- tag1
- tag2
---
Post test content p1
Post test content p2

10
spec/content/20200203.md Normal file
View File

@ -0,0 +1,10 @@
---
title: Test post
date: 2022-02-02
tags:
- tag1
- tag2
---
Post test content p1
Post test content p2

8
spec/expected/index.html Normal file
View File

@ -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>

View File

@ -0,0 +1,3 @@
<h2>Test post</h2>
<p>Post test content p1</p>
<p>Post test content p2</p>

26
spec/expected/rss.xml Normal file
View File

@ -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>&lt;p&gt;Post test content p1&lt;/p&gt;
&lt;p&gt;Post test content p2&lt;/p&gt;
</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>&lt;p&gt;Post test content p1&lt;/p&gt;
&lt;p&gt;Post test content p2&lt;/p&gt;
</description>
<pubDate>Wed, 02 Feb 2022 00:00:00 GMT</pubDate>
<guid>https://test.dev/posts/20200202</guid>
</item>
</channel>
</rss>

View File

@ -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>

View File

@ -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>

30
spec/main_spec.cr Normal file
View File

@ -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

16
spec/spec.cr Normal file
View File

@ -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

9
spec/test_config.yml Normal file
View File

@ -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

3
spec/theme/index.html Normal file
View File

@ -0,0 +1,3 @@
<title>{{ title }}</title>{% for post in posts %}
<h2>{{ post.title }}</h2>
{{ post.content }}{% endfor %}

2
spec/theme/post.html Normal file
View File

@ -0,0 +1,2 @@
<h2>{{ title }}</h2>
{{ content }}

5
spec/theme/tag.html Normal file
View File

@ -0,0 +1,5 @@
<title>{{ title }}</title>
{% for post in posts %}
<h2>{{ post.title }}</h2>
{{ post.content }}
{% endfor %}