diff --git a/guides/phk_blogging_format.md b/guides/phk_blogging_format.md index 17b028e35..d3884d0c9 100644 --- a/guides/phk_blogging_format.md +++ b/guides/phk_blogging_format.md @@ -1,331 +1,215 @@ -# .phk (PhoenixKit) Blogging Format Guide +# .phk Blogging Format (Current Implementation) -## Overview +PhoenixKit’s blogging module stores every post as a `.phk` file. Each file is a **YAML frontmatter block** followed by regular **Markdown**. Authors can sprinkle inline PHK components (for example ``) anywhere inside the Markdown, but there is no longer a root `` wrapper or XML layout. -The `.phk` format is PhoenixKit's component-based post markup language. It allows you to create blog posts with structured content that can be styled with different design variants **without changing the content**. - -## File Location - -Blog posts are stored at: -``` -priv/blogging////en.phk -``` - -Example: -``` -priv/blogging/blog/2025-10-28/14:30/en.phk -``` +--- -## Basic Structure +## Where posts live -Every `.phk` blog post starts with a root `` element containing metadata: +Posts are kept under `priv/blogging//…` inside your host application. The folder layout depends on the blog’s storage mode: -```xml - - - -``` +| Blog mode | Path template | Example | +|-----------|---------------|---------| +| Timestamp (legacy/default) | `priv/blogging////.phk` | `priv/blogging/news/2025-01-15/09:30/en.phk` | +| Slug (new) | `priv/blogging///.phk` | `priv/blogging/docs/getting-started/en.phk` | -### Post Attributes +Each language/localisation of a post gets its own `.phk` file in the same directory. -- `slug` - URL-friendly identifier (e.g., "home", "about-us") -- `title` - Post title for SEO and display -- `status` - Publication status: `draft`, `published`, or `archived` -- `published_at` - ISO8601 timestamp (changes folder location when updated) -- `description` (optional) - Meta description for SEO +--- -## Available Components +## File anatomy -### Hero Component +```yaml +--- +slug: simple-version-original-size +title: Simple Version (Original Size) +status: draft # draft | published | archived +published_at: 2025-11-07T22:42:00Z +created_at: 2025-11-07T22:42:17.231679Z +created_by_email: max@don.ee +updated_by_email: max@don.ee +--- -The Hero section is typically the first visual element users see. It comes with **3 design variants** that can be switched without changing content. +# Heading 1 -#### Variant 1: Split Image (`variant="split-image"`) +Standard **Markdown** lives here. Use `##` for subheadings, `-` for bullet lists, code fences, etc. -**Best for:** Landing pages, product showcases, marketing pages +Inline PHK components can appear anywhere in the Markdown body: -**Features:** -- Content on the left side -- Large image on the right side -- Gradient background (primary/secondary colors) -- Responsive grid layout +Screenshot -**Example:** -```xml - - Build Your SaaS Faster - Start shipping in days, not months - Start Free Trial - Learn More - Dashboard Preview - +Continue writing Markdown below the component. ``` -**Result:** -- 2-column layout on desktop (content | image) -- Stacked layout on mobile -- Eye-catching gradient background -- Primary and secondary CTAs side-by-side - ---- - -#### Variant 2: Centered (`variant="centered"`) - -**Best for:** Welcome pages, announcements, simple messaging +### Frontmatter keys -**Features:** -- All content centered -- Neutral background -- Maximum width container (4xl) -- Generous spacing - -**Example:** -```xml - - Welcome to PhoenixKit - Everything you need to build modern web applications - Get Started - Read Documentation - PhoenixKit Logo - -``` +Only a subset is required, but the blogging UI will populate everything shown above. Notable keys: -**Result:** -- Single centered column -- Clean, professional look -- Works great with or without images -- Text-focused presentation +- `slug` – used for slug-mode directories and public URLs. +- `title` – displayed in admin tables and public templates. +- `status` – controls whether the post is discoverable publicly (`published` only). +- `published_at` – timestamp used for ordering and for timestamp-mode folders. +- `created_by_* / updated_by_*` – audit metadata; the editor manages these. --- -#### Variant 3: Minimal (`variant="minimal"`) - -**Best for:** Documentation, blog posts, content pages - -**Features:** -- Simple, distraction-free design -- Smaller padding -- Maximum width container (3xl) -- Text-only focused (images optional) +## Markdown + inline PHK components -**Example:** -```xml - - Getting Started Guide - Learn how to build with PhoenixKit in 5 minutes - Start Reading - -``` - -**Result:** -- Compact, focused layout -- Quick to scan -- No distracting backgrounds -- Perfect for content consumption +After the frontmatter, everything is standard Markdown. The renderer automatically: ---- +1. Runs Markdown through Earmark (GitHub-flavoured Markdown). +2. Scans for inline PHK components (``, ``, ``, ``, ``). +3. Renders those components with Phoenix components before returning HTML. -### Child Components +This means you can drop components alongside text: -These components work inside Hero (and other container components): +```markdown +You can mix **bold text** and inline components: -#### Headline -```xml -Your Main Message Here -``` -- Renders as large, bold text (4xl to 6xl) -- Responsive sizing -- Base content color +Start Free Trial -#### Subheadline -```xml -Supporting description or value proposition -``` -- Renders as medium text (lg to xl) -- Slightly muted color (70% opacity) -- Good for explanations - -#### CTA (Call-to-Action) -```xml -Button Text -Secondary Button +- Bullet one +- Bullet two ``` -**Attributes:** -- `primary` - Set to `"true"` for primary styling (default: `"false"`) -- `action` - URL or anchor link +### Supported inline components -**Styling:** -- Primary: Bold, colored button (btn-primary) -- Secondary: Outlined button (btn-outline) +| Component | Notes | +|-----------|-------| +| `` | Works with either `src="/path/to/file.jpg"` or `file_id="…"`. Optional `file_variant="thumbnail" | "small" | "medium" | "large"` picks a specific variant from PhoenixKit Storage. The renderer now always returns the natural dimensions; add your own `class` if you want to constrain width. | +| `` | A layout block that can wrap ``, ``, ``, ``. Use sparingly inside Markdown (usually near the top). | +| `` | Renders a hero-style heading. | +| `` | Medium-sized supporting text. | +| `Label` | Button styled by the admin theme. | -#### Image -```xml -Descriptive text -``` +Additional components can be introduced by adding Phoenix components under `lib/phoenix_kit_web/components/blogging/` and registering them in the PageBuilder renderer. -**Attributes:** -- `src` - Image path (relative or absolute) -- `alt` - Accessibility description +--- -**Features:** -- Lazy loading enabled -- Rounded corners -- Drop shadow -- Responsive sizing +## Storage integration & variants ---- +When an `` references `file_id="…"`, the renderer calls `PhoenixKit.Storage.get_public_url_by_id/2`. The storage layer: -## Dynamic Data Placeholders +1. Looks for a matching file + variant (`original`, `thumbnail`, `small`, `medium`, `large`, etc.). +2. Returns the provider’s public URL if available (S3, R2, CDN…). +3. Falls back to PhoenixKit’s signed `/phoenix_kit/file/:id/:variant/:token` route for local/dev setups. -Use `{{variable}}` syntax to inject dynamic content: +If a variant does not exist yet (for example someone references `medium` before the variant generator runs), the renderer falls back to `original`. The `` component now *always* renders the file at its natural size; supply your own classes (e.g. `class="w-full"`) if you need to stretch or constraint it. -```xml -Welcome back, {{user.name}} -You have {{stats.active_projects}} active projects -``` +--- -**Nested values** are supported: -- `{{user.name}}` → accesses `assigns.user.name` -- `{{stats.total_users}}` → accesses `assigns.stats.total_users` -- `{{framework}}` → accesses `assigns.framework` - -**Preview mode** provides sample data: -```elixir -%{ - user: %{name: "Preview User", greeting_time: "Today"}, - stats: %{total_users: "1,000", active_projects: 5}, - framework: "Phoenix" -} -``` +## Complete example +```yaml +--- +slug: product-updates-oct-2025 +title: Product Updates – October 2025 +status: published +published_at: 2025-10-31T09:00:00Z --- -## Switching Design Variants +# October Highlights -The power of `.phk` is that you can **change the entire design without touching content**. Just change the `variant` attribute: +Thanks for building with PhoenixKit! Here are the highlights from this month. -**Before (Split Image):** -```xml - Build Your SaaS Faster - Start shipping in days, not months - Get Started - -``` - -**After (Centered) - Same content, different design:** -```xml - - Build Your SaaS Faster - Start shipping in days, not months - Get Started + Maintenance Mode v2 + Plan downtime with confidence. + Enable Module + Maintenance Mode Screenshot -``` ---- +## New referral analytics -## Complete Example +- Multi-touch attribution +- CSV exports +- Improved fraud detection -See `priv/static/examples/sample_page.phk` for a complete example showing all three Hero variants in one file. +Referral dashboard +``` --- -## How the Rendering Works - -When a `.phk` file is rendered: - -1. **Parse XML** → Convert to AST (Abstract Syntax Tree) -2. **Inject Data** → Replace `{{placeholders}}` with actual values -3. **Resolve Components** → Map `` → `PhoenixKitWeb.Components.Blogging.Hero` -4. **Apply Variant** → Select the correct rendering function based on `variant` attribute -5. **Render HTML** → Generate final HTML output +## Reference example – storage-focused post +```yaml --- - -## Adding New Components (Future) - -The system is designed to be extensible. Future components might include: - -```xml - - - - Fast Development - Ship features quickly - - - - - - - This saved us months of development time. - - - - - - Ready to get started? - - -``` - -Each component would have its own `.ex` file with multiple variant implementations. - +slug: storage-integration-example +title: Storage Integration Example +status: draft +published_at: 2025-07-01T10:00:00Z --- -## Best Practices +# Working with PhoenixKit Storage -### Content Organization -- ✅ Use semantic component names (``, not ``) -- ✅ Keep content and structure separate from design decisions -- ✅ Use descriptive alt text for images -- ✅ Choose variants based on page purpose, not content + + + Direct URL Image Example + This uses a direct asset path to display an image. + Get Started + Dashboard Preview + -### Dynamic Data -- ✅ Use placeholders for user-specific content (`{{user.name}}`) -- ✅ Use placeholders for stats that change (`{{stats.count}}`) -- ❌ Don't use placeholders for static marketing copy + + + Storage File ID Example + This pulls from PhoenixKit Storage. + Upload Image + Uploaded Image + -### Variant Selection -- `split-image` → Marketing pages, product showcases, conversions -- `centered` → Welcome pages, announcements, feature launches -- `minimal` → Documentation, blog posts, content-heavy pages + + + Thumbnail Variant + Great for small inline previews. + Thumbnail Image + -### Accessibility -- ✅ Always provide meaningful `alt` text for images -- ✅ Use descriptive CTA button text ("Start Free Trial" not "Click Here") -- ✅ Structure content logically (Headline → Subheadline → CTA) + + + Custom Styling + Combine variants with Tailwind utility classes. + Styled Image + +``` --- -## Troubleshooting - -### "Failed to render preview" -- Check XML syntax (all tags must be closed: `...`) -- Ensure `variant` attribute is valid (`split-image`, `centered`, or `minimal`) -- Verify all attributes are quoted: `primary="true"` not `primary=true` +## Rendering pipeline (current behaviour) -### Dynamic placeholders not working -- Check placeholder syntax: `{{user.name}}` not `{user.name}` -- Ensure the variable exists in preview assigns -- Nested paths must match exactly (case-sensitive) +1. **Frontmatter parsing** – YAML is parsed to capture metadata. +2. **Markdown rendering** – Earmark converts the Markdown body to HTML. +3. **Component pass** – the renderer finds inline PHK component tags and swaps them with Phoenix component output. +4. **Storage resolution** – `` elements fetch URLs from `PhoenixKit.Storage`; caching and signed URLs ensure files are served even when only local storage exists. +5. **Output** – the resulting HTML is cached for published posts to speed up public requests. -### Content not displaying -- Verify you're using child components inside `` (not plain text) -- Check that `` is the root element -- Ensure all opening tags have matching closing tags +There is no longer a pure-XML PageBuilder flow; Markdown is the primary content format. The legacy component pipeline still powers inline components, which is why the supporting modules remain in the codebase. --- -## Reference: Hero Variants Comparison +## Best practices -| Variant | Layout | Background | Best For | Image Required | -|---------|--------|------------|----------|----------------| -| `split-image` | 2-column (content \| image) | Gradient (primary/secondary) | Landing pages, marketing | Recommended | -| `centered` | Single column, centered | Neutral (base-200) | Announcements, welcome | Optional | -| `minimal` | Single column, centered | None | Documentation, content | Optional | +- **Let Markdown do the heavy lifting.** Use inline components only when you need structured UI blocks. +- **Always set `alt` text** for `` components. +- **Reference the correct blog mode path.** If you switch a blog from timestamp to slug mode (or vice versa), migrate the files accordingly. +- **Check variants in Storage.** If you expect `thumbnail` files, verify that automatic variant generation is enabled in Settings → Storage. +- **Keep frontmatter clean.** Avoid adding arbitrary keys unless the blogging UI or your host app actually reads them. +- **Preview before publishing.** The admin preview now uses the same renderer as the public site, so what you see there should match production output. --- -Built with ❤️ for PhoenixKit +Built with ❤️ for PhoenixKit (updated early 2025) diff --git a/lib/phoenix_kit/blogging/renderer.ex b/lib/phoenix_kit/blogging/renderer.ex index 2df79d741..908e37759 100644 --- a/lib/phoenix_kit/blogging/renderer.ex +++ b/lib/phoenix_kit/blogging/renderer.ex @@ -10,6 +10,7 @@ defmodule PhoenixKit.Blogging.Renderer do @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. @@ -40,7 +41,10 @@ defmodule PhoenixKit.Blogging.Renderer do 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 @@ -50,22 +54,161 @@ defmodule PhoenixKit.Blogging.Renderer do 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} -> "

