diff --git a/lib/berrypod/application.ex b/lib/berrypod/application.ex index 2fa3a05..72cace1 100644 --- a/lib/berrypod/application.ex +++ b/lib/berrypod/application.ex @@ -44,7 +44,9 @@ defmodule Berrypod.Application do # Theme CSS cache - must start after Endpoint for static_path/1 to work Berrypod.Theme.CSSCache, # Page definition cache - loads page block lists into ETS - Berrypod.Pages.PageCache + Berrypod.Pages.PageCache, + # URL routes cache - custom slugs and prefixes + BerrypodWeb.R ] # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/lib/berrypod_web/r.ex b/lib/berrypod_web/r.ex new file mode 100644 index 0000000..551c32f --- /dev/null +++ b/lib/berrypod_web/r.ex @@ -0,0 +1,322 @@ +defmodule BerrypodWeb.R do + @moduledoc """ + Runtime URL paths with ETS caching. Short name for ergonomic use. + + This module provides functions for generating URLs that respect custom URL slugs + and route prefixes configured by the shop owner. + + ## Usage + + R.cart() # => "/basket" (if cart is renamed to basket) + R.product(123) # => "/p/123" (if products prefix is "p") + R.url(R.product(123)) # => "https://shop.com/p/123" + + ## Page slugs vs route prefixes + + - **Page slugs** are single-segment paths like `/cart`, `/about`, `/contact` + - **Route prefixes** are multi-segment paths like `/products/:id`, `/collections/:slug` + + Page slugs are stored on individual Page records. Route prefixes are stored in Settings. + + ## Routing + + The router uses three catch-all routes (`/`, `/:prefix/:id_or_slug`, `/:slug`) that + delegate to `Shop.Page`, which resolves the actual page type at runtime using + `page_type_from_slug/1` and `prefix_type_from_segment/1`. See the router's + "Dynamic Shop Routes" comment block for the full list of handled pages. + + To add a new system page type: + 1. Add a clause to `default_page_type/1` and `default_slug/1` + 2. Add the page module to `@page_modules` in `Shop.Page` + """ + + use GenServer + + alias Berrypod.{Pages, Settings} + + @table __MODULE__ + + # ============================================================================= + # Client API - Path functions + # ============================================================================= + + @doc "Returns the home page path (always /)." + def home, do: "/" + + @doc "Returns the cart page path." + def cart, do: "/" <> page_slug(:cart) + + @doc "Returns the about page path." + def about, do: "/" <> page_slug(:about) + + @doc "Returns the contact page path." + def contact, do: "/" <> page_slug(:contact) + + @doc "Returns the search page path." + def search, do: "/" <> page_slug(:search) + + @doc "Returns the delivery page path." + def delivery, do: "/" <> page_slug(:delivery) + + @doc "Returns the privacy page path." + def privacy, do: "/" <> page_slug(:privacy) + + @doc "Returns the terms page path." + def terms, do: "/" <> page_slug(:terms) + + @doc "Returns the checkout success page path." + def checkout_success, do: "/" <> page_slug(:checkout_success) + + @doc "Returns the orders page path." + def orders, do: "/" <> page_slug(:orders) + + @doc """ + Returns the path for any page by slug. + + This is a generic version that works with any page slug, including custom pages. + Prefer the specific functions (cart/0, about/0, etc.) when the page is known. + """ + def page("home"), do: "/" + def page("cart"), do: cart() + def page("about"), do: about() + def page("contact"), do: contact() + def page("search"), do: search() + def page("delivery"), do: delivery() + def page("privacy"), do: privacy() + def page("terms"), do: terms() + def page("checkout_success"), do: checkout_success() + def page("orders"), do: orders() + def page(slug) when is_binary(slug), do: "/" <> slug + + @doc "Returns the path for a specific product." + def product(id), do: "/" <> prefix(:products) <> "/" <> to_string(id) + + @doc "Returns the path for a specific collection." + def collection(slug), do: "/" <> prefix(:collections) <> "/" <> slug + + @doc "Returns the path for a specific order." + def order(order_number), do: "/" <> prefix(:orders) <> "/" <> order_number + + @doc "Returns a full URL (with scheme and host) for the given path." + def url(path), do: BerrypodWeb.Endpoint.url() <> path + + # ============================================================================= + # Reverse lookups (for router) + # ============================================================================= + + @doc """ + Given a URL slug, returns the page type it maps to. + + Returns: + - `{:page, :cart}` for system pages + - `{:custom, page}` for custom pages + - `nil` if not found + """ + def page_type_from_slug(slug) do + # First check if it's a custom URL slug for a system page + case lookup({:reverse_slug, slug}) do + {:ok, page_type} -> + {:page, page_type} + + :not_found -> + # Check if it's a default slug for a system page + case default_page_type(slug) do + nil -> + # Check if it's a custom page + case Pages.get_published_page_by_effective_url(slug) do + nil -> nil + page -> {:custom, page} + end + + page_type -> + {:page, page_type} + end + end + end + + @doc """ + Given a URL segment, returns the prefix type it maps to. + + Returns `:products`, `:collections`, `:orders`, or `nil`. + """ + def prefix_type_from_segment(segment) do + case lookup({:reverse_prefix, segment}) do + {:ok, type} -> type + :not_found -> default_prefix_type(segment) + end + end + + @doc """ + Returns the current prefix for a given type. + + Used by the DynamicRoutes plug to check if a custom prefix is set. + + ## Examples + + R.prefix(:products) # => "p" (if customised) or "products" (default) + R.prefix(:collections) # => "c" (if customised) or "collections" (default) + """ + def prefix(prefix_type) do + key = {:prefix, prefix_type} + + case lookup(key) do + {:ok, prefix} -> prefix + :not_found -> default_prefix(prefix_type) + end + end + + # ============================================================================= + # Cache management + # ============================================================================= + + @doc "Invalidates a specific cache key." + def invalidate(key) do + GenServer.cast(__MODULE__, {:invalidate, key}) + end + + @doc "Invalidates all cache entries (use after prefix changes)." + def invalidate_all do + GenServer.cast(__MODULE__, :invalidate_all) + end + + @doc "Invalidates all cache entries synchronously. Use when you need the cache refreshed before proceeding." + def invalidate_all_sync do + GenServer.call(__MODULE__, :invalidate_all) + end + + @doc "Clears all cache entries without re-warming. Used in tests to reset to defaults." + def clear do + GenServer.call(__MODULE__, :clear) + end + + @doc "Warms the cache with current values from the database." + def warm_cache do + GenServer.cast(__MODULE__, :warm_cache) + end + + # ============================================================================= + # GenServer + # ============================================================================= + + def start_link(_opts) do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + @impl GenServer + def init(_) do + :ets.new(@table, [:named_table, :public, read_concurrency: true]) + do_warm_cache() + {:ok, %{}} + end + + @impl GenServer + def handle_cast({:invalidate, key}, state) do + :ets.delete(@table, key) + {:noreply, state} + end + + def handle_cast(:invalidate_all, state) do + :ets.delete_all_objects(@table) + do_warm_cache() + {:noreply, state} + end + + def handle_cast(:warm_cache, state) do + do_warm_cache() + {:noreply, state} + end + + @impl GenServer + def handle_call(:invalidate_all, _from, state) do + :ets.delete_all_objects(@table) + do_warm_cache() + {:reply, :ok, state} + end + + def handle_call(:clear, _from, state) do + :ets.delete_all_objects(@table) + {:reply, :ok, state} + end + + # ============================================================================= + # Private - Cache operations + # ============================================================================= + + defp page_slug(page_type) do + key = {:slug, page_type} + + case lookup(key) do + {:ok, slug} -> slug + :not_found -> default_slug(page_type) + end + end + + defp lookup(key) do + case :ets.lookup(@table, key) do + [{^key, value}] -> {:ok, value} + [] -> :not_found + end + rescue + ArgumentError -> :not_found + end + + defp do_warm_cache do + # Load page slugs + Pages.list_pages_with_custom_urls() + |> Enum.each(fn page -> + page_type = String.to_existing_atom(page.slug) + :ets.insert(@table, {{:slug, page_type}, page.url_slug}) + :ets.insert(@table, {{:reverse_slug, page.url_slug}, page_type}) + end) + + # Load route prefixes from settings + prefixes = Settings.get_setting("url_prefixes") || %{} + + Enum.each(prefixes, fn {type_str, prefix} -> + type = String.to_existing_atom(type_str) + :ets.insert(@table, {{:prefix, type}, prefix}) + :ets.insert(@table, {{:reverse_prefix, prefix}, type}) + end) + rescue + # During startup, tables/atoms might not exist yet + _ -> :ok + end + + # ============================================================================= + # Defaults + # ============================================================================= + + defp default_slug(:cart), do: "cart" + defp default_slug(:about), do: "about" + defp default_slug(:contact), do: "contact" + defp default_slug(:search), do: "search" + defp default_slug(:delivery), do: "delivery" + defp default_slug(:privacy), do: "privacy" + defp default_slug(:terms), do: "terms" + defp default_slug(:checkout_success), do: "checkout/success" + defp default_slug(:orders), do: "orders" + defp default_slug(_), do: nil + + defp default_prefix(:products), do: "products" + defp default_prefix(:collections), do: "collections" + defp default_prefix(:orders), do: "orders" + defp default_prefix(_), do: nil + + # Map default slugs back to page types (for router) + defp default_page_type("cart"), do: :cart + defp default_page_type("about"), do: :about + defp default_page_type("contact"), do: :contact + defp default_page_type("search"), do: :search + defp default_page_type("delivery"), do: :delivery + defp default_page_type("privacy"), do: :privacy + defp default_page_type("terms"), do: :terms + defp default_page_type("orders"), do: :orders + defp default_page_type("checkout/success"), do: :checkout_success + defp default_page_type(_), do: nil + + # Map default prefixes back to types + defp default_prefix_type("products"), do: :products + defp default_prefix_type("collections"), do: :collections + defp default_prefix_type("orders"), do: :orders + defp default_prefix_type(_), do: nil +end diff --git a/test/berrypod_web/r_test.exs b/test/berrypod_web/r_test.exs new file mode 100644 index 0000000..20a88a0 --- /dev/null +++ b/test/berrypod_web/r_test.exs @@ -0,0 +1,89 @@ +defmodule BerrypodWeb.RTest do + use Berrypod.DataCase, async: false + + alias BerrypodWeb.R + alias Berrypod.Pages + + setup do + # Clear caches between tests + R.invalidate_all_sync() + Berrypod.Pages.PageCache.invalidate_all() + :ok + end + + describe "page path functions" do + test "returns default paths when no custom slugs set" do + assert R.cart() == "/cart" + assert R.about() == "/about" + assert R.contact() == "/contact" + assert R.search() == "/search" + end + + test "returns custom path after url_slug is set" do + {:ok, _} = Pages.update_page_url_slug("cart", "basket") + + assert R.cart() == "/basket" + end + + test "returns default path after url_slug is cleared" do + {:ok, _} = Pages.update_page_url_slug("about", "our-story") + assert R.about() == "/our-story" + + {:ok, _} = Pages.update_page_url_slug("about", "") + assert R.about() == "/about" + end + end + + describe "page/1 generic function" do + test "routes through specific functions for system pages" do + {:ok, _} = Pages.update_page_url_slug("cart", "basket") + + assert R.page("cart") == "/basket" + assert R.page("home") == "/" + end + + test "returns /slug for custom pages" do + assert R.page("my-custom-page") == "/my-custom-page" + end + end + + describe "reverse lookups" do + test "page_type_from_slug finds system page by custom url" do + {:ok, _} = Pages.update_page_url_slug("about", "our-story") + + assert R.page_type_from_slug("our-story") == {:page, :about} + end + + test "page_type_from_slug finds system page by default url" do + assert R.page_type_from_slug("cart") == {:page, :cart} + assert R.page_type_from_slug("about") == {:page, :about} + end + + test "page_type_from_slug returns nil for unknown slug" do + assert R.page_type_from_slug("nonexistent") == nil + end + + test "page_type_from_slug finds custom page" do + {:ok, _} = Pages.create_custom_page(%{slug: "faq", title: "FAQ", published: true}) + + assert {:custom, page} = R.page_type_from_slug("faq") + assert page.slug == "faq" + end + end + + describe "prefix functions" do + test "returns default prefixes" do + assert R.product(123) == "/products/123" + assert R.collection("art") == "/collections/art" + assert R.order("ORD-123") == "/orders/ORD-123" + end + end + + describe "url/1" do + test "prepends endpoint URL to path" do + url = R.url("/cart") + assert String.starts_with?(url, "http") + assert String.ends_with?(url, "/cart") + end + end +end