Packages

phoenix_kit

1.7.75
1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib modules pages html_metadata.ex
Raw

lib/modules/pages/html_metadata.ex

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
<!-- METADATA
status: published
title: My Page Title
description: A brief description
slug: custom-url
author: John Doe
created_at: 2025-01-15T10:00:00Z
updated_at: 2025-01-15T10:00:00Z
-->
## 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 "<!-- METADATA"
@metadata_end "-->"
@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 = \"\"\"
...> <!-- METADATA
...> status: published
...> title: My Page
...> -->
...> # 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)
"<!-- METADATA\\nstatus: published\\n...\\n-->"
"""
@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 = "<!-- METADATA\\nstatus: draft\\n-->\\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 =~ "<!-- METADATA"
true
"""
@spec update_metadata(String.t(), metadata()) :: String.t()
def update_metadata(content, metadata) do
stripped_content = strip_metadata(content)
serialized = serialize(metadata)
serialized <> "\n\n" <> stripped_content
end
@doc """
Returns default metadata for new files.
## Examples
iex> metadata = HtmlMetadata.default_metadata()
iex> metadata.status
"draft"
"""
@spec default_metadata() :: metadata()
def default_metadata do
now = UtilsDate.utc_now()
%{
status: "draft",
title: nil,
description: nil,
slug: nil,
author: nil,
created_at: now,
updated_at: now
}
end
@doc """
Merges user-provided metadata with defaults.
Preserves custom fields that aren't in the standard set.
## Examples
iex> user_data = %{"status" => "published", "custom_field" => "value"}
iex> metadata = HtmlMetadata.merge_metadata(user_data, default_metadata())
iex> metadata.status
"published"
"""
@spec merge_metadata(map(), metadata()) :: metadata()
def merge_metadata(user_data, defaults) when is_map(user_data) do
parsed =
user_data
|> Enum.map(fn {key, value} ->
atom_key = to_atom_key(key)
{atom_key, parse_value(atom_key, value)}
end)
|> Enum.into(%{})
Map.merge(defaults, parsed)
end
# Private functions
defp to_atom_key(key) when is_atom(key), do: key
defp to_atom_key(key) when is_binary(key) do
String.to_existing_atom(key)
rescue
ArgumentError -> String.to_atom(key)
end
defp extract_metadata_block(content) do
# Match metadata block with regex
regex = ~r/#{Regex.escape(@metadata_start)}\s*\n(.*?)\n\s*#{Regex.escape(@metadata_end)}/s
case Regex.run(regex, content) do
[_full_match, yaml_content] -> {:ok, yaml_content}
nil -> :not_found
end
end
defp parse_metadata_yaml(yaml_content, original_content) do
case YamlElixir.read_from_string(yaml_content) do
{:ok, data} when is_map(data) ->
metadata = merge_metadata(data, default_metadata())
content_without_metadata = strip_metadata(original_content)
{:ok, metadata, content_without_metadata}
{:ok, _} ->
# Invalid YAML structure (not a map)
{:error, :no_metadata}
{:error, _reason} ->
# YAML parsing failed
{:error, :no_metadata}
end
end
defp format_value(%DateTime{} = dt) do
DateTime.to_iso8601(dt)
end
defp format_value(value) when is_binary(value) do
# Quote strings that contain special characters
if String.contains?(value, ["\n", ":", "#", "[", "]", "{", "}"]) do
"\"#{String.replace(value, "\"", "\\\"")}\""
else
value
end
end
defp format_value(value), do: to_string(value)
defp parse_value(:created_at, value) when is_binary(value) do
case DateTime.from_iso8601(value) do
{:ok, dt, _offset} -> dt
_ -> UtilsDate.utc_now()
end
end
defp parse_value(:updated_at, value) when is_binary(value) do
case DateTime.from_iso8601(value) do
{:ok, dt, _offset} -> dt
_ -> UtilsDate.utc_now()
end
end
defp parse_value(:status, value) when is_binary(value) do
if value in ["draft", "published", "archived"], do: value, else: "draft"
end
defp parse_value(_key, value), do: value
end