Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
404 changes: 144 additions & 260 deletions guides/phk_blogging_format.md

Large diffs are not rendered by default.

161 changes: 152 additions & 9 deletions lib/phoenix_kit/blogging/renderer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

@cache_name :blog_posts
@cache_version "v1"
@component_regex ~r/<(Image|Hero|CTA|Headline|Subheadline)\s+([^>]*?)\/>/s

@doc """
Renders a post's markdown content to HTML.
Expand Down Expand Up @@ -40,7 +41,10 @@
end

@doc """
Renders markdown content directly without caching.
Renders markdown or .phk content directly without caching.

Automatically detects .phk XML format and routes to PageBuilder.
Falls back to Earmark markdown rendering for non-XML content.

## Examples

Expand All @@ -50,22 +54,161 @@
def render_markdown(content) when is_binary(content) do
{time, result} =
:timer.tc(fn ->
case Earmark.as_html(content, %Earmark.Options{
code_class_prefix: "language-",
smartypants: true,
gfm: true
}) do
{:ok, html, _warnings} -> html
{:error, _html, _errors} -> "<p>Error rendering markdown</p>"
cond do
is_pure_phk_content?(content) ->
render_phk_content(content)

has_embedded_components?(content) ->
render_mixed_content(content)

true ->
render_earmark_markdown(content)
end
end)

Logger.debug("Markdown render time: #{time}μs", content_size: byte_size(content))
Logger.debug("Content render time: #{time}μs", content_size: byte_size(content))
result
end

def render_markdown(_), do: ""

# Detect if content is pure .phk XML format (starts with <Page> or <Hero>)
defp is_pure_phk_content?(content) do
trimmed = String.trim(content)
String.starts_with?(trimmed, "<Page") || String.starts_with?(trimmed, "<Hero")
end

# Detect if markdown content has embedded XML components
defp has_embedded_components?(content) do
String.contains?(content, "<Image ") ||
String.contains?(content, "<Hero ") ||
String.contains?(content, "<CTA ")
end

# Render .phk content using PageBuilder
defp render_phk_content(content) do
case PhoenixKitWeb.Live.Modules.Blogging.PageBuilder.render_content(content) do

Check warning on line 90 in lib/phoenix_kit/blogging/renderer.ex

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Nested modules could be aliased at the top of the invoking module.
{:ok, html} ->
# Convert Phoenix.LiveView.Rendered to string
html
|> Phoenix.HTML.Safe.to_iodata()

Check warning on line 94 in lib/phoenix_kit/blogging/renderer.ex

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Nested modules could be aliased at the top of the invoking module.
|> IO.iodata_to_binary()

{:error, reason} ->
Logger.warning("PHK render error: #{inspect(reason)}")
"<p>Error rendering page content</p>"
end
end

# Render markdown using Earmark
defp render_earmark_markdown(content) do
content = normalize_markdown(content)

case Earmark.as_html(content, %Earmark.Options{
code_class_prefix: "language-",
smartypants: true,
gfm: true,
escape: false
}) do
{:ok, html, _warnings} -> html
{:error, _html, _errors} -> "<p>Error rendering markdown</p>"
end
end

