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
|
||||
Context.new()
|
||||
|> Context.init()
|
||||
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
|
||||
|
||||
alias SloaneSH.Context
|
||||
alias SloaneSH.Layouts
|
||||
alias SloaneSH.Markdown
|
||||
alias SloaneSH.Write
|
||||
|
||||
def run(%Context{} = ctx) do
|
||||
ctx
|
||||
|> build_pages()
|
||||
|> build_posts()
|
||||
|> copy_img()
|
||||
end
|
||||
assets = ctx.posts ++ ctx.pages
|
||||
|
||||
def build_pages(%Context{} = ctx) do
|
||||
Logger.info("Building pages...")
|
||||
for page <- ctx.pages, do: build_page(ctx, page)
|
||||
File.mkdir_p!(ctx.config.output_dir)
|
||||
|
||||
ctx
|
||||
end
|
||||
|
||||
def build_posts(%Context{} = ctx) do
|
||||
Logger.info("Building posts...")
|
||||
for post <- ctx.posts, do: build_post(ctx, post)
|
||||
|
||||
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}")
|
||||
for asset <- assets do
|
||||
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
|
||||
err -> Logger.error("Failed to build page #{page}: #{inspect(err)}")
|
||||
{:error, err} ->
|
||||
Logger.error("Failed to write #{inspect(dest)}, #{inspect(err)}")
|
||||
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)}")
|
||||
err ->
|
||||
Logger.error("Failed to render #{inspect(asset.src)}, #{inspect(err)}")
|
||||
end
|
||||
end
|
||||
|
||||
def copy_img(%Context{} = ctx) do
|
||||
ctx
|
||||
end
|
||||
end
|
||||
|
|
|
@ -22,11 +22,6 @@ defmodule SloaneSH.Config do
|
|||
}
|
||||
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
|
||||
case File.read_link(path) do
|
||||
{:ok, link} ->
|
||||
|
|
|
@ -4,64 +4,53 @@ defmodule SloaneSH.Context do
|
|||
files.
|
||||
"""
|
||||
use TypedStruct
|
||||
require Logger
|
||||
|
||||
alias SloaneSH.Config
|
||||
alias SloaneSH.Asset
|
||||
alias SloaneSH.Assets.Page
|
||||
alias SloaneSH.Assets.Post
|
||||
alias __MODULE__
|
||||
|
||||
typedstruct do
|
||||
field :config, Config.t(), enforce: true
|
||||
field :pages, [String.t()], default: []
|
||||
field :posts, [String.t()], default: []
|
||||
field :pages, [Asset.t()], default: []
|
||||
field :posts, [Asset.t()], default: []
|
||||
end
|
||||
|
||||
def new(config \\ Config.default()) do
|
||||
%Context{config: config}
|
||||
def new(cfg \\ Config.default()) do
|
||||
pages = load_assets(cfg, Page, cfg.pages_dir)
|
||||
posts = load_assets(cfg, Post, cfg.posts_dir)
|
||||
|
||||
%Context{config: cfg, pages: pages, posts: posts}
|
||||
end
|
||||
|
||||
def init(%Context{config: config} = context) do
|
||||
with {:ok, pages_contents} <- File.ls(config.pages_dir),
|
||||
{: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}
|
||||
defp load_assets(cfg, mod, src_dir) do
|
||||
exts = mod.extensions(cfg)
|
||||
|
||||
for src <- collect_src_files(src_dir, exts) do
|
||||
contents = File.read!(src)
|
||||
|
||||
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
|
||||
|
||||
def maybe_add(%Context{config: config} = ctx, path) do
|
||||
if Config.in_config?(config, path) do
|
||||
cond do
|
||||
String.starts_with?(path, config.pages_dir) ->
|
||||
page = Path.relative_to(path, config.pages_dir)
|
||||
%{ctx | pages: Enum.uniq([page | ctx.pages])}
|
||||
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))
|
||||
|
||||
String.starts_with?(path, config.posts_dir) ->
|
||||
post = Path.relative_to(path, config.posts_dir)
|
||||
%{ctx | posts: Enum.uniq([post | ctx.posts])}
|
||||
other_dirs = Enum.filter(rest, &File.dir?/1)
|
||||
|
||||
true ->
|
||||
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
|
||||
src_files ++ Enum.flat_map(other_dirs, &collect_src_files(&1, exts))
|
||||
end
|
||||
end
|
||||
|
|
|
@ -3,7 +3,7 @@ defmodule SloaneSH.FrontMatter do
|
|||
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)
|
||||
|
||||
with {:ok, attrs} <- Toml.decode(toml, keys: :atoms) do
|
||||
|
@ -11,7 +11,7 @@ defmodule SloaneSH.FrontMatter do
|
|||
end
|
||||
end
|
||||
|
||||
def parse(body, _ctx) do
|
||||
def parse(body) do
|
||||
{:ok, %{}, body}
|
||||
end
|
||||
end
|
||||
|
|
|
@ -7,24 +7,36 @@ defmodule SloaneSH.Layouts do
|
|||
|
||||
@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,
|
||||
:attrs,
|
||||
:inner_content
|
||||
:attrs
|
||||
])
|
||||
|
||||
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,
|
||||
:attrs,
|
||||
:inner_content
|
||||
:attrs
|
||||
])
|
||||
|
||||
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,
|
||||
:attrs,
|
||||
:inner_content
|
||||
:attrs
|
||||
])
|
||||
|
||||
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, page_title), do: [prefix, " | ", page_title]
|
||||
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",
|
||||
"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
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
+++
|
||||
permalink = "/"
|
||||
page_title = "home"
|
||||
+++
|
||||
|
||||
|
|
|
@ -1,7 +1,3 @@
|
|||
+++
|
||||
permalink = "/search"
|
||||
+++
|
||||
|
||||
<div class="mt-6" id="search"></div>
|
||||
|
||||
<link rel="stylesheet" href="/pagefind/pagefind-ui.css" />
|
|
@ -1,6 +1,7 @@
|
|||
+++
|
||||
title = "Test Post"
|
||||
page_title = "Test Post"
|
||||
date = 2024-02-16
|
||||
+++
|
||||
|
||||
# Test Post
|
||||
|
|
Loading…
Reference in a new issue