basic build and watch commands

This commit is contained in:
sloane 2024-02-16 22:28:13 -05:00
parent aa01ee6967
commit 7c0a72d775
13 changed files with 291 additions and 15 deletions

View file

@ -1,4 +1,5 @@
# Used by "mix format" # Used by "mix format"
[ [
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
import_deps: [:typed_struct]
] ]

2
.gitignore vendored
View file

@ -26,3 +26,5 @@ sloane_sh-*.tar
/tmp/ /tmp/
/.elixir_ls/ /.elixir_ls/
/priv/output/

View file

@ -3,11 +3,24 @@ defmodule SloaneSH do
Sloane's personal static site generator powering [sloane.sh](https://sloane.sh). Sloane's personal static site generator powering [sloane.sh](https://sloane.sh).
""" """
alias SloaneSH.Build
alias SloaneSH.Watch
alias SloaneSH.Context
def build(_opts \\ []) do def build(_opts \\ []) do
context()
|> Build.run()
:ok :ok
end end
def watch(_opts \\ []) do def watch(_opts \\ []) do
SloaneSH.Watch.start_link() context()
|> Watch.start_link()
end
def context do
Context.new()
|> Context.init()
end end
end end

51
lib/sloane_sh/build.ex Normal file
View file

@ -0,0 +1,51 @@
defmodule SloaneSH.Build do
require Logger
alias SloaneSH.Context
alias SloaneSH.Markdown
alias SloaneSH.Write
def run(%Context{} = ctx) do
ctx
|> build_pages()
|> build_posts()
end
def build_pages(%Context{} = ctx) do
Logger.info("Building pages...")
for page <- ctx.pages, do: build_page(ctx, page)
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, html} <- Markdown.transform(ctx, data),
: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
def build_post(%Context{} = ctx, post) do
path = Path.join(ctx.config.posts_dir, post)
with {:ok, data} <- File.read(path),
{:ok, html} <- Markdown.transform(ctx, data),
: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

40
lib/sloane_sh/config.ex Normal file
View file

@ -0,0 +1,40 @@
defmodule SloaneSH.Config do
@moduledoc """
SloaneSH configuration
"""
use TypedStruct
alias __MODULE__
typedstruct do
field :pages_dir, String.t(), enforce: true
field :posts_dir, String.t(), enforce: true
field :output, String.t(), enforce: true
end
def default do
priv = :code.priv_dir(:sloane_sh) |> resolve_link()
%Config{
pages_dir: Path.join(priv, "site/pages"),
posts_dir: Path.join(priv, "site/posts"),
output: Path.join(priv, "output")
}
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} ->
dir = Path.dirname(path)
Path.expand(link, dir)
_ ->
path
end
end
end

67
lib/sloane_sh/context.ex Normal file
View file

@ -0,0 +1,67 @@
defmodule SloaneSH.Context do
@moduledoc """
A SloaneSH build context containing configuration and reference to content
files.
"""
use TypedStruct
alias SloaneSH.Config
alias __MODULE__
typedstruct do
field :config, Config.t(), enforce: true
field :pages, [String.t()], default: []
field :posts, [String.t()], default: []
end
def new(config \\ Config.default()) do
%Context{config: config}
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}
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])}
String.starts_with?(path, config.posts_dir) ->
post = Path.relative_to(path, config.posts_dir)
%{ctx | posts: Enum.uniq([post | ctx.posts])}
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
end
end

21
lib/sloane_sh/markdown.ex Normal file
View file

@ -0,0 +1,21 @@
defmodule SloaneSH.Markdown do
@moduledoc """
Markdown parsing using `Earmark` and `Earmark.Parser`
"""
require Logger
alias SloaneSH.Context
def transform(%Context{} = _ctx, data) when is_binary(data) do
case Earmark.as_html(data) do
{:ok, html_doc, deprecation_messages} ->
for msg <- deprecation_messages, do: Logger.warning(msg)
{:ok, html_doc}
{:error, html_doc, error_messages} ->
for msg <- error_messages, do: Logger.error(msg)
{:error, html_doc}
end
end
end