defp normalize_markdown(content) when is_binary(content) do
# Remove leading indentation before Markdown headings (e.g., " ## Title")
Regex.replace(~r/^[ \t]+(?=#)/m, content, "")
end

defp normalize_markdown(content), do: content

# Render mixed content: markdown with embedded XML components
defp render_mixed_content(content) when content == "" or is_nil(content), do: ""

defp render_mixed_content(content) do
content
|> render_mixed_segments([])
|> Enum.reverse()
|> Enum.join()
end

defp render_mixed_segments("", acc), do: acc

defp render_mixed_segments(content, acc) do
case Regex.run(@component_regex, content, return: :index) do
nil ->
[render_earmark_markdown(content) | acc]

[{match_start, match_len}, {tag_start, tag_len}, {attrs_start, attrs_len}] ->
before = binary_part(content, 0, match_start)
after_index = match_start + match_len
rest_content = binary_part(content, after_index, byte_size(content) - after_index)
tag = binary_part(content, tag_start, tag_len)
attrs = binary_part(content, attrs_start, attrs_len)

acc =
acc
|> maybe_add_markdown(before)
|> add_component(tag, attrs)

render_mixed_segments(rest_content, acc)
end
end

defp maybe_add_markdown(acc, ""), do: acc

defp maybe_add_markdown(acc, text) do
[render_earmark_markdown(text) | acc]
end

defp add_component(acc, tag, attrs) do
[render_inline_component(tag, attrs) | acc]
end

# Render individual inline component
defp render_inline_component("Image", attrs) do
# Parse attributes
attr_map = parse_xml_attributes(attrs)

assigns = %{
__changed__: nil,
attributes: attr_map,
variant: "default",
content: nil,
children: []
}

case PhoenixKitWeb.Components.Blogging.Image.render(assigns) do

Check warning on line 181 in lib/phoenix_kit/blogging/renderer.ex

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Nested modules could be aliased at the top of the invoking module.
rendered when is_struct(rendered) ->
rendered
|> Phoenix.HTML.Safe.to_iodata()

Check warning on line 184 in lib/phoenix_kit/blogging/renderer.ex

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Nested modules could be aliased at the top of the invoking module.
|> IO.iodata_to_binary()

html when is_binary(html) ->
html
end
rescue
error ->
Logger.warning("Error rendering Image component: #{inspect(error)}")
"<div class='error'>Error rendering image</div>"
end

defp render_inline_component(tag, _attrs) do
# Fallback for other components
Logger.warning("Inline component not supported yet: #{tag}")
""
end

# Parse XML attribute string into a map
defp parse_xml_attributes(attrs_string) do
# Match key="value" or key='value' patterns
attr_regex = ~r/(\w+)=["']([^"']+)["']/

Regex.scan(attr_regex, attrs_string)
|> Enum.map(fn [_, key, value] -> {key, value} end)
|> Enum.into(%{})
end

@doc """
Invalidates cache for a specific post.

Expand Down
86 changes: 85 additions & 1 deletion lib/phoenix_kit/storage.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
alias PhoenixKit.Storage.FileInstance
alias PhoenixKit.Storage.FileLocation
alias PhoenixKit.Storage.Manager
# NOTE: Temporary helper for blogging component system.
# The dedicated storage/media APIs under development should replace this fallback once available.
alias PhoenixKit.Storage.URLSigner
alias PhoenixKit.Storage.VariantGenerator

# ===== BUCKETS =====
Expand Down Expand Up @@ -720,13 +723,94 @@
# Look up the actual file path from file_instances where "original" variant is stored
case get_file_instance_by_name(file.id, "original") do
%PhoenixKit.Storage.FileInstance{file_name: file_path} ->
Manager.public_url(file_path)
Manager.public_url(file_path) || signed_file_url(file.id, "original")

nil ->
nil
end
end

@doc """
Gets a public URL for a specific file variant.

## Variants

For images: "original", "thumbnail", "small", "medium", "large"
For videos: "original", "360p", "720p", "1080p", "video_thumbnail"

## Examples

iex> get_public_url_by_variant(file, "thumbnail")
"https://cdn.example.com/12/a1/a1b2c3d4e5f6/a1b2c3d4e5f6_thumbnail.jpg"

iex> get_public_url_by_variant(file, "medium")
"https://cdn.example.com/12/a1/a1b2c3d4e5f6/a1b2c3d4e5f6_medium.jpg"

"""
def get_public_url_by_variant(%PhoenixKit.Storage.File{} = file, variant_name) do
case get_file_instance_by_name(file.id, variant_name) do
%PhoenixKit.Storage.FileInstance{file_name: file_path} ->
Manager.public_url(file_path) || signed_file_url(file.id, variant_name)

nil ->
# Fallback to original if variant doesn't exist
get_public_url(file)
end
end

@doc """
Gets a public URL for a file by file ID.

Convenience function that fetches the file and returns its URL.

## Examples

iex> get_public_url_by_id("018e3c4a-9f6b-7890-abcd-ef1234567890")
"https://cdn.example.com/12/a1/a1b2c3d4e5f6/a1b2c3d4e5f6_original.jpg"

iex> get_public_url_by_id("invalid-id")
nil

"""
def get_public_url_by_id(file_id) when is_binary(file_id) do
case get_file(file_id) do
%PhoenixKit.Storage.File{} = file ->
get_public_url(file)

nil ->
nil
end
end

def get_public_url_by_id(_), do: nil

@doc """
Gets a public URL for a specific file variant by file ID.

## Examples

iex> get_public_url_by_id("018e3c4a-9f6b-7890-abcd-ef1234567890", "thumbnail")
"https://cdn.example.com/12/a1/a1b2c3d4e5f6/a1b2c3d4e5f6_thumbnail.jpg"

"""
def get_public_url_by_id(file_id, variant_name) when is_binary(file_id) do
case get_file(file_id) do
%PhoenixKit.Storage.File{} = file ->
get_public_url_by_variant(file, variant_name)

nil ->
nil
end
end

defp signed_file_url(file_id, variant_name) do
try do

Check warning on line 807 in lib/phoenix_kit/storage.ex

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Prefer using an implicit `try` rather than explicit `try`.
URLSigner.signed_url(file_id, variant_name, locale: :none)
rescue
_ -> nil
end
end

@doc """
Checks if a file exists in storage.
"""
Expand Down
7 changes: 5 additions & 2 deletions lib/phoenix_kit/storage/url_signer.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
defmodule PhoenixKit.Storage.URLSigner do
# NOTE: Temporarily supporting the blogging component system until the storage/media team ships their replacement.
import Bitwise

alias PhoenixKit.Utils.Routes
Expand Down Expand Up @@ -50,10 +51,12 @@ defmodule PhoenixKit.Storage.URLSigner do
iex> PhoenixKit.Storage.URLSigner.signed_url("018e3c4a-9f6b-7890", "thumbnail")
"/phoenix_kit/file/018e3c4a-9f6b-7890/thumbnail/abc1" # With default prefix
"""
def signed_url(file_id, instance_name) when is_binary(file_id) and is_binary(instance_name) do
def signed_url(file_id, instance_name, opts \\ [])
when is_binary(file_id) and is_binary(instance_name) do
token = generate_token(file_id, instance_name)
file_path = "/file/#{file_id}/#{instance_name}/#{token}"
Routes.path(file_path)
locale_option = Keyword.get(opts, :locale, :none)
Routes.path(file_path, locale: locale_option)
end

@doc """
Expand Down
29 changes: 19 additions & 10 deletions lib/phoenix_kit/utils/routes.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,25 @@ defmodule PhoenixKit.Utils.Routes do
PhoenixKit prefix configured in the application.
"""

# NOTE: Locale override logic below exists for the temporary blogging component system integration.
# Switch to the upcoming media/storage helpers once they land.
def path(url_path, opts \\ []) do
if String.starts_with?(url_path, "/") do
url_prefix = PhoenixKit.Config.get_url_prefix()
base_path = if url_prefix === "/", do: "", else: url_prefix

# Get locale from options, process dictionary, or Gettext
locale =
opts[:locale] ||
Process.get(:phoenix_kit_current_locale) ||
Gettext.get_locale(PhoenixKitWeb.Gettext)

base_path = if url_prefix === "/", do: "", else: url_prefix
case Keyword.fetch(opts, :locale) do
{:ok, :none} -> :none
{:ok, nil} -> determine_locale()
{:ok, locale_value} -> locale_value
:error -> determine_locale()
end

if locale == "en" do
"#{base_path}#{url_path}"
else
"#{base_path}/#{locale}#{url_path}"
case locale do
:none -> "#{base_path}#{url_path}"
"en" -> "#{base_path}#{url_path}"
locale_value -> "#{base_path}/#{locale_value}#{url_path}"
end
else
raise """
Expand All @@ -30,6 +33,12 @@ defmodule PhoenixKit.Utils.Routes do
end
end

defp determine_locale do
Process.get(:phoenix_kit_current_locale) ||
Gettext.get_locale(PhoenixKitWeb.Gettext) ||
"en"
end

@doc """
Returns a locale-aware path using locale from assigns.

Expand Down
Loading
Loading