Error rendering markdown

" + 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 or ) + defp is_pure_phk_content?(content) do + trimmed = String.trim(content) + String.starts_with?(trimmed, " + # Convert Phoenix.LiveView.Rendered to string + html + |> Phoenix.HTML.Safe.to_iodata() + |> IO.iodata_to_binary() + + {:error, reason} -> + Logger.warning("PHK render error: #{inspect(reason)}") + "

Error rendering page content

" + 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} -> "

Error rendering markdown

" + 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 + rendered when is_struct(rendered) -> + rendered + |> Phoenix.HTML.Safe.to_iodata() + |> IO.iodata_to_binary() + + html when is_binary(html) -> + html + end + rescue + error -> + Logger.warning("Error rendering Image component: #{inspect(error)}") + "
Error rendering image
" + 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. diff --git a/lib/phoenix_kit/storage.ex b/lib/phoenix_kit/storage.ex index f20e223c3..f0663aea2 100644 --- a/lib/phoenix_kit/storage.ex +++ b/lib/phoenix_kit/storage.ex @@ -24,6 +24,9 @@ defmodule PhoenixKit.Storage do 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 ===== @@ -720,13 +723,94 @@ defmodule PhoenixKit.Storage do # 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 + URLSigner.signed_url(file_id, variant_name, locale: :none) + rescue + _ -> nil + end + end + @doc """ Checks if a file exists in storage. """ diff --git a/lib/phoenix_kit/storage/url_signer.ex b/lib/phoenix_kit/storage/url_signer.ex index 1c8721d9e..ca2577398 100644 --- a/lib/phoenix_kit/storage/url_signer.ex +++ b/lib/phoenix_kit/storage/url_signer.ex @@ -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 @@ -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 """ diff --git a/lib/phoenix_kit/utils/routes.ex b/lib/phoenix_kit/utils/routes.ex index bc8a1b997..42b90f945 100644 --- a/lib/phoenix_kit/utils/routes.ex +++ b/lib/phoenix_kit/utils/routes.ex @@ -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 """ @@ -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. diff --git a/lib/phoenix_kit_web/components/blogging/cta.ex b/lib/phoenix_kit_web/components/blogging/cta.ex index 49bd7507f..eb1c9f96f 100644 --- a/lib/phoenix_kit_web/components/blogging/cta.ex +++ b/lib/phoenix_kit_web/components/blogging/cta.ex @@ -1,43 +1,35 @@ -# ============================================================================ -# COMMENTED OUT: Component-based rendering system - CTA Component -# ============================================================================ -# This module was part of an experimental component-based page building system -# using XML-style markup (.phk files) with swappable design variants. -# Related to: lib/phoenix_kit/blogging/page_builder.ex -# ============================================================================ +defmodule PhoenixKitWeb.Components.Blogging.CTA do + @moduledoc """ + Call-to-action button component. + """ + use Phoenix.Component -# defmodule PhoenixKitWeb.Components.Blogging.CTA do -# @moduledoc """ -# Call-to-action button component. -# """ -# use Phoenix.Component -# -# attr :content, :string, required: true -# attr :attributes, :map, default: %{} -# attr :variant, :string, default: "default" -# -# def render(assigns) do -# is_primary = Map.get(assigns.attributes, "primary", "false") == "true" -# action = Map.get(assigns.attributes, "action", "#") -# -# assigns = -# assigns -# |> assign(:is_primary, is_primary) -# |> assign(:action, action) -# -# ~H""" -# -# {@content} -# -# """ -# end -# end + attr :content, :string, required: true + attr :attributes, :map, default: %{} + attr :variant, :string, default: "default" + + def render(assigns) do + is_primary = Map.get(assigns.attributes, "primary", "false") == "true" + action = Map.get(assigns.attributes, "action", "#") + + assigns = + assigns + |> assign(:is_primary, is_primary) + |> assign(:action, action) + + ~H""" + + {@content} + + """ + end +end diff --git a/lib/phoenix_kit_web/components/blogging/headline.ex b/lib/phoenix_kit_web/components/blogging/headline.ex index 1d63799e0..4ac382de2 100644 --- a/lib/phoenix_kit_web/components/blogging/headline.ex +++ b/lib/phoenix_kit_web/components/blogging/headline.ex @@ -1,26 +1,18 @@ -# ============================================================================ -# COMMENTED OUT: Component-based rendering system - Headline Component -# ============================================================================ -# This module was part of an experimental component-based page building system -# using XML-style markup (.phk files) with swappable design variants. -# Related to: lib/phoenix_kit/blogging/page_builder.ex -# ============================================================================ +defmodule PhoenixKitWeb.Components.Blogging.Headline do + @moduledoc """ + Headline component for hero sections. + """ + use Phoenix.Component -# defmodule PhoenixKitWeb.Components.Blogging.Headline do -# @moduledoc """ -# Headline component for hero sections. -# """ -# use Phoenix.Component -# -# attr :content, :string, required: true -# attr :attributes, :map, default: %{} -# attr :variant, :string, default: "default" -# -# def render(assigns) do -# ~H""" -#

