refactor build and asset pipeline
This commit is contained in:
parent
764290698f
commit
8bc094afb8
17 changed files with 238 additions and 148 deletions
|
@ -27,6 +27,5 @@ defmodule SloaneSH do
|
||||||
|
|
||||||
def context do
|
def context do
|
||||||
Context.new()
|
Context.new()
|
||||||
|> Context.init()
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
26
lib/sloane_sh/asset.ex
Normal file
26
lib/sloane_sh/asset.ex
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
defmodule SloaneSH.Asset do
|
||||||
|
use TypedStruct
|
||||||
|
alias SloaneSH.Config
|
||||||
|
alias SloaneSH.Context
|
||||||
|
|
||||||
|
typedstruct do
|
||||||
|
field :mod, module(), enforce: true
|
||||||
|
field :src, String.t(), enforce: true
|
||||||
|
field :src_contents, binary(), enforce: true
|
||||||
|
field :attrs, map(), enforce: true
|
||||||
|
end
|
||||||
|
|
||||||
|
@callback extensions(cfg :: Config.t()) :: [String.t()]
|
||||||
|
|
||||||
|
@callback attrs(cfg :: Config.t(), path :: String.t(), data :: binary()) ::
|
||||||
|
{:ok, map(), without_attrs :: binary()} | {:ok, map()} | :error | {:error, term()}
|
||||||
|
|
||||||
|
@callback render(
|
||||||
|
cfg :: Config.t(),
|
||||||
|
ctx :: Context.t(),
|
||||||
|
path :: String.t(),
|
||||||
|
data :: binary(),
|
||||||
|
attrs :: map()
|
||||||
|
) ::
|
||||||
|
{:ok, [{dest :: String.t(), binary()}]} | :error | {:error, term()}
|
||||||
|
end
|
0
lib/sloane_sh/assets/image.ex
Normal file
0
lib/sloane_sh/assets/image.ex
Normal file
78
lib/sloane_sh/assets/markdown.ex
Normal file
78
lib/sloane_sh/assets/markdown.ex
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
defmodule SloaneSH.Assets.Markdown do
|
||||||
|
@moduledoc """
|
||||||
|
Helper to define markdown, html, and eex templating for pages and posts
|
||||||
|
"""
|
||||||
|
defmacro __using__(opts) do
|
||||||
|
type = Keyword.fetch!(opts, :type)
|
||||||
|
|
||||||
|
quote do
|
||||||
|
alias SloaneSH.Asset
|
||||||
|
alias SloaneSH.FrontMatter
|
||||||
|
alias SloaneSH.Layouts
|
||||||
|
alias SloaneSH.OutputDirs
|
||||||
|
|
||||||
|
@behaviour Asset
|
||||||
|
|
||||||
|
@impl Asset
|
||||||
|
def extensions(_cfg), do: ~w[.md .html .md.eex .html.eex]
|
||||||
|
|
||||||
|
@impl Asset
|
||||||
|
def attrs(cfg, path, data) do
|
||||||
|
{:ok, attrs, without_attrs} = FrontMatter.parse(data)
|
||||||
|
|
||||||
|
attrs =
|
||||||
|
Map.put_new_lazy(attrs, :permalink, fn ->
|
||||||
|
output = apply(OutputDirs, unquote(type), [cfg, path])
|
||||||
|
permalink = OutputDirs.to_permalink(cfg, output)
|
||||||
|
end)
|
||||||
|
|
||||||
|
attrs = handle_attrs(cfg, path, without_attrs, attrs)
|
||||||
|
|
||||||
|
{:ok, attrs, without_attrs}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl Asset
|
||||||
|
def render(cfg, ctx, path, data, attrs) do
|
||||||
|
output_path =
|
||||||
|
if attrs[:permalink] do
|
||||||
|
OutputDirs.from_permalink(cfg, attrs[:permalink])
|
||||||
|
else
|
||||||
|
apply(OutputDirs, unquote(type), [cfg, path])
|
||||||
|
end
|
||||||
|
|
||||||
|
output = {output_path, do_render(ctx, path, data, attrs)}
|
||||||
|
{:ok, [output]}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_render(ctx, path, data, attrs) when is_binary(path) do
|
||||||
|
do_render(ctx, base_and_ext(path), data, attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_render(ctx, {path, ".eex"}, data, attrs) do
|
||||||
|
eexed = EEx.eval_string(data, ctx: ctx, attrs: attrs)
|
||||||
|
do_render(ctx, base_and_ext(path), eexed, attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_render(ctx, {path, ".md"}, data, attrs) do
|
||||||
|
html = Earmark.as_html!(data)
|
||||||
|
do_render(ctx, {path, ".html"}, html, attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_render(ctx, {_path, ".html"}, data, attrs) do
|
||||||
|
apply(Layouts, unquote(type), [data, ctx, attrs])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp base_and_ext(path) do
|
||||||
|
ext = Path.extname(path)
|
||||||
|
base = Path.basename(path, ext)
|
||||||
|
{base, ext}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_attrs(cfg, path, data, attrs) do
|
||||||
|
attrs
|
||||||
|
end
|
||||||
|
|
||||||
|
defoverridable handle_attrs: 4
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
3
lib/sloane_sh/assets/page.ex
Normal file
3
lib/sloane_sh/assets/page.ex
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
defmodule SloaneSH.Assets.Page do
|
||||||
|
use SloaneSH.Assets.Markdown, type: :page
|
||||||
|
end
|
12
lib/sloane_sh/assets/post.ex
Normal file
12
lib/sloane_sh/assets/post.ex
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
defmodule SloaneSH.Assets.Post do
|
||||||
|
use SloaneSH.Assets.Markdown, type: :post
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
def handle_attrs(_cfg, path, _data, attrs) do
|
||||||
|
unless Map.has_key?(attrs, :date) do
|
||||||
|
Logger.warning("Post missing date property: #{inspect(path)}")
|
||||||
|
end
|
||||||
|
|
||||||
|
attrs
|
||||||
|
end
|
||||||
|
end
|
|
@ -2,60 +2,28 @@ defmodule SloaneSH.Build do
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
alias SloaneSH.Context
|
alias SloaneSH.Context
|
||||||
alias SloaneSH.Layouts
|
|
||||||
alias SloaneSH.Markdown
|
|
||||||
alias SloaneSH.Write
|
|
||||||
|
|
||||||
def run(%Context{} = ctx) do
|
def run(%Context{} = ctx) do
|
||||||
ctx
|
assets = ctx.posts ++ ctx.pages
|
||||||
|> build_pages()
|
|
||||||
|> build_posts()
|
|
||||||
|> copy_img()
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_pages(%Context{} = ctx) do
|
File.mkdir_p!(ctx.config.output_dir)
|
||||||
Logger.info("Building pages...")
|
|
||||||
for page <- ctx.pages, do: build_page(ctx, page)
|
|
||||||
|
|
||||||
ctx
|
for asset <- assets do
|
||||||
end
|
case asset.mod.render(ctx.config, ctx, asset.src, asset.src_contents, asset.attrs) do
|
||||||
|
{:ok, output_files} ->
|
||||||
def build_posts(%Context{} = ctx) do
|
for {dest, content} <- output_files do
|
||||||
Logger.info("Building posts...")
|
with :ok <- dest |> Path.dirname() |> File.mkdir_p(),
|
||||||
for post <- ctx.posts, do: build_post(ctx, post)
|
:ok <- File.write(dest, content) do
|
||||||
|
Logger.info("Wrote #{inspect(dest)}.")
|
||||||
ctx
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_page(%Context{} = ctx, page) do
|
|
||||||
path = Path.join(ctx.config.pages_dir, page)
|
|
||||||
|
|
||||||
with {:ok, data} <- File.read(path),
|
|
||||||
{:ok, md} <- Markdown.transform(ctx, data),
|
|
||||||
contents = Layouts.page_layout(ctx, md.attrs, md.html),
|
|
||||||
html = Layouts.root_layout(ctx, md.attrs, contents),
|
|
||||||
:ok <- Write.page(ctx, page, html) do
|
|
||||||
Logger.info("Built page: #{page}")
|
|
||||||
else
|
else
|
||||||
err -> Logger.error("Failed to build page #{page}: #{inspect(err)}")
|
{:error, err} ->
|
||||||
|
Logger.error("Failed to write #{inspect(dest)}, #{inspect(err)}")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_post(%Context{} = ctx, post) do
|
err ->
|
||||||
path = Path.join(ctx.config.posts_dir, post)
|
Logger.error("Failed to render #{inspect(asset.src)}, #{inspect(err)}")
|
||||||
|
|
||||||
with {:ok, data} <- File.read(path),
|
|
||||||
{:ok, md} <- Markdown.transform(ctx, data),
|
|
||||||
contents = Layouts.post_layout(ctx, md.attrs, md.html),
|
|
||||||
html = Layouts.root_layout(ctx, md.attrs, contents),
|
|
||||||
:ok <- Write.post(ctx, post, html) do
|
|
||||||
Logger.info("Built post: #{post}")
|
|
||||||
else
|
|
||||||
err -> Logger.error("Failed to build post #{post}: #{inspect(err)}")
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def copy_img(%Context{} = ctx) do
|
|
||||||
ctx
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -22,11 +22,6 @@ defmodule SloaneSH.Config do
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def in_config?(%Config{} = cfg, path) do
|
|
||||||
Enum.any?([cfg.pages_dir, cfg.posts_dir], &String.starts_with?(path, &1)) and
|
|
||||||
Path.extname(path) == ".md"
|
|
||||||
end
|
|
||||||
|
|
||||||
defp resolve_link(path) do
|
defp resolve_link(path) do
|
||||||
case File.read_link(path) do
|
case File.read_link(path) do
|
||||||
{:ok, link} ->
|
{:ok, link} ->
|
||||||
|
|
|
@ -4,64 +4,53 @@ defmodule SloaneSH.Context do
|
||||||
files.
|
files.
|
||||||
"""
|
"""
|
||||||
use TypedStruct
|
use TypedStruct
|
||||||
|
require Logger
|
||||||
|
|
||||||
alias SloaneSH.Config
|
alias SloaneSH.Config
|
||||||
|
alias SloaneSH.Asset
|
||||||
|
alias SloaneSH.Assets.Page
|
||||||
|
alias SloaneSH.Assets.Post
|
||||||
alias __MODULE__
|
alias __MODULE__
|
||||||
|
|
||||||
typedstruct do
|
typedstruct do
|
||||||
field :config, Config.t(), enforce: true
|
field :config, Config.t(), enforce: true
|
||||||
field :pages, [String.t()], default: []
|
field :pages, [Asset.t()], default: []
|
||||||
field :posts, [String.t()], default: []
|
field :posts, [Asset.t()], default: []
|
||||||
end
|
end
|
||||||
|
|
||||||
def new(config \\ Config.default()) do
|
def new(cfg \\ Config.default()) do
|
||||||
%Context{config: config}
|
pages = load_assets(cfg, Page, cfg.pages_dir)
|
||||||
|
posts = load_assets(cfg, Post, cfg.posts_dir)
|
||||||
|
|
||||||
|
%Context{config: cfg, pages: pages, posts: posts}
|
||||||
end
|
end
|
||||||
|
|
||||||
def init(%Context{config: config} = context) do
|
defp load_assets(cfg, mod, src_dir) do
|
||||||
with {:ok, pages_contents} <- File.ls(config.pages_dir),
|
exts = mod.extensions(cfg)
|
||||||
{:ok, posts_contents} <- File.ls(config.posts_dir) do
|
|
||||||
pages = Enum.filter(pages_contents, &String.match?(&1, ~r/.*\.md$/))
|
for src <- collect_src_files(src_dir, exts) do
|
||||||
posts = Enum.filter(posts_contents, &String.match?(&1, ~r/.*\.md$/))
|
contents = File.read!(src)
|
||||||
%Context{context | pages: pages, posts: posts}
|
|
||||||
|
case mod.attrs(cfg, src, contents) do
|
||||||
|
{:ok, attrs, src_contents} ->
|
||||||
|
%Asset{mod: mod, src: src, src_contents: src_contents, attrs: attrs}
|
||||||
|
|
||||||
|
{:ok, attrs} ->
|
||||||
|
%Asset{mod: mod, src: src, src_contents: contents, attrs: attrs}
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
Logger.warning("Failed to parse attrs for #{inspect(src)}")
|
||||||
|
%Asset{mod: mod, src: src, src_contents: contents, attrs: %{}}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def maybe_add(%Context{config: config} = ctx, path) do
|
defp collect_src_files(src_dir, exts) do
|
||||||
if Config.in_config?(config, path) do
|
files = src_dir |> File.ls!() |> Enum.map(&Path.join(src_dir, &1))
|
||||||
cond do
|
{src_files, rest} = Enum.split_with(files, &String.ends_with?(&1, exts))
|
||||||
String.starts_with?(path, config.pages_dir) ->
|
|
||||||
page = Path.relative_to(path, config.pages_dir)
|
|
||||||
%{ctx | pages: Enum.uniq([page | ctx.pages])}
|
|
||||||
|
|
||||||
String.starts_with?(path, config.posts_dir) ->
|
other_dirs = Enum.filter(rest, &File.dir?/1)
|
||||||
post = Path.relative_to(path, config.posts_dir)
|
|
||||||
%{ctx | posts: Enum.uniq([post | ctx.posts])}
|
|
||||||
|
|
||||||
true ->
|
src_files ++ Enum.flat_map(other_dirs, &collect_src_files(&1, exts))
|
||||||
ctx
|
|
||||||
end
|
|
||||||
else
|
|
||||||
ctx
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def in_context?(%Context{config: config, pages: pages, posts: posts}, path) do
|
|
||||||
with true <- Config.in_config?(config, path) do
|
|
||||||
cond do
|
|
||||||
String.starts_with?(path, config.pages_dir) ->
|
|
||||||
page = Path.relative_to(path, config.pages_dir)
|
|
||||||
|
|
||||||
[page in pages]
|
|
||||||
|
|
||||||
String.starts_with?(path, config.posts_dir) ->
|
|
||||||
post = Path.relative_to(path, config.posts_dir)
|
|
||||||
|
|
||||||
[post in posts]
|
|
||||||
|
|
||||||
true ->
|
|
||||||
false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,7 +3,7 @@ defmodule SloaneSH.FrontMatter do
|
||||||
Parses TOML front matter out put files
|
Parses TOML front matter out put files
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def parse("+++" <> rest, _ctx) do
|
def parse("+++" <> rest) do
|
||||||
[toml, body] = String.split(rest, ["+++\n", "+++\r\n"], parts: 2)
|
[toml, body] = String.split(rest, ["+++\n", "+++\r\n"], parts: 2)
|
||||||
|
|
||||||
with {:ok, attrs} <- Toml.decode(toml, keys: :atoms) do
|
with {:ok, attrs} <- Toml.decode(toml, keys: :atoms) do
|
||||||
|
@ -11,7 +11,7 @@ defmodule SloaneSH.FrontMatter do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def parse(body, _ctx) do
|
def parse(body) do
|
||||||
{:ok, %{}, body}
|
{:ok, %{}, body}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,24 +7,36 @@ defmodule SloaneSH.Layouts do
|
||||||
|
|
||||||
@layouts_dir Path.join(:code.priv_dir(:sloane_sh), "site/layouts")
|
@layouts_dir Path.join(:code.priv_dir(:sloane_sh), "site/layouts")
|
||||||
|
|
||||||
EEx.function_from_file(:def, :root_layout, Path.join(@layouts_dir, "root.html.eex"), [
|
EEx.function_from_file(:def, :root, Path.join(@layouts_dir, "root.html.eex"), [
|
||||||
|
:inner_content,
|
||||||
:ctx,
|
:ctx,
|
||||||
:attrs,
|
:attrs
|
||||||
:inner_content
|
|
||||||
])
|
])
|
||||||
|
|
||||||
EEx.function_from_file(:def, :page_layout, Path.join(@layouts_dir, "page.html.eex"), [
|
EEx.function_from_file(:defp, :page_layout, Path.join(@layouts_dir, "page.html.eex"), [
|
||||||
|
:inner_content,
|
||||||
:ctx,
|
:ctx,
|
||||||
:attrs,
|
:attrs
|
||||||
:inner_content
|
|
||||||
])
|
])
|
||||||
|
|
||||||
EEx.function_from_file(:def, :post_layout, Path.join(@layouts_dir, "post.html.eex"), [
|
EEx.function_from_file(:defp, :post_layout, Path.join(@layouts_dir, "post.html.eex"), [
|
||||||
|
:inner_content,
|
||||||
:ctx,
|
:ctx,
|
||||||
:attrs,
|
:attrs
|
||||||
:inner_content
|
|
||||||
])
|
])
|
||||||
|
|
||||||
|
def page(inner_content, ctx, attrs) do
|
||||||
|
inner_content
|
||||||
|
|> page_layout(ctx, attrs)
|
||||||
|
|> root(ctx, attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
def post(inner_content, ctx, attrs) do
|
||||||
|
inner_content
|
||||||
|
|> post_layout(ctx, attrs)
|
||||||
|
|> root(ctx, attrs)
|
||||||
|
end
|
||||||
|
|
||||||
defp prefix_title(prefix, nil), do: prefix
|
defp prefix_title(prefix, nil), do: prefix
|
||||||
defp prefix_title(prefix, page_title), do: [prefix, " | ", page_title]
|
defp prefix_title(prefix, page_title), do: [prefix, " | ", page_title]
|
||||||
end
|
end
|
||||||
|
|
40
lib/sloane_sh/output_dirs.ex
Normal file
40
lib/sloane_sh/output_dirs.ex
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
defmodule SloaneSH.OutputDirs do
|
||||||
|
def page(cfg, src) do
|
||||||
|
path = Path.relative_to(src, cfg.pages_dir)
|
||||||
|
|
||||||
|
cfg.output_dir |> Path.join(path) |> prettify_html_path()
|
||||||
|
end
|
||||||
|
|
||||||
|
def post(cfg, src) do
|
||||||
|
path = Path.relative_to(src, cfg.posts_dir)
|
||||||
|
path = Path.join("post", path)
|
||||||
|
|
||||||
|
cfg.output_dir |> Path.join(path) |> prettify_html_path()
|
||||||
|
end
|
||||||
|
|
||||||
|
def prettify_html_path(path) do
|
||||||
|
file = Path.basename(path)
|
||||||
|
[without_extension | _] = String.split(file, ".", parts: 2)
|
||||||
|
|
||||||
|
suffix =
|
||||||
|
if without_extension == "index" do
|
||||||
|
"index.html"
|
||||||
|
else
|
||||||
|
Path.join(without_extension, "index.html")
|
||||||
|
end
|
||||||
|
|
||||||
|
String.replace_suffix(path, file, suffix)
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_permalink(cfg, output_path) do
|
||||||
|
output_path
|
||||||
|
|> Path.relative_to(cfg.output_dir)
|
||||||
|
|> String.trim_trailing("index.html")
|
||||||
|
|> String.replace_prefix("", "/")
|
||||||
|
|> String.trim_trailing("/")
|
||||||
|
end
|
||||||
|
|
||||||
|
def from_permalink(cfg, permalink) do
|
||||||
|
Path.join([cfg.output_dir, permalink, "/index.html"])
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,33 +0,0 @@
|
||||||
defmodule SloaneSH.Write do
|
|
||||||
def page(ctx, src, data) do
|
|
||||||
path = md_to_html(src)
|
|
||||||
|
|
||||||
write(ctx, path, data)
|
|
||||||
end
|
|
||||||
|
|
||||||
def post(ctx, src, data) do
|
|
||||||
path = Path.join("posts", md_to_html(src))
|
|
||||||
|
|
||||||
write(ctx, path, data)
|
|
||||||
end
|
|
||||||
|
|
||||||
def write(ctx, name, data) do
|
|
||||||
path = Path.join(ctx.config.output_dir, name)
|
|
||||||
|
|
||||||
with :ok <- File.mkdir_p(Path.dirname(path)) do
|
|
||||||
File.write(path, data)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def md_to_html("index.md"), do: "index.html"
|
|
||||||
|
|
||||||
def md_to_html(path) do
|
|
||||||
dir = Path.dirname(path)
|
|
||||||
base = Path.basename(path, ".md")
|
|
||||||
|
|
||||||
case dir do
|
|
||||||
"." -> Path.join(base, "index.html")
|
|
||||||
_ -> Path.join([dir, base, "index.html"])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
7
mix.exs
7
mix.exs
|
@ -41,7 +41,12 @@ defmodule SloaneSH.MixProject do
|
||||||
"tailwind default --minify",
|
"tailwind default --minify",
|
||||||
"esbuild default --minify --sourcemap --target=chrome58,firefox57,safari11,edge16"
|
"esbuild default --minify --sourcemap --target=chrome58,firefox57,safari11,edge16"
|
||||||
],
|
],
|
||||||
"site.index": "cmd npx -y pagefind --site priv/output/"
|
"site.index": "cmd npx -y pagefind --site priv/output/",
|
||||||
|
"site.deploy": [
|
||||||
|
"site.build",
|
||||||
|
"site.index",
|
||||||
|
"assets.deploy"
|
||||||
|
]
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
+++
|
+++
|
||||||
permalink = "/"
|
|
||||||
page_title = "home"
|
page_title = "home"
|
||||||
+++
|
+++
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,3 @@
|
||||||
+++
|
|
||||||
permalink = "/search"
|
|
||||||
+++
|
|
||||||
|
|
||||||
<div class="mt-6" id="search"></div>
|
<div class="mt-6" id="search"></div>
|
||||||
|
|
||||||
<link rel="stylesheet" href="/pagefind/pagefind-ui.css" />
|
<link rel="stylesheet" href="/pagefind/pagefind-ui.css" />
|
|
@ -1,6 +1,7 @@
|
||||||
+++
|
+++
|
||||||
title = "Test Post"
|
title = "Test Post"
|
||||||
page_title = "Test Post"
|
page_title = "Test Post"
|
||||||
|
date = 2024-02-16
|
||||||
+++
|
+++
|
||||||
|
|
||||||
# Test Post
|
# Test Post
|
||||||
|
|
Loading…
Reference in a new issue