defmodule PhoenixKit.Modules.Pages.HtmlMetadata do @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 =~ "