-# {@content} -#

-# """ -# end -# end + attr :content, :string, required: true + attr :attributes, :map, default: %{} + attr :variant, :string, default: "default" + + def render(assigns) do + ~H""" +

+ {@content} +

+ """ + end +end diff --git a/lib/phoenix_kit_web/components/blogging/hero.ex b/lib/phoenix_kit_web/components/blogging/hero.ex index fa6a7ce4d..f254a5a3f 100644 --- a/lib/phoenix_kit_web/components/blogging/hero.ex +++ b/lib/phoenix_kit_web/components/blogging/hero.ex @@ -1,107 +1,99 @@ -# ============================================================================ -# COMMENTED OUT: Component-based rendering system - Hero Component -# ============================================================================ -# This module was part of an experimental component-based page building system -# using XML-style markup (.phk files) with swappable design variants. -# Related to: lib/phoenix_kit/blogging/page_builder.ex -# ============================================================================ +defmodule PhoenixKitWeb.Components.Blogging.Hero do + @moduledoc """ + Hero section component with multiple variants. -# defmodule PhoenixKitWeb.Components.Blogging.Hero do -# @moduledoc """ -# Hero section component with multiple variants. -# -# Variants: -# - `split-image`: Hero with content on left, image on right -# - `centered`: Centered content with optional background -# - `minimal`: Simple centered text-only hero -# -# Example usage in .phk file: -# ```xml -# -# Build Your SaaS Faster -# Start shipping in days, not months -# Get Started -# Learn More -# Dashboard -# -# ``` -# """ -# use Phoenix.Component -# -# attr :variant, :string, default: "centered" -# attr :children, :list, default: [] -# attr :attributes, :map, default: %{} -# attr :content, :string, default: nil -# -# def render(assigns) do -# case assigns.variant do -# "split-image" -> render_split_image(assigns) -# "centered" -> render_centered(assigns) -# "minimal" -> render_minimal(assigns) -# _ -> render_centered(assigns) -# end -# end -# -# # Split Image Variant: Content on left, image on right -# defp render_split_image(assigns) do -# ~H""" -#
-#
-#
-#
-# <%= for child <- @children do %> -# <%= if child.type in [:headline, :subheadline, :cta] do %> -# {render_child(child, assigns)} -# <% end %> -# <% end %> -#
-#
-# <%= for child <- @children do %> -# <%= if child.type == :image do %> -# {render_child(child, assigns)} -# <% end %> -# <% end %> -#
-#
-#
-#
-# """ -# end -# -# # Centered Variant: All content centered -# defp render_centered(assigns) do -# ~H""" -#
-#
-#
-# <%= for child <- @children do %> -# {render_child(child, assigns)} -# <% end %> -#
-#
-#
-# """ -# end -# -# # Minimal Variant: Simple text-only hero -# defp render_minimal(assigns) do -# ~H""" -#
-#
-#
-# <%= for child <- @children do %> -# {render_child(child, assigns)} -# <% end %> -#
-#
-#
-# """ -# end -# -# defp render_child(child, assigns) do -# case PhoenixKitWeb.Live.Modules.Blogging.PageBuilder.Renderer.render(child, assigns) do -# {:ok, html} -> html -# {:error, _} -> "" -# end -# end -# end + Variants: + - `split-image`: Hero with content on left, image on right + - `centered`: Centered content with optional background + - `minimal`: Simple centered text-only hero + + Example usage in .phk file: + ```xml + + Build Your SaaS Faster + Start shipping in days, not months + Get Started + Learn More + Dashboard + + ``` + """ + use Phoenix.Component + + attr :variant, :string, default: "centered" + attr :children, :list, default: [] + attr :attributes, :map, default: %{} + attr :content, :string, default: nil + + def render(assigns) do + case assigns.variant do + "split-image" -> render_split_image(assigns) + "centered" -> render_centered(assigns) + "minimal" -> render_minimal(assigns) + _ -> render_centered(assigns) + end + end + + # Split Image Variant: Content on left, image on right + defp render_split_image(assigns) do + ~H""" +
+
+
+
+ <%= for child <- @children do %> + <%= if child.type in [:headline, :subheadline, :cta] do %> + {render_child(child, assigns)} + <% end %> + <% end %> +
+
+ <%= for child <- @children do %> + <%= if child.type == :image do %> + {render_child(child, assigns)} + <% end %> + <% end %> +
+
+
+
+ """ + end + + # Centered Variant: All content centered + defp render_centered(assigns) do + ~H""" +
+
+
+ <%= for child <- @children do %> + {render_child(child, assigns)} + <% end %> +
+
+
+ """ + end + + # Minimal Variant: Simple text-only hero + defp render_minimal(assigns) do + ~H""" +
+
+
+ <%= for child <- @children do %> + {render_child(child, assigns)} + <% end %> +
+
+
+ """ + end + + defp render_child(child, assigns) do + case PhoenixKitWeb.Live.Modules.Blogging.PageBuilder.Renderer.render(child, assigns) do + {:ok, html} -> html + {:error, _} -> "" + end + end +end diff --git a/lib/phoenix_kit_web/components/blogging/image.ex b/lib/phoenix_kit_web/components/blogging/image.ex index e7ed3d367..9e65d5aac 100644 --- a/lib/phoenix_kit_web/components/blogging/image.ex +++ b/lib/phoenix_kit_web/components/blogging/image.ex @@ -1,37 +1,95 @@ -# ============================================================================ -# COMMENTED OUT: Component-based rendering system - Image Component -# ============================================================================ -# This module was part of an experimental component-based page building system -# using XML-style markup (.phk files) with swappable design variants. -# Related to: lib/phoenix_kit/blogging/page_builder.ex -# ============================================================================ - -# defmodule PhoenixKitWeb.Components.Blogging.Image do -# @moduledoc """ -# Image component for hero sections and content. -# """ -# use Phoenix.Component -# -# attr :attributes, :map, default: %{} -# attr :variant, :string, default: "default" -# attr :content, :string, default: nil -# -# def render(assigns) do -# src = Map.get(assigns.attributes, "src", "") -# alt = Map.get(assigns.attributes, "alt", "") -# -# assigns = -# assigns -# |> assign(:src, src) -# |> assign(:alt, alt) -# -# ~H""" -# {@alt} -# """ -# end -# end +defmodule PhoenixKitWeb.Components.Blogging.Image do + @moduledoc """ + Image component with lazy loading and responsive sizing. + + Supports both direct URLs and PhoenixKit Storage file IDs with automatic variant selection. + + ## Usage + + ### With direct URL: + + Description + + ### With PhoenixKit Storage file ID: + + Description + Description + + ## Attributes + + - `src` - Direct image URL (takes precedence over file_id) + - `file_id` - PhoenixKit Storage file ID + - `file_variant` - Storage variant to use (default: "original") + - Images: "original", "thumbnail", "small", "medium", "large" + - `alt` - Alt text for accessibility (required) + - `class` - Additional CSS classes (optional) + """ + use Phoenix.Component + + attr :attributes, :map, default: %{} + attr :variant, :string, default: "default" + attr :content, :string, default: nil + + def render(assigns) do + # Extract attributes + src = Map.get(assigns.attributes, "src") + file_id = Map.get(assigns.attributes, "file_id") + file_variant = Map.get(assigns.attributes, "file_variant", "original") + alt = Map.get(assigns.attributes, "alt", "") + custom_class = Map.get(assigns.attributes, "class", "") + + # Determine image source + image_src = + cond do + # Direct src takes precedence + src && src != "" -> + src + + # Use file_id from PhoenixKit Storage + file_id && file_id != "" -> + get_file_url(file_id, file_variant) + + # No source provided + true -> + nil + end + + assigns = + assigns + |> assign(:src, image_src) + |> assign(:alt, alt) + |> assign(:custom_class, custom_class) + + ~H""" + <%= if @src do %> + {@alt} + <% else %> + <%!-- Fallback for missing image --%> +
+ Image not available +
+ <% end %> + """ + end + + # Helper function to get file URL from Storage + defp get_file_url(file_id, variant) do + case PhoenixKit.Storage.get_public_url_by_id(file_id, variant) do + nil -> + # Try without variant (fallback to original) + PhoenixKit.Storage.get_public_url_by_id(file_id) + + url -> + url + end + rescue + _ -> + # Gracefully handle missing repo or file + nil + end +end diff --git a/lib/phoenix_kit_web/components/blogging/page.ex b/lib/phoenix_kit_web/components/blogging/page.ex index b58468202..2e6145ac2 100644 --- a/lib/phoenix_kit_web/components/blogging/page.ex +++ b/lib/phoenix_kit_web/components/blogging/page.ex @@ -1,35 +1,27 @@ -# ============================================================================ -# COMMENTED OUT: Component-based rendering system - Page Component -# ============================================================================ -# This module was part of an experimental component-based page building system -# using XML-style markup (.phk files) with swappable design variants. -# Related to: lib/phoenix_kit/blogging/page_builder.ex -# ============================================================================ +defmodule PhoenixKitWeb.Components.Blogging.Page do + @moduledoc """ + Root page component wrapper. + """ + use Phoenix.Component -# defmodule PhoenixKitWeb.Components.Blogging.Page do -# @moduledoc """ -# Root page component wrapper. -# """ -# use Phoenix.Component -# -# attr :children, :list, default: [] -# attr :attributes, :map, default: %{} -# attr :variant, :string, default: "default" -# -# def render(assigns) do -# ~H""" -#
-# <%= for child <- @children do %> -# {render_child(child, assigns)} -# <% end %> -#
-# """ -# end -# -# defp render_child(child, assigns) do -# case PhoenixKitWeb.Live.Modules.Blogging.PageBuilder.Renderer.render(child, assigns) do -# {:ok, html} -> html -# {:error, _} -> "" -# end -# end -# end + attr :children, :list, default: [] + attr :attributes, :map, default: %{} + attr :variant, :string, default: "default" + + def render(assigns) do + ~H""" +
+ <%= for child <- @children do %> + {render_child(child, assigns)} + <% end %> +
+ """ + end + + defp render_child(child, assigns) do + case PhoenixKitWeb.Live.Modules.Blogging.PageBuilder.Renderer.render(child, assigns) do + {:ok, html} -> html + {:error, _} -> "" + end + end +end diff --git a/lib/phoenix_kit_web/components/blogging/subheadline.ex b/lib/phoenix_kit_web/components/blogging/subheadline.ex index add1976b2..41b754340 100644 --- a/lib/phoenix_kit_web/components/blogging/subheadline.ex +++ b/lib/phoenix_kit_web/components/blogging/subheadline.ex @@ -1,26 +1,18 @@ -# ============================================================================ -# COMMENTED OUT: Component-based rendering system - Subheadline Component -# ============================================================================ -# This module was part of an experimental component-based page building system -# using XML-style markup (.phk files) with swappable design variants. -# Related to: lib/phoenix_kit/blogging/page_builder.ex -# ============================================================================ +defmodule PhoenixKitWeb.Components.Blogging.Subheadline do + @moduledoc """ + Subheadline component for supporting text. + """ + use Phoenix.Component -# defmodule PhoenixKitWeb.Components.Blogging.Subheadline do -# @moduledoc """ -# Subheadline component for hero sections. -# """ -# use Phoenix.Component -# -# attr :content, :string, required: true -# attr :attributes, :map, default: %{} -# attr :variant, :string, default: "default" -# -# def render(assigns) do -# ~H""" -#

