Packages

phoenix_kit

1.7.49
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 metadata.ex
Raw

lib/modules/pages/metadata.ex

defmodule PhoenixKit.Modules.Pages.Metadata do
@moduledoc """
Metadata helpers for .phk (PhoenixKit) pages posts.
Metadata is stored as a simple key-value format at the top of the file:
```
---
slug: home
title: Welcome
status: published
published_at: 2025-10-29T18:48:00Z
---
Content goes here...
```
"""
alias PhoenixKit.Utils.Date, as: UtilsDate
@type metadata :: %{
status: String.t(),
title: String.t(),
description: String.t() | nil,
slug: String.t(),
published_at: String.t(),
featured_image_id: String.t() | nil,
created_at: String.t() | nil,
created_by_id: String.t() | nil,
created_by_email: String.t() | nil,
updated_by_id: String.t() | nil,
updated_by_email: String.t() | nil,
# Version fields
version: integer() | nil,
version_created_at: String.t() | nil,
version_created_from: integer() | nil,
# Per-post version access control (allows public access to older versions)
allow_version_access: boolean() | nil,
# Per-language URL slug (optional, defaults to directory slug)
url_slug: String.t() | nil,
# Previous URL slugs for 301 redirects (list of old slugs)
previous_url_slugs: [String.t()] | nil,
# Primary language for this post (controls versioning/status inheritance)
# If nil, uses the global content language setting
primary_language: String.t() | nil
}
@doc """
Parses .phk content, extracting metadata from frontmatter and returning the content.
Title is extracted from the markdown content itself (first H1 heading).
"""
@spec parse_with_content(String.t()) :: {:ok, metadata(), String.t()}
def parse_with_content(content) do
case extract_frontmatter(content) do
{:ok, metadata, body_content} ->
# Extract title from content
title = extract_title_from_content(body_content)
metadata_with_title = Map.put(metadata, :title, title)
{:ok, metadata_with_title, body_content}
{:error, _} ->
# Fallback: try old XML format for backwards compatibility
metadata = extract_metadata_from_xml(content)
title = extract_title_from_content(content)
metadata_with_title = Map.put(metadata, :title, title)
{:ok, metadata_with_title, content}
end
end
@doc """
Serializes metadata as YAML-style frontmatter.
Note: Title is NOT saved in frontmatter - it's extracted from content.
"""
@spec serialize(metadata()) :: String.t()
def serialize(metadata) do
optional_lines =
[
:featured_image_id,
:created_at,
:created_by_id,
:created_by_email,
:updated_by_id,
:updated_by_email,
# Version fields (optional for backward compatibility)
:version,
:version_created_at,
:version_created_from,
:allow_version_access,
# Per-language URL slug
:url_slug,
# Primary language for this post
:primary_language
]
|> Enum.flat_map(fn key ->
case metadata_value(metadata, key) do
nil -> []
"" -> []
# Handle boolean values for allow_version_access
true -> ["#{Atom.to_string(key)}: true"]
false -> ["#{Atom.to_string(key)}: false"]
value -> ["#{Atom.to_string(key)}: #{value}"]
end
end)
# Handle previous_url_slugs as a comma-separated list
previous_slugs_line =
case metadata_value(metadata, :previous_url_slugs) do
nil -> []
[] -> []
slugs when is_list(slugs) -> ["previous_url_slugs: #{Enum.join(slugs, ",")}"]
_ -> []
end
optional_lines = optional_lines ++ previous_slugs_line
lines =
[
"slug: #{metadata.slug}",
"status: #{metadata.status}",
"published_at: #{metadata.published_at}"
]
|> Enum.concat(optional_lines)
|> Enum.join("\n")
"""
---
#{lines}
---
"""
end
@doc """
Returns default metadata for a new post.
New posts default to version 1.
"""
@spec default_metadata() :: metadata()
def default_metadata do
now = UtilsDate.utc_now()
%{
status: "draft",
title: "",
description: nil,
slug: "",
published_at: DateTime.to_iso8601(now),
featured_image_id: nil,
created_at: nil,
created_by_id: nil,
created_by_email: nil,
updated_by_id: nil,
updated_by_email: nil,
# Version fields - new posts start at v1
version: 1,
version_created_at: DateTime.to_iso8601(now),
version_created_from: nil,
# Per-post version access - defaults to false (only published version accessible)
allow_version_access: false,
# Per-language URL slug - nil means use directory slug
url_slug: nil,
# Previous URL slugs for 301 redirects
previous_url_slugs: nil,
# Primary language - nil means use global content language setting
primary_language: nil
}
end
@doc """
Extracts title from markdown content.
Looks for the first H1 heading (# Title) within the first few lines.
Falls back to the first line if no H1 found.
"""
@spec extract_title_from_content(String.t()) :: String.t()
def extract_title_from_content(content) when is_binary(content) do
content
|> String.trim()
|> do_extract_title()
end
def extract_title_from_content(_), do: "Untitled"
defp do_extract_title(""), do: "Untitled"
defp do_extract_title(content) do
content
|> extract_title_from_lines()
|> case do
"Untitled" ->
extract_title_from_components(content) || "Untitled"
title ->
title
end
end
defp extract_title_from_lines(""), do: "Untitled"
defp extract_title_from_lines(content) do
lines =
content
|> extract_candidate_lines()
|> Enum.take(15)
# Look for first H1 heading (# Title)
h1_line =
Enum.find(lines, fn line ->
String.starts_with?(line, "# ") and String.length(line) > 2
end)
cond do
h1_line != nil ->
h1_line
|> String.trim_leading("# ")
|> String.trim()
not Enum.empty?(lines) ->
# Fallback to first non-empty line
List.first(lines)
|> String.slice(0, 100)
true ->
"Untitled"
end
end
defp extract_candidate_lines(content) do
{lines, _depth} =
content
|> String.split("\n")
|> Enum.reduce({[], 0}, fn raw_line, {acc, depth} ->
line = String.trim(raw_line)
cond do
line == "" and depth == 0 ->
{acc, depth}
component_self_closing?(line) ->
{acc, depth}
component_open?(line) ->
{acc, depth + 1}
depth > 0 and multiline_self_close?(raw_line) ->
{acc, max(depth - 1, 0)}
component_close?(line) and depth > 0 ->
{acc, max(depth - 1, 0)}
depth > 0 ->
{acc, depth}
true ->
{[line | acc], depth}
end
end)
lines
|> Enum.reverse()
|> Enum.reject(&(&1 == ""))
end
defp component_open?(line) do
String.starts_with?(line, "<") and
not String.starts_with?(line, "</") and
Regex.match?(~r/^<[A-Z][\w-]*/, line)
end
defp component_close?(line) do
Regex.match?(~r{^</[A-Z][\w-]*>}, line)
end
defp component_self_closing?(line) do
component_open?(line) and String.ends_with?(line, "/>")
end
defp multiline_self_close?(line) do
line
|> String.trim()
|> case do
"/>" -> true
">" -> false
other -> String.ends_with?(other, "/>")
end
end
defp extract_title_from_components(content) do
component_title(content, "Headline") ||
component_attribute(content, "Hero", "title") ||
component_title(content, "Title")
end
defp component_title(content, tag) do
regex = ~r/<#{tag}\b[^>]*>(.*?)<\/#{tag}>/is
case Regex.run(regex, content, capture: :all_but_first) do
[inner | _] -> sanitize_component_text(inner)
_ -> nil
end
end
defp component_attribute(content, tag, attr) do
regex = ~r/<#{tag}\b[^>]*#{attr}="([^"]+)"[^>]*>/i
case Regex.run(regex, content, capture: :all_but_first) do
[value | _] -> sanitize_component_text(value)
_ -> nil
end
end
defp sanitize_component_text(text) do
text
|> String.trim()
|> String.replace(~r/<[^>]+>/, "")
|> String.replace(~r/\s+/, " ")
|> String.trim()
|> case do
"" -> nil
cleaned -> String.slice(cleaned, 0, 100)
end
end
# Extract metadata from YAML-style frontmatter
defp extract_frontmatter(content) do
case Regex.run(~r/^---\n(.*?)\n---\n(.*)$/s, content) do
[_, frontmatter, body] ->
metadata = parse_frontmatter_lines(frontmatter)
{:ok, metadata, String.trim(body)}
_ ->
{:error, :no_frontmatter}
end
end
defp parse_frontmatter_lines(frontmatter) do
default = default_metadata()
lines =
frontmatter
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
metadata =
Enum.reduce(lines, %{}, fn line, acc ->
case String.split(line, ":", parts: 2) do
[key, value] ->
Map.put(acc, String.trim(key), String.trim(value))
_ ->
acc
end
end)
metadata_map = %{
# Title is extracted from content, not from frontmatter
# But we keep this for backward compatibility with old files
title: Map.get(metadata, "title", default.title),
status: Map.get(metadata, "status", default.status),
slug: Map.get(metadata, "slug", default.slug),
published_at: Map.get(metadata, "published_at", default.published_at),
featured_image_id: Map.get(metadata, "featured_image_id", default.featured_image_id),
description: Map.get(metadata, "description"),
created_at: Map.get(metadata, "created_at", default.created_at),
created_by_id: Map.get(metadata, "created_by_id", default.created_by_id),
created_by_email: Map.get(metadata, "created_by_email", default.created_by_email),
updated_by_id: Map.get(metadata, "updated_by_id", default.updated_by_id),
updated_by_email: Map.get(metadata, "updated_by_email", default.updated_by_email),
# Version fields - parse with defaults for backward compatibility
version: parse_integer(Map.get(metadata, "version"), default.version),
version_created_at: Map.get(metadata, "version_created_at", default.version_created_at),
version_created_from: parse_integer(Map.get(metadata, "version_created_from"), nil),
# Per-post version access control
allow_version_access:
parse_boolean(Map.get(metadata, "allow_version_access"), default.allow_version_access),
# Per-language URL slug (optional)
url_slug: parse_url_slug(Map.get(metadata, "url_slug")),
# Previous URL slugs for 301 redirects (comma-separated list)
previous_url_slugs: parse_previous_url_slugs(Map.get(metadata, "previous_url_slugs")),
# Primary language for this post
primary_language: Map.get(metadata, "primary_language")
}
# For backward compatibility, also read legacy is_live field if present
# This allows migration to detect old posts
legacy_is_live = parse_boolean(Map.get(metadata, "is_live"), nil)
if legacy_is_live != nil do
Map.put(metadata_map, :legacy_is_live, legacy_is_live)
else
metadata_map
end
end
# Parse integer from string, returning default if nil or invalid
defp parse_integer(nil, default), do: default
defp parse_integer("", default), do: default
defp parse_integer(value, default) when is_binary(value) do
case Integer.parse(value) do
{int, _} -> int
:error -> default
end
end
defp parse_integer(value, _default) when is_integer(value), do: value
defp parse_integer(_, default), do: default
# Parse boolean from string
defp parse_boolean(nil, default), do: default
defp parse_boolean("true", _default), do: true
defp parse_boolean("false", _default), do: false
defp parse_boolean(value, _default) when is_boolean(value), do: value
defp parse_boolean(_, default), do: default
# Parse url_slug - returns nil if empty
defp parse_url_slug(nil), do: nil
defp parse_url_slug(""), do: nil
defp parse_url_slug(value) when is_binary(value), do: String.trim(value)
# Parse previous_url_slugs as comma-separated list
defp parse_previous_url_slugs(nil), do: nil
defp parse_previous_url_slugs(""), do: nil
defp parse_previous_url_slugs(value) when is_binary(value) do
value
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> case do
[] -> nil
slugs -> slugs
end
end
defp parse_previous_url_slugs(value) when is_list(value), do: value
# Extract metadata from <Page> element attributes (legacy XML format)
defp extract_metadata_from_xml(content) do
default = default_metadata()
# Simple regex-based extraction (for now)
title = extract_attribute(content, "title") || default.title
status = extract_attribute(content, "status") || default.status
slug = extract_attribute(content, "slug") || default.slug
published_at = extract_attribute(content, "published_at") || default.published_at
description = extract_attribute(content, "description")
%{
title: title,
status: status,
slug: slug,
published_at: published_at,
description: description,
created_at: nil,
created_by_id: nil,
created_by_email: nil,
updated_by_id: nil,
updated_by_email: nil,
# Legacy posts default to v1 (will be migrated)
version: 1,
version_created_at: nil,
version_created_from: nil,
allow_version_access: false,
url_slug: nil,
previous_url_slugs: nil,
primary_language: nil
}
end
defp extract_attribute(content, attr_name) do
regex = ~r/<Page[^>]*\s#{attr_name}="([^"]*)"/
case Regex.run(regex, content) do
[_, value] -> value
_ -> nil
end
end
defp metadata_value(metadata, key) do
Map.get(metadata, key) || Map.get(metadata, Atom.to_string(key))
end
end