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 %>
<%= post.date %>  <%= post.title %>
<% 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( """ https://pre.hn/feed.rss <%= DateTime.utc_now() |> DateTime.to_iso8601() %> pre.hn Robert Prehn's personal blog. <%= for post <- @posts do %> <%= HtmlSanitizeEx.strip_tags(post.title) %> <%= post.body |> sanitize_html_for_rss.() |> xml_escape.() %> <%= rss_date_format.(post.date) %> <%= rss_date_format.(post[:updated_at] || post.date) %> Robert Prehn <%= @base %><%= post.slug %>/ <% end %> """, 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("&", "&") |> String.replace("<", "<") |> String.replace(">", ">") |> String.replace("\"", """) |> String.replace("'", "'") 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