pre.hn/lib/lain.ex

294 lines
7.4 KiB
Elixir

defmodule Lain do
@moduledoc """
Documentation for `Lain`.
"""
use Phoenix.Component
require Logger
import Phoenix.LiveViewTest, only: [rendered_to_string: 1]
alias Lain.Frontmatter
alias Lain.Markdown
def read(path) do
path
|> File.read!()
|> Frontmatter.front_matter_split()
|> then(fn {frontmatter_text, body_text} ->
{Frontmatter.make_frontmatter(path, frontmatter_text), body_text}
end)
|> then(fn {frontmatter, body_text} ->
body = Markdown.render(body_text)
Map.merge(frontmatter, %{"body" => body, "path" => path})
end)
end
def validate(%{"slug" => nil, "path" => path}) do
{:error, "slug missing from #{path}"}
end
def validate(%{"slug" => "", "path" => path}) do
{:error, "slug missing from #{path}"}
end
def validate(other), do: {:ok, other}
def clean(base_path) do
base_path
|> Path.join("**")
|> Path.wildcard()
|> Enum.each(fn path ->
if File.dir?(path) do
if !File.exists?(Path.join(path, ".keep")) do
File.exists?(path) && File.rm_rf!(path)
end
else
File.exists?(path) && File.rm!(path)
end
end)
end
def serve(path) do
Application.put_env(:lain, :serve_endpoints, true, persistent: true)
Application.put_env(:lain, :built_path, Path.join(Path.expand(path), "build"),
persistent: true
)
run(path)
:timer.sleep(:infinity)
end
def run(path) do
posts =
path
|> Path.join("**/*.md")
|> Path.wildcard()
|> Enum.map(&read/1)
|> Enum.map(&validate/1)
|> Enum.filter(fn
{:error, error} ->
Logger.warning(error)
false
{:ok, _other} ->
true
end)
|> Enum.map(&elem(&1, 1))
|> Enum.sort_by(&Map.get(&1, "date"), :desc)
|> Enum.map(&atomize_keys/1)
make_feed(posts, path)
make_sitemap(posts, path)
index = make_index(posts, path)
Lain.LinkLog.run(path)
Lain.Static.run(path)
Enum.map([index | posts], &write_page(&1, path))
end
def atomize_keys(page) do
page
|> Enum.map(fn {key, value} -> {String.to_atom(key), value} end)
|> Enum.into(%{})
end
def make_index(posts, root_path) do
posts =
Enum.filter(posts, fn %{path: path} ->
String.starts_with?(path, Path.join(root_path, "posts"))
end)
assigns = %{posts: posts}
body =
~H"""
<%= for post <- @posts do %>
<div :if={!post.hidden} style="margin-bottom: 1rem">
<span class="text-yellow align-top"><%= post.date %></span>&nbsp;
<a class="text-ellipsis overflow-hidden inline-block" style="max-width: 40ch" href={"/#{post.slug}/"}><%= post.title %></a>
</div>
<% end %>
"""
|> rendered_to_string()
%{
title: "Blog",
slug: "blog",
date: "2023-09-23",
body: body
}
end
def make_feed(posts, path) do
posts = posts |> Enum.reject(& &1.hidden) |> Enum.take(10)
base = Application.get_env(:lain, :base, "/")
assigns = %{base: base, posts: posts}
body =
EEx.eval_string(
"""
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<id>https://pre.hn/feed.rss</id>
<updated><%= DateTime.utc_now() |> DateTime.to_iso8601() %></updated>
<title>pre.hn</title>
<subtitle>Robert Prehn's personal blog.</subtitle>
<link href="https://pre.hn/feed.rss" rel="self" type="application/rss+xml"></link>
<%= for post <- @posts do %>
<entry>
<title><%= HtmlSanitizeEx.strip_tags(post.title) %></title>
<content type="html"><%= post.body |> sanitize_html_for_rss.() |> xml_escape.() %>
</content>
<published><%= rss_date_format.(post.date) %></published>
<updated><%= rss_date_format.(post[:updated_at] || post.date) %></updated>
<author><name>Robert Prehn</name></author>
<id><%= @base %><%= post.slug %>/</id>
<link type="text/html" href="<%= @base %><%= post.slug %>/"></link>
</entry>
<% end %>
</feed>
""",
assigns: assigns,
rss_date_format: &rss_date_format/1,
sanitize_html_for_rss: &sanitize_html_for_rss/1,
xml_escape: &xml_escape/1
)
File.mkdir_p!(Path.join([path, "build"]))
path = Path.join([path, "build", "feed.rss"])
File.write!(path, body)
end
@days_of_week %{
1 => "Mon",
2 => "Tue",
3 => "Wed",
4 => "Thu",
5 => "Fri",
6 => "Sat",
7 => "Sun"
}
@months %{
1 => "Jan",
2 => "Feb",
3 => "Mar",
4 => "Apr",
5 => "May",
6 => "Jun",
7 => "Jul",
8 => "Aug",
9 => "Sep",
10 => "Oct",
11 => "Nov",
12 => "Dec"
}
def rss_date_format(date_string) do
date = Date.from_iso8601!(date_string)
{:ok, datetime} = DateTime.new(date, %Time{hour: 0, minute: 0, second: 0})
DateTime.to_iso8601(datetime)
end
def sanitize_html_for_rss(body_string) do
html = Floki.parse_fragment!(body_string)
Floki.traverse_and_update(html, fn
{"iframe", _attrs, _children} = node ->
[src | _] = Floki.attribute(node, "src")
[title | _] = Floki.attribute(node, "title")
{"a", [{"href", src || "#"}], [title]}
other ->
other
end)
|> Floki.raw_html()
end
def xml_escape(xml_string) do
xml_string
|> String.replace("&", "&amp;")
|> String.replace("<", "&lt;")
|> String.replace(">", "&gt;")
|> String.replace("\"", "&quot;")
|> String.replace("'", "&apos;")
end
def make_sitemap(posts, path) do
posts
|> Stream.reject(fn %{hidden: hidden} ->
hidden
end)
|> Stream.map(
&%Sitemapper.URL{lastmod: &1[:updated_at] || &1.date, loc: "https://pre.hn/#{&1.slug}/"}
)
|> Sitemapper.generate(sitemap_url: "https://pre.hn/")
|> Sitemapper.persist(
store: Sitemapper.FileStore,
store_config: [path: Path.join(path, "build")]
)
|> Stream.run()
end
def render_template(path, assigns) do
EEx.eval_file(path, [assigns: assigns],
engine: Phoenix.LiveView.TagEngine,
tag_handler: Phoenix.LiveView.HTMLEngine,
caller: __ENV__,
source: File.read!(path)
)
end
attr(:path, :string, required: true)
attr(:title, :string, required: true)
attr(:slug, :string, required: true)
attr(:description, :string)
slot(:inner_block, required: true)
def layout(assigns) do
%{path: path} = assigns
path = Path.join(path, "templates/layout.html.heex")
inner_block = ~H"""
<%= render_slot(@inner_block) %>
"""
assigns = %{assigns | inner_block: inner_block}
render_template(path, assigns)
end
def menu(%{path: root_path} = assigns) do
path = Path.join([root_path, "templates", "menu.html.heex"])
render_template(path, assigns)
end
def write_page(%{slug: slug} = assigns, root_path) do
path =
if slug == "index" do
Path.join([root_path, "build", "index.html"])
else
directory = Path.join([root_path, "build", slug])
File.mkdir_p!(directory)
Path.join([root_path, "build", slug, "index.html"])
end
assigns = Map.put(assigns, :root_path, root_path)
root_path
|> Path.join("templates/page.html.heex")
|> render_template(assigns)
|> rendered_to_string()
|> then(&File.write!(path, &1))
end
end