defmodule PhoenixKit.Modules.Pages.HtmlMetadata do
alias PhoenixKit.Utils.Date, as: UtilsDate
@moduledoc """
Metadata management for Pages module.
Handles parsing and serialization of metadata stored in HTML comment blocks
with YAML content.
## Format
## Fields
- `status` - `draft` | `published` | `archived` (default: `draft`)
- `title` - Page title (optional, defaults to filename)
- `description` - SEO description (optional)
- `slug` - Custom URL slug (optional)
- `author` - Content author (optional)
- `created_at` - ISO 8601 datetime (auto-generated)
- `updated_at` - ISO 8601 datetime (auto-updated)
"""
@type metadata :: %{
status: String.t(),
title: String.t() | nil,
description: String.t() | nil,
slug: String.t() | nil,
author: String.t() | nil,
created_at: DateTime.t(),
updated_at: DateTime.t()
}
@metadata_start ""
@doc """
Parses metadata from file content.
Searches for metadata block in first 20 lines (optimization),
then searches entire file if not found.
Returns metadata map and content without metadata block.
## Examples
iex> content = \"\"\"
...>
...> # Content
...> \"\"\"
iex> {:ok, metadata, content} = HtmlMetadata.parse(content)
iex> metadata.status
"published"
"""
@spec parse(String.t()) :: {:ok, metadata(), String.t()} | {:error, :no_metadata}
def parse(content) when is_binary(content) do
# Try first 20 lines (optimization)
lines = String.split(content, "\n")
first_20 = Enum.take(lines, 20) |> Enum.join("\n")
case extract_metadata_block(first_20) do
{:ok, yaml_content} ->
parse_metadata_yaml(yaml_content, content)
:not_found ->
# Search entire file
case extract_metadata_block(content) do
{:ok, yaml_content} ->
parse_metadata_yaml(yaml_content, content)
:not_found ->
{:error, :no_metadata}
end
end
end
@doc """
Serializes metadata to HTML comment format.
## Examples
iex> metadata = %{
...> status: "published",
...> title: "My Page",
...> created_at: ~U[2025-01-15 10:00:00Z],
...> updated_at: ~U[2025-01-15 10:00:00Z]
...> }
iex> HtmlMetadata.serialize(metadata)
""
"""
@spec serialize(metadata()) :: String.t()
def serialize(metadata) do
yaml_content =
metadata
|> Map.take([
:status,
:title,
:description,
:slug,
:author,
:created_at,
:updated_at
])
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Enum.map_join("\n", fn {key, value} ->
formatted_value = format_value(value)
"#{key}: #{formatted_value}"
end)
"""
#{@metadata_start}
#{yaml_content}
#{@metadata_end}
"""
end
@doc """
Strips metadata block from content.
Returns content without the metadata block.
## Examples
iex> content = "\\n\\n# Content"
iex> HtmlMetadata.strip_metadata(content)
"# Content"
"""
@spec strip_metadata(String.t()) :: String.t()
def strip_metadata(content) do
case extract_metadata_block(content) do
{:ok, _yaml_content} ->
# Remove the entire metadata block
content
|> String.replace(
~r/#{Regex.escape(@metadata_start)}.*?#{Regex.escape(@metadata_end)}/s,
""
)
|> String.trim()
:not_found ->
content
end
end
@doc """
Updates metadata in content.
If metadata exists, replaces it. If not, prepends it.
## Examples
iex> content = "# Content"
iex> metadata = default_metadata()
iex> updated = HtmlMetadata.update_metadata(content, metadata)
iex> updated =~ "