-# {@content} -#

-# """ -# end -# end + attr :content, :string, required: true + attr :attributes, :map, default: %{} + attr :variant, :string, default: "default" + + def render(assigns) do + ~H""" +

+ {@content} +

+ """ + end +end diff --git a/lib/phoenix_kit_web/live/modules/blogging/context/page_builder.ex b/lib/phoenix_kit_web/live/modules/blogging/context/page_builder.ex index ee9907c57..25ddc0a23 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/context/page_builder.ex +++ b/lib/phoenix_kit_web/live/modules/blogging/context/page_builder.ex @@ -11,132 +11,120 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.PageBuilder do 6. Render to HTML """ - # ============================================================================ - # COMMENTED OUT: Component-based rendering system - # ============================================================================ - # This module was part of an experimental component-based page building system - # using XML-style markup (.phk files) with swappable design variants. - # See related files: - # - lib/phoenix_kit/blogging/page_builder/parser.ex - # - lib/phoenix_kit/blogging/page_builder/renderer.ex - # - lib/phoenix_kit_web/components/blogging/*.ex - # - priv/blogging/blog/*/en.phk (sample files) - # ============================================================================ - - # alias PhoenixKitWeb.Live.Modules.Blogging.PageBuilder.Parser - # alias PhoenixKitWeb.Live.Modules.Blogging.PageBuilder.Renderer - - # @type assigns :: map() - # @type ast :: map() - # @type render_result :: {:ok, Phoenix.LiveView.Rendered.t()} | {:error, term()} - - # @doc """ - # Renders a .phk page file to HTML. - # - # ## Examples - # - # iex> PageBuilder.render_page("/path/to/page.phk", %{user: %{name: "Alice"}}) - # {:ok, rendered_html} - # """ - # @spec render_page(String.t(), assigns()) :: render_result() - # def render_page(page_path, assigns \\ %{}) do - # with {:ok, content} <- read_page_file(page_path), - # {:ok, ast} <- parse_to_ast(content), - # {:ok, ast_with_data} <- inject_dynamic_data(ast, assigns), - # {:ok, resolved} <- resolve_components(ast_with_data), - # {:ok, themed} <- apply_theme(resolved, assigns), - # {:ok, html} <- render_to_html(themed, assigns) do - # {:ok, html} - # else - # {:error, reason} -> {:error, reason} - # end - # end - - # @doc """ - # Renders .phk content directly (without file path). - # """ - # @spec render_content(String.t(), assigns()) :: render_result() - # def render_content(content, assigns \\ %{}) do - # with {:ok, ast} <- parse_to_ast(content), - # {:ok, ast_with_data} <- inject_dynamic_data(ast, assigns), - # {:ok, resolved} <- resolve_components(ast_with_data), - # {:ok, themed} <- apply_theme(resolved, assigns), - # {:ok, html} <- render_to_html(themed, assigns) do - # {:ok, html} - # else - # {:error, reason} -> {:error, reason} - # end - # end - - # # Step 1: Read .phk file - # defp read_page_file(page_path) do - # case File.read(page_path) do - # {:ok, content} -> {:ok, content} - # {:error, reason} -> {:error, {:file_read_error, reason}} - # end - # end - - # # Step 2: Parse XML to AST - # defp parse_to_ast(content) do - # Parser.parse(content) - # end - - # # Step 3: Inject dynamic data (replace {{variable}} placeholders) - # defp inject_dynamic_data(ast, assigns) do - # {:ok, inject_assigns(ast, assigns)} - # end - - # # Step 4: Resolve components (map XML tags to actual component modules) - # defp resolve_components(ast) do - # {:ok, ast} - # end - - # # Step 5: Apply theme/variant settings - # defp apply_theme(ast, _assigns) do - # {:ok, ast} - # end - - # # Step 6: Render to HTML - # defp render_to_html(ast, assigns) do - # Renderer.render(ast, assigns) - # end - - # # Recursively inject assigns into AST nodes - # defp inject_assigns(ast, assigns) when is_map(ast) do - # ast - # |> Map.update(:content, nil, &inject_assigns(&1, assigns)) - # |> Map.update(:attributes, %{}, &inject_assigns(&1, assigns)) - # |> Map.update(:children, [], &inject_assigns(&1, assigns)) - # end - - # defp inject_assigns(ast, assigns) when is_list(ast) do - # Enum.map(ast, &inject_assigns(&1, assigns)) - # end - - # defp inject_assigns(content, assigns) when is_binary(content) do - # interpolate_string(content, assigns) - # end - - # defp inject_assigns(value, _assigns), do: value - - # # Interpolate {{variable}} placeholders - # defp interpolate_string(string, assigns) do - # Regex.replace(~r/\{\{([^}]+)\}\}/, string, fn _, path -> - # get_nested_value(assigns, String.trim(path)) |> to_string() - # end) - # end - - # # Get nested value from assigns (e.g., "user.name" -> assigns.user.name) - # defp get_nested_value(map, path) do - # path - # |> String.split(".") - # |> Enum.reduce(map, fn key, acc -> - # case acc do - # %{} -> Map.get(acc, key) || Map.get(acc, String.to_existing_atom(key)) - # _ -> nil - # end - # end) - # rescue - # _ -> "" - # end + alias PhoenixKitWeb.Live.Modules.Blogging.PageBuilder.Parser + alias PhoenixKitWeb.Live.Modules.Blogging.PageBuilder.Renderer + + @type assigns :: map() + @type ast :: map() + @type render_result :: {:ok, Phoenix.LiveView.Rendered.t()} | {:error, term()} + + @doc """ + Renders a .phk page file to HTML. + + ## Examples + + iex> PageBuilder.render_page("/path/to/page.phk", %{user: %{name: "Alice"}}) + {:ok, rendered_html} + """ + @spec render_page(String.t(), assigns()) :: render_result() + def render_page(page_path, assigns \\ %{}) do + with {:ok, content} <- read_page_file(page_path), + {:ok, ast} <- parse_to_ast(content), + {:ok, ast_with_data} <- inject_dynamic_data(ast, assigns), + {:ok, resolved} <- resolve_components(ast_with_data), + {:ok, themed} <- apply_theme(resolved, assigns), + {:ok, html} <- render_to_html(themed, assigns) do + {:ok, html} + else + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Renders .phk content directly (without file path). + """ + @spec render_content(String.t(), assigns()) :: render_result() + def render_content(content, assigns \\ %{}) do + with {:ok, ast} <- parse_to_ast(content), + {:ok, ast_with_data} <- inject_dynamic_data(ast, assigns), + {:ok, resolved} <- resolve_components(ast_with_data), + {:ok, themed} <- apply_theme(resolved, assigns), + {:ok, html} <- render_to_html(themed, assigns) do + {:ok, html} + else + {:error, reason} -> {:error, reason} + end + end + + # Step 1: Read .phk file + defp read_page_file(page_path) do + case File.read(page_path) do + {:ok, content} -> {:ok, content} + {:error, reason} -> {:error, {:file_read_error, reason}} + end + end + + # Step 2: Parse XML to AST + defp parse_to_ast(content) do + Parser.parse(content) + end + + # Step 3: Inject dynamic data (replace {{variable}} placeholders) + defp inject_dynamic_data(ast, assigns) do + {:ok, inject_assigns(ast, assigns)} + end + + # Step 4: Resolve components (map XML tags to actual component modules) + defp resolve_components(ast) do + {:ok, ast} + end + + # Step 5: Apply theme/variant settings + defp apply_theme(ast, _assigns) do + {:ok, ast} + end + + # Step 6: Render to HTML + defp render_to_html(ast, assigns) do + Renderer.render(ast, assigns) + end + + # Recursively inject assigns into AST nodes + defp inject_assigns(ast, assigns) when is_map(ast) do + ast + |> Map.update(:content, nil, &inject_assigns(&1, assigns)) + |> Map.update(:attributes, %{}, &inject_assigns(&1, assigns)) + |> Map.update(:children, [], &inject_assigns(&1, assigns)) + end + + defp inject_assigns(ast, assigns) when is_list(ast) do + Enum.map(ast, &inject_assigns(&1, assigns)) + end + + defp inject_assigns(content, assigns) when is_binary(content) do + interpolate_string(content, assigns) + end + + defp inject_assigns(value, _assigns), do: value + + # Interpolate {{variable}} placeholders + defp interpolate_string(string, assigns) do + Regex.replace(~r/\{\{([^}]+)\}\}/, string, fn _, path -> + get_nested_value(assigns, String.trim(path)) |> to_string() + end) + end + + # Get nested value from assigns (e.g., "user.name" -> assigns.user.name) + defp get_nested_value(map, path) do + path + |> String.split(".") + |> Enum.reduce(map, fn key, acc -> + case acc do + %{} -> Map.get(acc, key) || Map.get(acc, String.to_existing_atom(key)) + _ -> nil + end + end) + rescue + _ -> "" + end end diff --git a/lib/phoenix_kit_web/live/modules/blogging/context/page_builder/parser.ex b/lib/phoenix_kit_web/live/modules/blogging/context/page_builder/parser.ex index 6d59a9e85..2c8894217 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/context/page_builder/parser.ex +++ b/lib/phoenix_kit_web/live/modules/blogging/context/page_builder/parser.ex @@ -1,156 +1,152 @@ -# ============================================================================ -# COMMENTED OUT: Component-based rendering system - XML Parser -# ============================================================================ -# This module was part of an experimental component-based page building system -# using XML-style markup (.phk files) with swappable design variants. -# Related to: lib/phoenix_kit/blogging/page_builder.ex -# ============================================================================ - -# defmodule PhoenixKitWeb.Live.Modules.Blogging.PageBuilder.Parser do -# @moduledoc """ -# Parses .phk (PhoenixKit) XML-style markup into an AST. -# -# Example input: -# ```xml -# -# -# Welcome to PhoenixKit -# Build faster with {{framework}} -# Get Started -# -# -# ``` -# -# Output AST: -# ```elixir -# %{ -# type: :page, -# attributes: %{slug: "home"}, -# children: [ -# %{ -# type: :hero, -# attributes: %{variant: "split-image"}, -# children: [ -# %{type: :headline, content: "Welcome to PhoenixKit"}, -# %{type: :subheadline, content: "Build faster with {{framework}}"}, -# %{type: :cta, attributes: %{primary: "true", action: "/signup"}, content: "Get Started"} -# ] -# } -# ] -# } -# ``` -# """ -# -# @doc """ -# Parses .phk XML content into an AST. -# """ -# @spec parse(String.t()) :: {:ok, map()} | {:error, term()} -# def parse(content) when is_binary(content) do -# content = String.trim(content) -# -# case Saxy.parse_string(content, PhoenixKitWeb.Live.Modules.Blogging.PageBuilder.SaxHandler, []) do -# {:ok, ast} -> {:ok, ast} -# {:error, reason} -> {:error, {:parse_error, reason}} -# end -# rescue -# e -> {:error, {:parse_exception, e}} -# end -# -# def parse(_), do: {:error, :invalid_content} -# end -# -# defmodule PhoenixKitWeb.Live.Modules.Blogging.PageBuilder.SaxHandler do -# @moduledoc false -# @behaviour Saxy.Handler -# -# def handle_event(:start_document, _prolog, _state) do -# {:ok, %{stack: [], result: nil}} -# end -# -# def handle_event(:end_document, _data, state) do -# {:ok, state.result} -# end -# -# def handle_event(:start_element, {name, attributes}, state) do -# node = %{ -# type: normalize_tag_name(name), -# attributes: parse_attributes(attributes), -# children: [], -# content: nil -# } -# -# new_state = %{state | stack: [node | state.stack]} -# {:ok, new_state} -# end -# -# def handle_event(:end_element, _name, %{stack: [current | rest]} = state) do -# # Simplify node if it only has content and no children -# simplified = -# cond do -# current.children == [] and is_binary(current.content) -> -# %{ -# type: current.type, -# attributes: current.attributes, -# content: String.trim(current.content) -# } -# -# current.content == nil and current.children != [] -> -# %{ -# type: current.type, -# attributes: current.attributes, -# children: Enum.reverse(current.children) -# } -# -# true -> -# %{ -# type: current.type, -# attributes: current.attributes, -# children: Enum.reverse(current.children), -# content: current.content && String.trim(current.content) -# } -# end -# -# case rest do -# [] -> -# {:ok, %{state | stack: [], result: simplified}} -# -# [parent | ancestors] -> -# updated_parent = %{parent | children: [simplified | parent.children]} -# {:ok, %{state | stack: [updated_parent | ancestors]}} -# end -# end -# -# def handle_event(:characters, chars, %{stack: [current | rest]} = state) do -# trimmed = String.trim(chars) -# -# updated_current = -# if trimmed != "" do -# case current.content do -# nil -> %{current | content: chars} -# existing -> %{current | content: existing <> chars} -# end -# else -# current -# end -# -# {:ok, %{state | stack: [updated_current | rest]}} -# end -# -# def handle_event(:characters, _chars, state) do -# {:ok, state} -# end -# -# # Normalize tag names to atoms (Page -> :page, Hero -> :hero) -# defp normalize_tag_name(name) do -# name -# |> String.downcase() -# |> String.to_atom() -# end -# -# # Convert attribute list to map with string keys -# defp parse_attributes(attrs) do -# Enum.into(attrs, %{}, fn {key, value} -> -# {String.downcase(key), value} -# end) -# end -# end +defmodule PhoenixKitWeb.Live.Modules.Blogging.PageBuilder.Parser do + @moduledoc """ + Parses .phk (PhoenixKit) XML-style markup into an AST. + + Example input: + ```xml + + + Welcome to PhoenixKit + Build faster with {{framework}} + Get Started + + + ``` + + Output AST: + ```elixir + %{ + type: :page, + attributes: %{slug: "home"}, + children: [ + %{ + type: :hero, + attributes: %{variant: "split-image"}, + children: [ + %{type: :headline, content: "Welcome to PhoenixKit"}, + %{type: :subheadline, content: "Build faster with {{framework}}"}, + %{type: :cta, attributes: %{primary: "true", action: "/signup"}, content: "Get Started"} + ] + } + ] + } + ``` + """ + + @doc """ + Parses .phk XML content into an AST. + """ + @spec parse(String.t()) :: {:ok, map()} | {:error, term()} + def parse(content) when is_binary(content) do + content = String.trim(content) + + case Saxy.parse_string( + content, + PhoenixKitWeb.Live.Modules.Blogging.PageBuilder.SaxHandler, + [] + ) do + {:ok, ast} -> {:ok, ast} + {:error, reason} -> {:error, {:parse_error, reason}} + end + rescue + e -> {:error, {:parse_exception, e}} + end + + def parse(_), do: {:error, :invalid_content} +end + +defmodule PhoenixKitWeb.Live.Modules.Blogging.PageBuilder.SaxHandler do + @moduledoc false + @behaviour Saxy.Handler + + def handle_event(:start_document, _prolog, _state) do + {:ok, %{stack: [], result: nil}} + end + + def handle_event(:end_document, _data, state) do + {:ok, state.result} + end + + def handle_event(:start_element, {name, attributes}, state) do + node = %{ + type: normalize_tag_name(name), + attributes: parse_attributes(attributes), + children: [], + content: nil + } + + new_state = %{state | stack: [node | state.stack]} + {:ok, new_state} + end + + def handle_event(:end_element, _name, %{stack: [current | rest]} = state) do + # Simplify node if it only has content and no children + simplified = + cond do + current.children == [] and is_binary(current.content) -> + %{ + type: current.type, + attributes: current.attributes, + content: String.trim(current.content) + } + + current.content == nil and current.children != [] -> + %{ + type: current.type, + attributes: current.attributes, + children: Enum.reverse(current.children) + } + + true -> + %{ + type: current.type, + attributes: current.attributes, + children: Enum.reverse(current.children), + content: current.content && String.trim(current.content) + } + end + + case rest do + [] -> + {:ok, %{state | stack: [], result: simplified}} + + [parent | ancestors] -> + updated_parent = %{parent | children: [simplified | parent.children]} + {:ok, %{state | stack: [updated_parent | ancestors]}} + end + end + + def handle_event(:characters, chars, %{stack: [current | rest]} = state) do + trimmed = String.trim(chars) + + updated_current = + if trimmed != "" do + case current.content do + nil -> %{current | content: chars} + existing -> %{current | content: existing <> chars} + end + else + current + end + + {:ok, %{state | stack: [updated_current | rest]}} + end + + def handle_event(:characters, _chars, state) do + {:ok, state} + end + + # Normalize tag names to atoms (Page -> :page, Hero -> :hero) + defp normalize_tag_name(name) do + name + |> String.downcase() + |> String.to_atom() + end + + # Convert attribute list to map with string keys + defp parse_attributes(attrs) do + Enum.into(attrs, %{}, fn {key, value} -> + {String.downcase(key), value} + end) + end +end diff --git a/lib/phoenix_kit_web/live/modules/blogging/context/page_builder/renderer.ex b/lib/phoenix_kit_web/live/modules/blogging/context/page_builder/renderer.ex index 69c6e63de..822ad24e8 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/context/page_builder/renderer.ex +++ b/lib/phoenix_kit_web/live/modules/blogging/context/page_builder/renderer.ex @@ -1,107 +1,99 @@ -# ============================================================================ -# COMMENTED OUT: Component-based rendering system - AST to HTML Renderer -# ============================================================================ -# This module was part of an experimental component-based page building system -# using XML-style markup (.phk files) with swappable design variants. -# Related to: lib/phoenix_kit/blogging/page_builder.ex -# ============================================================================ +defmodule PhoenixKitWeb.Live.Modules.Blogging.PageBuilder.Renderer do + @moduledoc """ + Renders AST nodes to HTML by delegating to component modules. + """ -# defmodule PhoenixKitWeb.Live.Modules.Blogging.PageBuilder.Renderer do -# @moduledoc """ -# Renders AST nodes to HTML by delegating to component modules. -# """ -# -# @doc """ -# Renders an AST node to HTML. -# """ -# def render(ast, assigns) when is_map(ast) do -# case resolve_component(ast.type) do -# {:ok, component_module} -> -# render_component(component_module, ast, assigns) -# -# {:error, :not_found} -> -# # Fallback for unknown components -# render_unknown(ast, assigns) -# end -# end -# -# def render(ast, _assigns) when is_list(ast) do -# {:ok, -# Phoenix.HTML.raw( -# ast -# |> Enum.map(fn node -> -# case render(node, %{}) do -# {:ok, html} -> Phoenix.HTML.safe_to_string(html) -# {:error, _} -> "" -# end -# end) -# |> Enum.join() -# )} -# end -# -# def render(content, _assigns) when is_binary(content) do -# {:ok, Phoenix.HTML.raw(content)} -# end -# -# # Resolve component type to module -# defp resolve_component(:page), do: {:ok, PhoenixKitWeb.Components.Blogging.Page} -# defp resolve_component(:hero), do: {:ok, PhoenixKitWeb.Components.Blogging.Hero} -# defp resolve_component(:headline), do: {:ok, PhoenixKitWeb.Components.Blogging.Headline} -# -# defp resolve_component(:subheadline), -# do: {:ok, PhoenixKitWeb.Components.Blogging.Subheadline} -# -# defp resolve_component(:cta), do: {:ok, PhoenixKitWeb.Components.Blogging.CTA} -# defp resolve_component(:image), do: {:ok, PhoenixKitWeb.Components.Blogging.Image} -# defp resolve_component(_), do: {:error, :not_found} -# -# # Render using the component module -# defp render_component(component_module, ast, assigns) do -# component_assigns = build_component_assigns(ast, assigns) -# -# try do -# html = component_module.render(component_assigns) -# {:ok, html} -# rescue -# e -> -# {:error, {:render_error, e}} -# end -# end -# -# # Build assigns map for component -# defp build_component_assigns(ast, parent_assigns) do -# base_assigns = %{ -# __changed__: nil, -# variant: Map.get(ast.attributes, "variant", "default"), -# attributes: ast.attributes, -# content: ast[:content], -# children: ast[:children] || [] -# } -# -# Map.merge(parent_assigns, base_assigns) -# end -# -# # Fallback renderer for unknown components -# defp render_unknown(ast, assigns) do -# content = -# cond do -# ast[:content] -> -# ast.content -# -# ast[:children] -> -# ast.children -# |> Enum.map(fn child -> -# case render(child, assigns) do -# {:ok, html} -> Phoenix.HTML.safe_to_string(html) -# _ -> "" -# end -# end) -# |> Enum.join() -# -# true -> -# "" -# end -# -# {:ok, Phoenix.HTML.raw("
#{content}
")} -# end -# end + @doc """ + Renders an AST node to HTML. + """ + def render(ast, assigns) when is_map(ast) do + case resolve_component(ast.type) do + {:ok, component_module} -> + render_component(component_module, ast, assigns) + + {:error, :not_found} -> + # Fallback for unknown components + render_unknown(ast, assigns) + end + end + + def render(ast, _assigns) when is_list(ast) do + {:ok, + Phoenix.HTML.raw( + ast + |> Enum.map(fn node -> + case render(node, %{}) do + {:ok, html} -> Phoenix.HTML.safe_to_string(html) + {:error, _} -> "" + end + end) + |> Enum.join() + )} + end + + def render(content, _assigns) when is_binary(content) do + {:ok, Phoenix.HTML.raw(content)} + end + + # Resolve component type to module + defp resolve_component(:page), do: {:ok, PhoenixKitWeb.Components.Blogging.Page} + defp resolve_component(:hero), do: {:ok, PhoenixKitWeb.Components.Blogging.Hero} + defp resolve_component(:headline), do: {:ok, PhoenixKitWeb.Components.Blogging.Headline} + + defp resolve_component(:subheadline), + do: {:ok, PhoenixKitWeb.Components.Blogging.Subheadline} + + defp resolve_component(:cta), do: {:ok, PhoenixKitWeb.Components.Blogging.CTA} + defp resolve_component(:image), do: {:ok, PhoenixKitWeb.Components.Blogging.Image} + defp resolve_component(_), do: {:error, :not_found} + + # Render using the component module + defp render_component(component_module, ast, assigns) do + component_assigns = build_component_assigns(ast, assigns) + + try do + html = component_module.render(component_assigns) + {:ok, html} + rescue + e -> + {:error, {:render_error, e}} + end + end + + # Build assigns map for component + defp build_component_assigns(ast, parent_assigns) do + base_assigns = %{ + __changed__: nil, + variant: Map.get(ast.attributes, "variant", "default"), + attributes: ast.attributes, + content: ast[:content], + children: ast[:children] || [] + } + + Map.merge(parent_assigns, base_assigns) + end + + # Fallback renderer for unknown components + defp render_unknown(ast, assigns) do + content = + cond do + ast[:content] -> + ast.content + + ast[:children] -> + ast.children + |> Enum.map(fn child -> + case render(child, assigns) do + {:ok, html} -> Phoenix.HTML.safe_to_string(html) + _ -> "" + end + end) + |> Enum.join() + + true -> + "" + end + + {:ok, Phoenix.HTML.raw("
#{content}
")} + end +end diff --git a/lib/phoenix_kit_web/live/modules/blogging/preview.ex b/lib/phoenix_kit_web/live/modules/blogging/preview.ex index 7dc780d51..d444827b3 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/preview.ex +++ b/lib/phoenix_kit_web/live/modules/blogging/preview.ex @@ -5,7 +5,9 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Preview do use PhoenixKitWeb, :live_view use Gettext, backend: PhoenixKitWeb.Gettext + require Logger alias Phoenix.HTML + alias PhoenixKit.Blogging.Renderer alias PhoenixKitWeb.Live.Modules.Blogging # alias PhoenixKitWeb.Live.Modules.Blogging.PageBuilder # COMMENTED OUT: Component system alias PhoenixKit.Settings @@ -151,36 +153,17 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Preview do # Related to: lib/phoenix_kit/blogging/page_builder.ex # ============================================================================ - defp render_markdown_content(content) do - case Earmark.as_html(content) do - {:ok, html, _warnings} -> - {:ok, HTML.raw(html)} - - {:error, _html, errors} -> - message = - errors - |> Enum.map_join("; ", &format_markdown_error/1) - |> case do - "" -> gettext("An unknown error occurred while rendering markdown.") - err -> gettext("Failed to render markdown: %{message}", message: err) - end - - {:error, message} - end - end - - defp format_markdown_error({severity, line, message}) - when is_atom(severity) and is_integer(line) and is_binary(message) do - "#{severity} (line #{line}): #{message}" + defp render_markdown_content(content) when is_binary(content) do + content + |> Renderer.render_markdown() + |> HTML.raw() + |> then(&{:ok, &1}) + rescue + error -> + Logger.error("Preview markdown rendering failed", error: inspect(error)) + {:error, gettext("Failed to render markdown preview.")} end - defp format_markdown_error(%{line: line, message: message}) - when is_integer(line) and is_binary(message) do - "line #{line}: #{message}" - end - - defp format_markdown_error(other), do: inspect(other) - defp build_preview_post(data, fallback_blog_slug, fallback_locale) do blog_slug = data[:blog_slug] || fallback_blog_slug language = data[:language] || fallback_locale diff --git a/lib/phoenix_kit_web/live/modules/blogging/preview.html.heex b/lib/phoenix_kit_web/live/modules/blogging/preview.html.heex index 3eaa5dc48..b6428ba3b 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/preview.html.heex +++ b/lib/phoenix_kit_web/live/modules/blogging/preview.html.heex @@ -89,6 +89,15 @@ margin-top: 1rem; margin-bottom: 1rem; padding-left: 1.5rem; + list-style-position: outside; + } + + .blogging-markdown ul { + list-style-type: disc; + } + + .blogging-markdown ol { + list-style-type: decimal; } .blogging-markdown li { @@ -171,7 +180,7 @@ }
- {@rendered_content} + {raw(@rendered_content)}
<% else %>