refactor build and asset pipeline

This commit is contained in:
sloane 2024-02-24 13:16:56 -05:00
parent 764290698f
commit 8bc094afb8
17 changed files with 238 additions and 148 deletions

View file

@ -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
View 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

View file

View 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

View file

@ -0,0 +1,3 @@
defmodule SloaneSH.Assets.Page do
use SloaneSH.Assets.Markdown, type: :page
end

View 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

View file

@ -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} ->
for {dest, content} <- output_files do
with :ok <- dest |> Path.dirname() |> File.mkdir_p(),
:ok <- File.write(dest, content) do
Logger.info("Wrote #{inspect(dest)}.")
else
{:error, err} ->
Logger.error("Failed to write #{inspect(dest)}, #{inspect(err)}")
end
end
def build_posts(%Context{} = ctx) do err ->
Logger.info("Building posts...") Logger.error("Failed to render #{inspect(asset.src)}, #{inspect(err)}")
for post <- ctx.posts, do: build_post(ctx, post) end
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
err -> Logger.error("Failed to build page #{page}: #{inspect(err)}")
end end
end end
def build_post(%Context{} = ctx, post) do
path = Path.join(ctx.config.posts_dir, post)
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
def copy_img(%Context{} = ctx) do
ctx
end
end end

View file

@ -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} ->

View file

@ -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$/))
posts = Enum.filter(posts_contents, &String.match?(&1, ~r/.*\.md$/))
%Context{context | pages: pages, posts: posts}
end
end
def maybe_add(%Context{config: config} = ctx, path) do for src <- collect_src_files(src_dir, exts) do
if Config.in_config?(config, path) do contents = File.read!(src)
cond do
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) -> case mod.attrs(cfg, src, contents) do
post = Path.relative_to(path, config.posts_dir) {:ok, attrs, src_contents} ->
%{ctx | posts: Enum.uniq([post | ctx.posts])} %Asset{mod: mod, src: src, src_contents: src_contents, attrs: attrs}
true -> {:ok, attrs} ->
ctx %Asset{mod: mod, src: src, src_contents: contents, attrs: attrs}
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 Logger.warning("Failed to parse attrs for #{inspect(src)}")
cond do %Asset{mod: mod, src: src, src_contents: contents, attrs: %{}}
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
defp collect_src_files(src_dir, exts) do
files = src_dir |> File.ls!() |> Enum.map(&Path.join(src_dir, &1))
{src_files, rest} = Enum.split_with(files, &String.ends_with?(&1, exts))
other_dirs = Enum.filter(rest, &File.dir?/1)
src_files ++ Enum.flat_map(other_dirs, &collect_src_files(&1, exts))
end
end end

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View file

@ -1,5 +1,4 @@
+++ +++
permalink = "/"
page_title = "home" page_title = "home"
+++ +++

View file

@ -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" />

View file

@ -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