View file

@ -1,29 +1,62 @@
defmodule SloaneSH.Watch do defmodule SloaneSH.Watch do
use GenServer use GenServer
use TypedStruct
require Logger require Logger
def start_link(init_arg \\ [], opts \\ []) do alias SloaneSH.Build
GenServer.start_link(__MODULE__, init_arg, opts) alias SloaneSH.Context
typedstruct do
field :ctx, Context.t(), enforce: true
field :watcher_pid, pid(), enforce: true
end
def start_link(%Context{} = ctx, opts \\ []) do
GenServer.start_link(__MODULE__, ctx, opts)
end end
@impl GenServer @impl GenServer
def init([]) do def init(%Context{} = ctx) do
dirs = [Path.join(:code.priv_dir(:sloane_sh), "site")] {:ok, watcher_pid} =
Logger.info("Watching #{inspect(dirs)} for changes") FileSystem.start_link(
{:ok, pid} = FileSystem.start_link(dirs: dirs) dirs:
FileSystem.subscribe(pid) dbg([
ctx.config.pages_dir,
ctx.config.posts_dir
])
)
{:ok, pid} FileSystem.subscribe(watcher_pid)
state = %__MODULE__{ctx: ctx, watcher_pid: watcher_pid}
{:ok, state, {:continue, :build}}
end end
@impl GenServer @impl GenServer
def handle_info({:file_event, pid, {path, events}}, pid) do def handle_continue(:build, %{ctx: ctx} = state) do
Logger.info("File event: #{inspect(path)} #{inspect(events)}") Build.run(ctx)
{:noreply, pid}
{:noreply, state}
end end
@impl GenServer @impl GenServer
def handle_info({:file_event, pid, :stop}, pid) do def handle_info({:file_event, pid, {path, events}}, %{ctx: ctx, watcher_pid: pid} = state) do
ctx = Context.maybe_add(ctx, path)
if Context.in_context?(ctx, path) do
path = Path.relative_to(path, ctx.config.pages_dir)
Logger.info("File changed: #{path}")
Build.build_page(ctx, path)
end
%{state | ctx: ctx}
{:noreply, state}
end
@impl GenServer
def handle_info({:file_event, pid, :stop}, %{watcher_pid: pid}) do
Logger.warning("File watcher stopped") Logger.warning("File watcher stopped")
{:stop, :watcher_stopped, pid} {:stop, :watcher_stopped, pid}
end end

33
lib/sloane_sh/write.ex Normal file
View file

@ -0,0 +1,33 @@
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, 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

@ -21,7 +21,10 @@ defmodule SloaneSH.MixProject do
# Run "mix help deps" to learn about dependencies. # Run "mix help deps" to learn about dependencies.
defp deps do defp deps do
[ [
{:file_system, "~> 1.0.0"} {:file_system, "~> 1.0.0"},
{:typed_struct, "~> 0.3.0"},
{:earmark, "~> 1.4"},
{:earmark_parser, "~> 1.4"}
] ]
end end
end end

View file

@ -1,3 +1,6 @@
%{ %{
"earmark": {:hex, :earmark, "1.4.46", "8c7287bd3137e99d26ae4643e5b7ef2129a260e3dcf41f251750cb4563c8fb81", [:mix], [], "hexpm", "798d86db3d79964e759ddc0c077d5eb254968ed426399fbf5a62de2b5ff8910a"},
"earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"},
"file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"},
"typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"},
} }

6
priv/site/pages/index.md Normal file
View file

@ -0,0 +1,6 @@
# Hello, World!
my name is sloane. i am a software engineer.
i'm on the fediverse [@sloane@tech.lgbt](https://tech.lgbt/@sloane)

View file

@ -0,0 +1,3 @@
# Test Post
this is just a test of the posts functionality