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
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
@ -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
|
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:
|
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:
|
||||||
|
|
||||||
|
|
371
main.cr
Normal file
371
main.cr
Normal 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
19
shard.yml
Normal 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
10
spec/content/20200202.md
Normal 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
10
spec/content/20200203.md
Normal 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
8
spec/expected/index.html
Normal 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>
|
3
spec/expected/posts/20200202/index.html
Normal file
3
spec/expected/posts/20200202/index.html
Normal 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
26
spec/expected/rss.xml
Normal 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><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>
|
11
spec/expected/tags/tag1/index.html
Normal file
11
spec/expected/tags/tag1/index.html
Normal 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>
|
||||||
|
|
11
spec/expected/tags/tag2/index.html
Normal file
11
spec/expected/tags/tag2/index.html
Normal 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
30
spec/main_spec.cr
Normal 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
16
spec/spec.cr
Normal 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
9
spec/test_config.yml
Normal 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
3
spec/theme/index.html
Normal 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
2
spec/theme/post.html
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<h2>{{ title }}</h2>
|
||||||
|
{{ content }}
|
5
spec/theme/tag.html
Normal file
5
spec/theme/tag.html
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<title>{{ title }}</title>
|
||||||
|
{% for post in posts %}
|
||||||
|
<h2>{{ post.title }}</h2>
|
||||||
|
{{ post.content }}
|
||||||
|
{% endfor %}
|
Loading…
Reference in New Issue
Block a user