Current section
Files
Jump to
Current section
Files
lib/sayfa/content.ex
defmodule Sayfa.Content do
@moduledoc """
Content struct and parsing functions.
Represents a fully parsed piece of content with HTML body, metadata,
and all front matter fields resolved. This is the struct passed to templates.
## Parsing
Content can be parsed from a raw string (with YAML front matter) or from
a `Sayfa.Content.Raw` struct:
iex> {:ok, content} = Sayfa.Content.parse("---\\ntitle: Hello\\n---\\n# World")
iex> content.title
"Hello"
iex> content.body =~ "World</h1>"
true
## Front Matter
Known fields are mapped to struct keys. Unknown fields are collected
into the `meta` map:
---
title: "My Post"
custom_field: "value"
---
Results in `content.title == "My Post"` and `content.meta["custom_field"] == "value"`.
"""
alias Sayfa.Content.Raw
@enforce_keys [:title, :body]
defstruct [
:title,
:body,
:date,
:slug,
:lang,
:source_path,
categories: [],
tags: [],
draft: false,
meta: %{}
]
@type t :: %__MODULE__{
title: String.t(),
body: String.t(),
date: Date.t() | nil,
slug: String.t() | nil,
lang: atom() | nil,
source_path: String.t() | nil,
categories: [String.t()],
tags: [String.t()],
draft: boolean(),
meta: map()
}
@known_keys ~w(title date slug lang categories tags draft)
# --- Collections API ---
@doc """
Filters contents by content type string.
## Examples
iex> posts = [%Sayfa.Content{title: "A", body: "", meta: %{"content_type" => "posts"}},
...> %Sayfa.Content{title: "B", body: "", meta: %{"content_type" => "pages"}}]
iex> Sayfa.Content.all_of_type(posts, "posts") |> length()
1
"""
@spec all_of_type([t()], String.t()) :: [t()]
def all_of_type(contents, type) do
Enum.filter(contents, fn c -> c.meta["content_type"] == type end)
end
@doc """
Filters contents that have the given tag.
## Examples
iex> contents = [%Sayfa.Content{title: "A", body: "", tags: ["elixir", "otp"]},
...> %Sayfa.Content{title: "B", body: "", tags: ["rust"]}]
iex> Sayfa.Content.with_tag(contents, "elixir") |> length()
1
"""
@spec with_tag([t()], String.t()) :: [t()]
def with_tag(contents, tag) do
Enum.filter(contents, fn c -> tag in c.tags end)
end
@doc """
Filters contents that have the given category.
## Examples
iex> contents = [%Sayfa.Content{title: "A", body: "", categories: ["programming"]},
...> %Sayfa.Content{title: "B", body: "", categories: ["cooking"]}]
iex> Sayfa.Content.with_category(contents, "programming") |> length()
1
"""
@spec with_category([t()], String.t()) :: [t()]
def with_category(contents, category) do
Enum.filter(contents, fn c -> category in c.categories end)
end
@doc """
Sorts contents by date.
Items with `nil` dates are pushed to the end.
## Options
- `:desc` (default) — newest first
- `:asc` — oldest first
## Examples
iex> contents = [%Sayfa.Content{title: "Old", body: "", date: ~D[2024-01-01]},
...> %Sayfa.Content{title: "New", body: "", date: ~D[2024-06-01]}]
iex> sorted = Sayfa.Content.sort_by_date(contents)
iex> hd(sorted).title
"New"
"""
@spec sort_by_date([t()], :asc | :desc) :: [t()]
def sort_by_date(contents, order \\ :desc) do
{with_dates, without_dates} = Enum.split_with(contents, fn c -> c.date != nil end)
sorted =
case order do
:desc -> Enum.sort_by(with_dates, & &1.date, {:desc, Date})
:asc -> Enum.sort_by(with_dates, & &1.date, {:asc, Date})
end
sorted ++ without_dates
end
@doc """
Returns the N most recent contents (sorted by date descending).
## Examples
iex> contents = [%Sayfa.Content{title: "A", body: "", date: ~D[2024-01-01]},
...> %Sayfa.Content{title: "B", body: "", date: ~D[2024-06-01]},
...> %Sayfa.Content{title: "C", body: "", date: ~D[2024-03-01]}]
iex> Sayfa.Content.recent(contents, 2) |> Enum.map(& &1.title)
["B", "C"]
"""
@spec recent([t()], pos_integer()) :: [t()]
def recent(contents, n) do
contents |> sort_by_date(:desc) |> Enum.take(n)
end
@doc """
Groups contents by tag.
Returns a map where each key is a tag and the value is a list
of contents that have that tag.
## Examples
iex> contents = [%Sayfa.Content{title: "A", body: "", tags: ["elixir", "otp"]},
...> %Sayfa.Content{title: "B", body: "", tags: ["elixir"]}]
iex> groups = Sayfa.Content.group_by_tag(contents)
iex> length(groups["elixir"])
2
iex> length(groups["otp"])
1
"""
@spec group_by_tag([t()]) :: %{String.t() => [t()]}
def group_by_tag(contents) do
Enum.reduce(contents, %{}, fn content, acc ->
Enum.reduce(content.tags, acc, fn tag, inner_acc ->
Map.update(inner_acc, tag, [content], &[content | &1])
end)
end)
|> Map.new(fn {k, v} -> {k, Enum.reverse(v)} end)
end
@doc """
Groups contents by category.
Returns a map where each key is a category and the value is a list
of contents that have that category.
## Examples
iex> contents = [%Sayfa.Content{title: "A", body: "", categories: ["programming"]},
...> %Sayfa.Content{title: "B", body: "", categories: ["programming", "elixir"]}]
iex> groups = Sayfa.Content.group_by_category(contents)
iex> length(groups["programming"])
2
"""
@spec group_by_category([t()]) :: %{String.t() => [t()]}
def group_by_category(contents) do
Enum.reduce(contents, %{}, fn content, acc ->
Enum.reduce(content.categories, acc, fn cat, inner_acc ->
Map.update(inner_acc, cat, [content], &[content | &1])
end)
end)
|> Map.new(fn {k, v} -> {k, Enum.reverse(v)} end)
end
@doc """
Groups contents by `{tag, lang_prefix}` tuple.
Returns a map where each key is a `{tag, lang_prefix}` tuple and the value
is a list of contents that have that tag in that language.
## Examples
iex> contents = [
...> %Sayfa.Content{title: "A", body: "", tags: ["elixir"], meta: %{"lang_prefix" => ""}},
...> %Sayfa.Content{title: "B", body: "", tags: ["elixir"], meta: %{"lang_prefix" => "tr"}}
...> ]
iex> groups = Sayfa.Content.group_by_tag_and_lang(contents)
iex> length(groups[{"elixir", ""}])
1
iex> length(groups[{"elixir", "tr"}])
1
"""
@spec group_by_tag_and_lang([t()]) :: %{{String.t(), String.t()} => [t()]}
def group_by_tag_and_lang(contents) do
Enum.reduce(contents, %{}, fn content, acc ->
lang_prefix = content.meta["lang_prefix"] || ""
Enum.reduce(content.tags, acc, fn tag, inner_acc ->
Map.update(inner_acc, {tag, lang_prefix}, [content], &[content | &1])
end)
end)
|> Map.new(fn {k, v} -> {k, Enum.reverse(v)} end)
end
@doc """
Groups contents by `{category, lang_prefix}` tuple.
Returns a map where each key is a `{category, lang_prefix}` tuple and the value
is a list of contents that have that category in that language.
## Examples
iex> contents = [
...> %Sayfa.Content{title: "A", body: "", categories: ["programming"], meta: %{"lang_prefix" => ""}},
...> %Sayfa.Content{title: "B", body: "", categories: ["programming"], meta: %{"lang_prefix" => "tr"}}
...> ]
iex> groups = Sayfa.Content.group_by_category_and_lang(contents)
iex> length(groups[{"programming", ""}])
1
iex> length(groups[{"programming", "tr"}])
1
"""
@spec group_by_category_and_lang([t()]) :: %{{String.t(), String.t()} => [t()]}
def group_by_category_and_lang(contents) do
Enum.reduce(contents, %{}, fn content, acc ->
lang_prefix = content.meta["lang_prefix"] || ""
Enum.reduce(content.categories, acc, fn cat, inner_acc ->
Map.update(inner_acc, {cat, lang_prefix}, [content], &[content | &1])
end)
end)
|> Map.new(fn {k, v} -> {k, Enum.reverse(v)} end)
end
@doc """
Parses a raw string containing YAML front matter and Markdown body.
The string must have front matter delimited by `---` lines at the top.
## Examples
iex> {:ok, content} = Sayfa.Content.parse("---\\ntitle: Hello\\n---\\n# World")
iex> content.title
"Hello"
"""
@spec parse(String.t()) :: {:ok, t()} | {:error, term()}
def parse(raw_string) when is_binary(raw_string) do
with {:ok, front_matter, body_markdown} <- split_front_matter(raw_string),
{:ok, html} <- Sayfa.Markdown.render(body_markdown) do
build_content(front_matter, html)
end
end
@doc """
Parses a raw string, raising on error.
## Examples
iex> content = Sayfa.Content.parse!("---\\ntitle: Hello\\n---\\n# World")
iex> content.title
"Hello"
"""
@spec parse!(String.t()) :: t()
def parse!(raw_string) do
case parse(raw_string) do
{:ok, content} -> content
{:error, reason} -> raise "Content parsing failed: #{inspect(reason)}"
end
end
@doc """
Reads a file and returns a `Sayfa.Content.Raw` struct without rendering Markdown.
This is used by the builder to allow hooks to modify the raw content
before Markdown rendering.
## Examples
{:ok, raw} = Sayfa.Content.parse_raw_file("content/posts/hello.md")
raw.front_matter["title"]
#=> "Hello"
"""
@spec parse_raw_file(String.t()) :: {:ok, Raw.t()} | {:error, term()}
def parse_raw_file(file_path) do
case File.read(file_path) do
{:ok, raw_string} ->
with {:ok, front_matter, body_markdown} <- split_front_matter(raw_string) do
{:ok,
%Raw{
path: file_path,
front_matter: front_matter,
body_markdown: body_markdown,
filename: Path.basename(file_path)
}}
end
{:error, reason} ->
{:error, {:file_read_error, file_path, reason}}
end
end
@doc """
Reads and parses a content file from disk.
## Examples
Sayfa.Content.parse_file("content/posts/2024-01-15-hello.md")
"""
@spec parse_file(String.t()) :: {:ok, t()} | {:error, term()}
def parse_file(file_path) do
case File.read(file_path) do
{:ok, raw_string} ->
with {:ok, content} <- parse(raw_string) do
filename = Path.basename(file_path)
slug = content.slug || slug_from_filename(filename)
date = content.date || date_from_filename(filename)
{:ok, %{content | source_path: file_path, slug: slug, date: date}}
end
{:error, reason} ->
{:error, {:file_read_error, file_path, reason}}
end
end
@doc """
Transforms a `Sayfa.Content.Raw` struct into a `Sayfa.Content` struct.
Renders the Markdown body to HTML and maps front matter fields.
An optional config map may be passed as the second argument. The
`highlight_theme` key selects the syntax highlighting theme (default: `"github_light"`).
## Examples
iex> raw = %Sayfa.Content.Raw{
...> path: "content/posts/hello.md",
...> front_matter: %{"title" => "Hello"},
...> body_markdown: "# World"
...> }
iex> {:ok, content} = Sayfa.Content.from_raw(raw)
iex> content.title
"Hello"
"""
@spec from_raw(Raw.t()) :: {:ok, t()} | {:error, term()}
@spec from_raw(Raw.t(), map()) :: {:ok, t()} | {:error, term()}
def from_raw(%Raw{} = raw, config \\ %{}) do
theme = Map.get(config, :highlight_theme, "github_light")
with {:ok, html} <- Sayfa.Markdown.render(raw.body_markdown, theme) do
slug = slug_from_filename(raw.filename)
filename_date = date_from_filename(raw.filename)
case build_content(raw.front_matter, html) do
{:ok, content} ->
{:ok,
%{
content
| source_path: raw.path,
slug: content.slug || slug,
date: content.date || filename_date
}}
error ->
error
end
end
end
@doc """
Returns the URL path for a content item.
Combines `lang_prefix`, `url_prefix`, and `slug` from the content's metadata
to build the correct path. This is the single source of truth for content URLs.
## Examples
iex> content = %Sayfa.Content{title: "T", body: "", slug: "hello", meta: %{"url_prefix" => "posts", "lang_prefix" => ""}}
iex> Sayfa.Content.url(content)
"/posts/hello"
iex> content = %Sayfa.Content{title: "T", body: "", slug: "merhaba", meta: %{"url_prefix" => "posts", "lang_prefix" => "tr"}}
iex> Sayfa.Content.url(content)
"/tr/posts/merhaba"
iex> content = %Sayfa.Content{title: "T", body: "", slug: "index", meta: %{"url_prefix" => "", "lang_prefix" => ""}}
iex> Sayfa.Content.url(content)
"/"
"""
@spec url(t()) :: String.t()
def url(content) do
prefix = content.meta["url_prefix"] || ""
lang_prefix = content.meta["lang_prefix"] || ""
base =
case {prefix, content.slug} do
{"", "index"} -> "/"
{"", slug} -> "/#{slug}"
{p, "index"} -> "/#{p}"
{p, slug} -> "/#{p}/#{slug}"
end
case lang_prefix do
"" -> base
lp -> "/#{lp}#{base}"
end
end
@doc """
Generates a URL-friendly slug from a filename.
Strips date prefixes (e.g., `2024-01-15-`) and the `.md` extension.
## Examples
iex> Sayfa.Content.slug_from_filename("2024-01-15-hello-world.md")
"hello-world"
iex> Sayfa.Content.slug_from_filename("about.md")
"about"
iex> Sayfa.Content.slug_from_filename(nil)
nil
"""
@spec slug_from_filename(String.t() | nil) :: String.t() | nil
def slug_from_filename(nil), do: nil
def slug_from_filename(filename) do
filename
|> Path.rootname()
|> strip_date_prefix()
end
@doc """
Extracts a `Date` from a filename's `YYYY-MM-DD-` prefix.
Returns `nil` if the filename has no date prefix or is `nil`.
## Examples
iex> Sayfa.Content.date_from_filename("2024-01-15-hello-world.md")
~D[2024-01-15]
iex> Sayfa.Content.date_from_filename("about.md")
nil
iex> Sayfa.Content.date_from_filename(nil)
nil
"""
@spec date_from_filename(String.t() | nil) :: Date.t() | nil
def date_from_filename(nil), do: nil
def date_from_filename(filename) do
case Regex.run(~r/^(\d{4}-\d{2}-\d{2})-/, Path.rootname(filename)) do
[_, date_str] -> parse_date(date_str)
nil -> nil
end
end
# --- Private Functions ---
defp split_front_matter(raw_string) do
with [maybe_front_matter, body] <- split_on_delimiter(raw_string),
"---" <> yaml_string <- String.trim_leading(maybe_front_matter) do
parse_yaml_front_matter(yaml_string, body)
else
_ -> {:error, :missing_front_matter}
end
end
defp split_on_delimiter(raw_string) do
String.split(raw_string, ~r/\n---\s*\n/, parts: 2)
end
defp parse_yaml_front_matter(yaml_string, body) do
case YamlElixir.read_from_string(yaml_string) do
{:ok, front_matter} when is_map(front_matter) ->
{:ok, front_matter, String.trim(body)}
{:ok, _} ->
{:error, :invalid_front_matter}
{:error, reason} ->
{:error, {:yaml_parse_error, reason}}
end
end
defp build_content(front_matter, html) do
case Map.get(front_matter, "title") do
nil ->
{:error, :missing_title}
title ->
{known, meta} = split_known_fields(front_matter)
content = %__MODULE__{
title: title,
body: html,
date: parse_date(Map.get(known, "date")),
slug: Map.get(known, "slug"),
lang: parse_lang(Map.get(known, "lang")),
categories: List.wrap(Map.get(known, "categories", [])),
tags: List.wrap(Map.get(known, "tags", [])),
draft: Map.get(known, "draft", false),
meta: meta
}
{:ok, content}
end
end
defp split_known_fields(front_matter) do
Enum.split_with(front_matter, fn {key, _} -> key in @known_keys end)
|> then(fn {known_list, meta_list} ->
{Map.new(known_list), Map.new(meta_list)}
end)
end
defp parse_date(nil), do: nil
defp parse_date(%Date{} = date), do: date
defp parse_date(date_string) when is_binary(date_string) do
case Date.from_iso8601(date_string) do
{:ok, date} -> date
{:error, _} -> nil
end
end
defp parse_date(_), do: nil
defp parse_lang(nil), do: nil
defp parse_lang(lang) when is_atom(lang), do: lang
defp parse_lang(lang) when is_binary(lang), do: String.to_atom(lang)
defp strip_date_prefix(name) do
case Regex.run(~r/^\d{4}-\d{2}-\d{2}-(.+)$/, name) do
[_, slug] -> slug
nil -> name
end
end
end