diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a7ef19b --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/content +lib +/public +/tags +/theme +tildy.yml +shard.lock +spec/public diff --git a/LICENSE b/LICENSE index 5e63615..5be3d88 100644 --- a/LICENSE +++ b/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: diff --git a/README.md b/README.md index 0cde17a..7ab4c29 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ -# tildy - +# Tildy + +Tildy is a nice small static blog generator. diff --git a/main.cr b/main.cr new file mode 100644 index 0000000..c623139 --- /dev/null +++ b/main.cr @@ -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 diff --git a/shard.yml b/shard.yml new file mode 100644 index 0000000..947e49d --- /dev/null +++ b/shard.yml @@ -0,0 +1,19 @@ +name: tildy +version: 0.1 + +authors: + - kyan + +targets: + tildy: + main: main.cr + +dependencies: + markd: + github: icyleaf/markd + crinja: + github: straight-shoota/crinja + +crystal: '>= 1.10.1' + +license: MIT diff --git a/spec/content/20200202.md b/spec/content/20200202.md new file mode 100644 index 0000000..32557af --- /dev/null +++ b/spec/content/20200202.md @@ -0,0 +1,10 @@ +--- +title: Test post +date: 2022-02-02 +tags: +- tag1 +- tag2 +--- +Post test content p1 + +Post test content p2 diff --git a/spec/content/20200203.md b/spec/content/20200203.md new file mode 100644 index 0000000..32557af --- /dev/null +++ b/spec/content/20200203.md @@ -0,0 +1,10 @@ +--- +title: Test post +date: 2022-02-02 +tags: +- tag1 +- tag2 +--- +Post test content p1 + +Post test content p2 diff --git a/spec/expected/index.html b/spec/expected/index.html new file mode 100644 index 0000000..a69e3d8 --- /dev/null +++ b/spec/expected/index.html @@ -0,0 +1,8 @@ +testing +

Test post

+

Post test content p1

+

Post test content p2

+ +

Test post

+

Post test content p1

+

Post test content p2

diff --git a/spec/expected/posts/20200202/index.html b/spec/expected/posts/20200202/index.html new file mode 100644 index 0000000..34d9d61 --- /dev/null +++ b/spec/expected/posts/20200202/index.html @@ -0,0 +1,3 @@ +

Test post

+

Post test content p1

+

Post test content p2

diff --git a/spec/expected/rss.xml b/spec/expected/rss.xml new file mode 100644 index 0000000..3dcb445 --- /dev/null +++ b/spec/expected/rss.xml @@ -0,0 +1,26 @@ + + + + + testing + https://test.dev + Test description + en_us + + https://test.dev/posts/20200203 + <p>Post test content p1</p> +<p>Post test content p2</p> + + Wed, 02 Feb 2022 00:00:00 GMT + https://test.dev/posts/20200203 + + + https://test.dev/posts/20200202 + <p>Post test content p1</p> +<p>Post test content p2</p> + + Wed, 02 Feb 2022 00:00:00 GMT + https://test.dev/posts/20200202 + + + diff --git a/spec/expected/tags/tag1/index.html b/spec/expected/tags/tag1/index.html new file mode 100644 index 0000000..0c3587e --- /dev/null +++ b/spec/expected/tags/tag1/index.html @@ -0,0 +1,11 @@ +testing + +

Test post

+

Post test content p1

+

Post test content p2

+ + +

Test post

+

Post test content p1

+

Post test content p2

+ diff --git a/spec/expected/tags/tag2/index.html b/spec/expected/tags/tag2/index.html new file mode 100644 index 0000000..0c3587e --- /dev/null +++ b/spec/expected/tags/tag2/index.html @@ -0,0 +1,11 @@ +testing + +

Test post

+

Post test content p1

+

Post test content p2

+ + +

Test post

+

Post test content p1

+

Post test content p2

+ diff --git a/spec/main_spec.cr b/spec/main_spec.cr new file mode 100644 index 0000000..ab47148 --- /dev/null +++ b/spec/main_spec.cr @@ -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 diff --git a/spec/spec.cr b/spec/spec.cr new file mode 100644 index 0000000..e67b2d5 --- /dev/null +++ b/spec/spec.cr @@ -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 diff --git a/spec/test_config.yml b/spec/test_config.yml new file mode 100644 index 0000000..ca71da0 --- /dev/null +++ b/spec/test_config.yml @@ -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 diff --git a/spec/theme/index.html b/spec/theme/index.html new file mode 100644 index 0000000..651441c --- /dev/null +++ b/spec/theme/index.html @@ -0,0 +1,3 @@ +{{ title }}{% for post in posts %} +

{{ post.title }}

+{{ post.content }}{% endfor %} diff --git a/spec/theme/post.html b/spec/theme/post.html new file mode 100644 index 0000000..1fea73d --- /dev/null +++ b/spec/theme/post.html @@ -0,0 +1,2 @@ +

{{ title }}

+{{ content }} diff --git a/spec/theme/tag.html b/spec/theme/tag.html new file mode 100644 index 0000000..6cfe4bf --- /dev/null +++ b/spec/theme/tag.html @@ -0,0 +1,5 @@ +{{ title }} +{% for post in posts %} +

{{ post.title }}

+{{ post.content }} +{% endfor %}