From 7c0a72d7755216dd76a3d1688d46590346071843 Mon Sep 17 00:00:00 2001 From: sloane <1699281+sloanelybutsurely@users.noreply.github.com> Date: Fri, 16 Feb 2024 22:28:13 -0500 Subject: [PATCH] basic build and watch commands --- .formatter.exs | 3 +- .gitignore | 2 + lib/sloane_sh.ex | 15 +++++- lib/sloane_sh/build.ex | 51 +++++++++++++++++++ lib/sloane_sh/config.ex | 40 +++++++++++++++ lib/sloane_sh/context.ex | 67 +++++++++++++++++++++++++ lib/sloane_sh/markdown.ex | 21 ++++++++ lib/sloane_sh/watch.ex | 57 ++++++++++++++++----- lib/sloane_sh/write.ex | 33 ++++++++++++ mix.exs | 5 +- mix.lock | 3 ++ priv/site/pages/index.md | 6 +++ priv/site/posts/2024-02-16-test-post.md | 3 ++ 13 files changed, 291 insertions(+), 15 deletions(-) create mode 100644 lib/sloane_sh/build.ex create mode 100644 lib/sloane_sh/config.ex create mode 100644 lib/sloane_sh/context.ex create mode 100644 lib/sloane_sh/markdown.ex create mode 100644 lib/sloane_sh/write.ex create mode 100644 priv/site/pages/index.md create mode 100644 priv/site/posts/2024-02-16-test-post.md diff --git a/.formatter.exs b/.formatter.exs index d2cda26..376fc86 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,4 +1,5 @@ # 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] ] diff --git a/.gitignore b/.gitignore index afabe58..4b241bd 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,5 @@ sloane_sh-*.tar /tmp/ /.elixir_ls/ + +/priv/output/ diff --git a/lib/sloane_sh.ex b/lib/sloane_sh.ex index 8834a17..7df2d54 100644 --- a/lib/sloane_sh.ex +++ b/lib/sloane_sh.ex @@ -3,11 +3,24 @@ defmodule SloaneSH do 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 + context() + |> Build.run() + :ok end def watch(_opts \\ []) do - SloaneSH.Watch.start_link() + context() + |> Watch.start_link() + end + + def context do + Context.new() + |> Context.init() end end diff --git a/lib/sloane_sh/build.ex b/lib/sloane_sh/build.ex new file mode 100644 index 0000000..c295258 --- /dev/null +++ b/lib/sloane_sh/build.ex @@ -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 diff --git a/lib/sloane_sh/config.ex b/lib/sloane_sh/config.ex new file mode 100644 index 0000000..07c16e7 --- /dev/null +++ b/lib/sloane_sh/config.ex @@ -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 diff --git a/lib/sloane_sh/context.ex b/lib/sloane_sh/context.ex new file mode 100644 index 0000000..af0e4aa --- /dev/null +++ b/lib/sloane_sh/context.ex @@ -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 diff --git a/lib/sloane_sh/markdown.ex b/lib/sloane_sh/markdown.ex new file mode 100644 index 0000000..a418871 --- /dev/null +++ b/lib/sloane_sh/markdown.ex @@ -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 diff --git a/lib/sloane_sh/watch.ex b/lib/sloane_sh/watch.ex index 152e8d9..7068ed1 100644 --- a/lib/sloane_sh/watch.ex +++ b/lib/sloane_sh/watch.ex @@ -1,29 +1,62 @@ defmodule SloaneSH.Watch do use GenServer + use TypedStruct require Logger - def start_link(init_arg \\ [], opts \\ []) do - GenServer.start_link(__MODULE__, init_arg, opts) + alias SloaneSH.Build + 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 @impl GenServer - def init([]) do - dirs = [Path.join(:code.priv_dir(:sloane_sh), "site")] - Logger.info("Watching #{inspect(dirs)} for changes") - {:ok, pid} = FileSystem.start_link(dirs: dirs) - FileSystem.subscribe(pid) + def init(%Context{} = ctx) do + {:ok, watcher_pid} = + FileSystem.start_link( + dirs: + 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 @impl GenServer - def handle_info({:file_event, pid, {path, events}}, pid) do - Logger.info("File event: #{inspect(path)} #{inspect(events)}") - {:noreply, pid} + def handle_continue(:build, %{ctx: ctx} = state) do + Build.run(ctx) + + {:noreply, state} end @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") {:stop, :watcher_stopped, pid} end diff --git a/lib/sloane_sh/write.ex b/lib/sloane_sh/write.ex new file mode 100644 index 0000000..8839021 --- /dev/null +++ b/lib/sloane_sh/write.ex @@ -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 diff --git a/mix.exs b/mix.exs index e457596..dfdc4c7 100644 --- a/mix.exs +++ b/mix.exs @@ -21,7 +21,10 @@ defmodule SloaneSH.MixProject do # Run "mix help deps" to learn about dependencies. 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 diff --git a/mix.lock b/mix.lock index 91e039c..d9f79b7 100644 --- a/mix.lock +++ b/mix.lock @@ -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"}, + "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"}, } diff --git a/priv/site/pages/index.md b/priv/site/pages/index.md new file mode 100644 index 0000000..23013fa --- /dev/null +++ b/priv/site/pages/index.md @@ -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) diff --git a/priv/site/posts/2024-02-16-test-post.md b/priv/site/posts/2024-02-16-test-post.md new file mode 100644 index 0000000..be6ab48 --- /dev/null +++ b/priv/site/posts/2024-02-16-test-post.md @@ -0,0 +1,3 @@ +# Test Post + +this is just a test of the posts functionality