Sayfa.Content (Sayfa v0.5.0)

Copy Markdown View Source

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 Article"
custom_field: "value"
---

Results in content.title == "My Article" and content.meta["custom_field"] == "value".

Summary

Functions

Filters contents by content type string.

Extracts a Date from a filename's YYYY-MM-DD- prefix.

Transforms a Sayfa.Content.Raw struct into a Sayfa.Content struct.

Groups contents by category.

Groups contents by {category, lang_prefix} tuple.

Groups contents by tag.

Groups contents by {tag, lang_prefix} tuple.

Parses a raw string containing YAML front matter and Markdown body.

Parses a raw string, raising on error.

Reads and parses a content file from disk.

Reads a file and returns a Sayfa.Content.Raw struct without rendering Markdown.

Returns the N most recent contents (sorted by date descending).

Generates a URL-friendly slug from a filename.

Sorts contents by date.

Returns the URL path for a content item.

Filters contents that have the given category.

Filters contents that have the given tag.

Types

t()

@type t() :: %Sayfa.Content{
  body: String.t(),
  categories: [String.t()],
  date: Date.t() | nil,
  draft: boolean(),
  lang: atom() | nil,
  meta: map(),
  slug: String.t() | nil,
  source_path: String.t() | nil,
  tags: [String.t()],
  title: String.t()
}

Functions

all_of_type(contents, type)

@spec all_of_type([t()], String.t()) :: [t()]

Filters contents by content type string.

Examples

iex> articles = [%Sayfa.Content{title: "A", body: "", meta: %{"content_type" => "articles"}},
...>             %Sayfa.Content{title: "B", body: "", meta: %{"content_type" => "pages"}}]
iex> Sayfa.Content.all_of_type(articles, "articles") |> length()
1

date_from_filename(filename)

@spec date_from_filename(String.t() | nil) :: Date.t() | nil

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

from_raw(raw, config \\ %{})

@spec from_raw(Sayfa.Content.Raw.t(), map()) :: {:ok, t()} | {:error, term()}

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/articles/hello.md",
...>   front_matter: %{"title" => "Hello"},
...>   body_markdown: "# World"
...> }
iex> {:ok, content} = Sayfa.Content.from_raw(raw)
iex> content.title
"Hello"

group_by_category(contents)

@spec group_by_category([t()]) :: %{required(String.t()) => [t()]}

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

group_by_category_and_lang(contents)

@spec group_by_category_and_lang([t()]) :: %{
  required({String.t(), String.t()}) => [t()]
}

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

group_by_tag(contents)

@spec group_by_tag([t()]) :: %{required(String.t()) => [t()]}

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

group_by_tag_and_lang(contents)

@spec group_by_tag_and_lang([t()]) :: %{required({String.t(), String.t()}) => [t()]}

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

parse(raw_string)

@spec parse(String.t()) :: {:ok, t()} | {:error, term()}

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"

parse!(raw_string)

@spec parse!(String.t()) :: t()

Parses a raw string, raising on error.

Examples

iex> content = Sayfa.Content.parse!("---\ntitle: Hello\n---\n# World")
iex> content.title
"Hello"

parse_file(file_path)

@spec parse_file(String.t()) :: {:ok, t()} | {:error, term()}

Reads and parses a content file from disk.

Examples

Sayfa.Content.parse_file("content/articles/2024-01-15-hello.md")

parse_raw_file(file_path)

@spec parse_raw_file(String.t()) :: {:ok, Sayfa.Content.Raw.t()} | {:error, term()}

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/articles/hello.md")
raw.front_matter["title"]
#=> "Hello"

recent(contents, n)

@spec recent([t()], pos_integer()) :: [t()]

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"]

slug_from_filename(filename)

@spec slug_from_filename(String.t() | nil) :: String.t() | nil

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

sort_by_date(contents, order \\ :desc)

@spec sort_by_date([t()], :asc | :desc) :: [t()]

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"

url(content)

@spec url(t()) :: String.t()

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" => "articles", "lang_prefix" => ""}}
iex> Sayfa.Content.url(content)
"/articles/hello"

iex> content = %Sayfa.Content{title: "T", body: "", slug: "merhaba", meta: %{"url_prefix" => "articles", "lang_prefix" => "tr"}}
iex> Sayfa.Content.url(content)
"/tr/articles/merhaba"

iex> content = %Sayfa.Content{title: "T", body: "", slug: "index", meta: %{"url_prefix" => "", "lang_prefix" => ""}}
iex> Sayfa.Content.url(content)
"/"

with_category(contents, category)

@spec with_category([t()], String.t()) :: [t()]

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

with_tag(contents, tag)

@spec with_tag([t()], String.t()) :: [t()]

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