Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Beacon generates sitemap.xml per site and a sitemap_index.xml #687

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
3 changes: 2 additions & 1 deletion .formatter.exs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
locals_without_parens = [
beacon_site: 1,
beacon_site: 2
beacon_site: 2,
beacon_sitemap_index: 1
]

[
Expand Down
8 changes: 5 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,20 @@

## Unreleased

### Doc
### Enhancements
- Beacon will now automatically generate a `sitemap.xml` for each `beacon_site` defined in the Router
- Add macro `beacon_sitemap_index` for use in the Router to serve a sitemap index

### Doc
- Add missing instructions on Upgrade Guide v0.3.0 where to place the Beacon tuple

### Chore

- Exclude Dialyzer files from package


## 0.3.1 (2024-12-10)

### Fixes

- Avoid unloading imported dynamic Components modules without a replacement

## 0.3.0 (2024-12-05)
Expand Down
13 changes: 13 additions & 0 deletions lib/beacon/loader/routes.ex
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,19 @@ defmodule Beacon.Loader.Routes do
@endpoint.url() <> beacon_media_path(file_name)
end

def beacon_page_url(conn, %{path: path} = page) do
prefix = @router.__beacon_scoped_prefix_for_site__(@site)
APB9785 marked this conversation as resolved.
Show resolved Hide resolved
path = Path.join([@endpoint.url(), prefix, path])
Phoenix.VerifiedRoutes.unverified_path(conn, conn.private.phoenix_router, path)
end

def beacon_sitemap_url(conn) do
if prefix = @router.__beacon_scoped_prefix_for_site__(@site) do
path = Path.join([@endpoint.url(), prefix, "sitemap.xml"])
Phoenix.VerifiedRoutes.unverified_path(conn, conn.private.phoenix_router, path)
end
end

defp sanitize_path(path) do
String.replace(path, "//", "/")
end
Expand Down
71 changes: 67 additions & 4 deletions lib/beacon/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ defmodule Beacon.Router do
defp prelude do
quote do
Module.register_attribute(__MODULE__, :beacon_sites, accumulate: true)
import Beacon.Router, only: [beacon_site: 2]
import Beacon.Router, only: [beacon_site: 2, beacon_sitemap_index: 1]
@before_compile unquote(__MODULE__)
end
end
Expand All @@ -111,25 +111,28 @@ defmodule Beacon.Router do
@doc false
def __beacon_sites__, do: unquote(Macro.escape(sites))
unquote(prefixes)
def __beacon_scoped_prefix_for_site__(_), do: nil
end
end

@doc """
Mounts a site in the `prefix` in your host application router.

This will automatically serve a `sitemap.xml` file from the `prefix` path defined for this site.

## Options

* `:site` (required) `t:Beacon.Types.Site.t/0` - register your site with a unique name.
Note that the name has to match the one used in your site configuration.
See the module doc and `Beacon.Config` for more info.
* `:root_layout` - override the default root layout for the site. Defaults to `{Beacon.Web.Layouts, :runtime}`.
See `Beacon.Web.Layouts` and `Phoenix.LiveView.Router.live_session/3` for more info.
Use with caution.
See `Beacon.Web.Layouts` and `Phoenix.LiveView.Router.live_session/3` for more info.
Use with caution.

"""
defmacro beacon_site(prefix, opts) do
# TODO: raise on duplicated sites defined on the same prefix
quote bind_quoted: binding(), location: :keep do
quote bind_quoted: binding(), location: :keep, generated: true do
import Phoenix.Router, only: [scope: 3, get: 3, get: 4]
import Phoenix.LiveView.Router, only: [live: 3, live_session: 3]

Expand All @@ -145,6 +148,8 @@ defmodule Beacon.Router do
get "/__beacon_assets__/css-:md5", Beacon.Web.AssetsController, :css, assigns: %{site: opts[:site]}
get "/__beacon_assets__/js-:md5", Beacon.Web.AssetsController, :js, assigns: %{site: opts[:site]}

get "/sitemap.xml", Beacon.Web.SitemapController, :show, as: :beacon_sitemap, assigns: %{site: opts[:site]}

live "/*path", Beacon.Web.PageLive, :path
end
end
Expand All @@ -153,6 +158,64 @@ defmodule Beacon.Router do
end
end

@doc """
Creates a sitemap index at the given path (including the filename and extension).

## Example

defmodule MyApp.Router do
...
scope "/" do
pipe_through :browser

beacon_sitemap_index "/sitemap_index.xml"

beacon_site "/other", site: :other
beacon_site "/", site: :home
end
end

In the above example, there are two Beacon sites, so Beacon will serve two sitemaps:
* `my_domain.com/sitemap.xml` for site `:home`
* `my_domain.com/other/sitemap.xml` for site `:other`

Then Beacon will reference both of those sitemaps in the top-level index:
* `my_domain.com/sitemap_index.xml`

## Requirements

Note that your sitemap index cannot have a path which is "deeper" in the directory structure than
your Beacon sites (which will be contained in the index).

For example, the following is NOT allowed:

scope "/" do
...
beacon_sitemap_index "/root/nested/sitemap_index.xml"

beacon_site "/root", site: :root
end

However, the opposite case (nesting the sites deeper than the index) is perfectly fine:

scope "/" do
...
beacon_sitemap_index "/sitemap_index.xml"

beacon_site "/nested/path/to/site", site: :nested
end

"""
defmacro beacon_sitemap_index(path_with_filename) do
quote bind_quoted: binding(), location: :keep, generated: true do
import Phoenix.Router, only: [scope: 3, get: 4]

scope "/", alias: false, as: false do
get path_with_filename, Beacon.Web.SitemapController, :index, as: :beacon_sitemap
end
end
end

@doc false
@spec __options__(keyword()) :: {atom(), atom(), keyword()}
def __options__(opts) do
Expand Down
45 changes: 45 additions & 0 deletions lib/beacon/web/controllers/sitemap_controller.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
defmodule Beacon.Web.SitemapController do
@moduledoc false
use Beacon.Web, :controller

def init(action) when action in [:index, :show], do: action

def call(conn, :index) do
conn
|> put_view(Beacon.Web.SitemapXML)
|> put_resp_content_type("text/xml")
|> put_resp_header("cache-control", "public max-age=300")
|> render(:sitemap_index, urls: get_sitemap_urls(conn))
end

def call(%{assigns: %{site: site}} = conn, :show) do
conn
|> put_view(Beacon.Web.SitemapXML)
|> put_resp_content_type("text/xml")
|> put_resp_header("cache-control", "public max-age=300")
|> render(:sitemap, pages: get_pages(conn, site))
end

defp get_sitemap_urls(conn) do
Beacon.Registry.running_sites()
|> Enum.map(fn site ->
routes_module = Beacon.Loader.fetch_routes_module(site)
Beacon.apply_mfa(site, routes_module, :beacon_sitemap_url, [conn])
end)
|> Enum.reject(&is_nil/1)
|> Enum.sort()
end

defp get_pages(conn, site) do
routes_module = Beacon.Loader.fetch_routes_module(site)

site
|> Beacon.Content.list_published_pages()
|> Enum.map(fn page ->
%{
loc: Beacon.apply_mfa(site, routes_module, :beacon_page_url, [conn, page]),
lastmod: DateTime.to_iso8601(page.updated_at)
}
end)
end
end
7 changes: 7 additions & 0 deletions lib/beacon/web/sitemap/sitemap.xml.eex
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<%= for page <- @pages do %><url>
<loc><%= page.loc %></loc>
<lastmod><%= page.lastmod %></lastmod>
</url><% end %>
</urlset>
6 changes: 6 additions & 0 deletions lib/beacon/web/sitemap/sitemap_index.xml.eex
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<%= for url <- @urls do %><sitemap>
<loc><%= url %></loc>
</sitemap><% end %>
</sitemapindex>
4 changes: 4 additions & 0 deletions lib/beacon/web/sitemap_xml.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
defmodule Beacon.Web.SitemapXML do
import Phoenix.Template, only: [embed_templates: 1]
embed_templates "sitemap/*.xml"
end
6 changes: 0 additions & 6 deletions test/beacon_web/controllers/media_library_controller_test.exs
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
defmodule Beacon.Web.Controllers.MediaLibraryControllerTest do
use Beacon.Web.ConnCase, async: true

setup do
Process.flag(:error_handler, Beacon.ErrorHandler)
Process.put(:__beacon_site__, :my_site)
:ok
end

test "show", %{conn: conn} do
%{file_name: file_name} = Beacon.Test.Fixtures.beacon_media_library_asset_fixture(site: :my_site)
routes = Beacon.Loader.fetch_routes_module(:my_site)
Expand Down
60 changes: 60 additions & 0 deletions test/beacon_web/controllers/sitemap_controller_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
defmodule Beacon.Web.SitemapControllerTest do
use Beacon.Web.ConnCase, async: false

setup do
site = :my_site

layout =
beacon_published_layout_fixture(
site: site,
template: """
<header>Page header</header>
<%= @inner_content %>
<footer>Page footer</footer>
"""
)

page = beacon_published_page_fixture(site: site, path: "/foo", layout_id: layout.id)

routes = Beacon.Loader.fetch_routes_module(site)

[site: site, layout: layout, page: page, routes: routes]
end

test "index", %{conn: conn} do

Check failure on line 24 in test/beacon_web/controllers/sitemap_controller_test.exs

View workflow job for this annotation

GitHub Actions / test: OTP 23 | Elixir 1.14.0 | Phoenix 1.7.0 | LV 0.20.2 | PG 13.0-alpine

test index (Beacon.Web.SitemapControllerTest)

Check failure on line 24 in test/beacon_web/controllers/sitemap_controller_test.exs

View workflow job for this annotation

GitHub Actions / test: OTP 27 | Elixir 1.17 | Phoenix ~> 1.7 | LV ~> 1.0 | PG 16.4-alpine

test index (Beacon.Web.SitemapControllerTest)
conn = get(conn, "/sitemap_index.xml")

assert response(conn, 200) == """
&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;
&lt;sitemapindex xmlns=&quot;http://www.sitemaps.org/schemas/sitemap/0.9&quot;&gt;
&lt;sitemap&gt;
&lt;loc&gt;http://host.com/sitemap.xml&lt;/loc&gt;
&lt;/sitemap&gt;&lt;sitemap&gt;
&lt;loc&gt;http://localhost:4000/nested/media/sitemap.xml&lt;/loc&gt;
&lt;/sitemap&gt;&lt;sitemap&gt;
&lt;loc&gt;http://localhost:4000/nested/site/sitemap.xml&lt;/loc&gt;
&lt;/sitemap&gt;&lt;sitemap&gt;
&lt;loc&gt;http://localhost:4000/other/sitemap.xml&lt;/loc&gt;
&lt;/sitemap&gt;&lt;sitemap&gt;
&lt;loc&gt;http://localhost:4000/sitemap.xml&lt;/loc&gt;
&lt;/sitemap&gt;
&lt;/sitemapindex&gt;
"""
end

test "show", %{conn: conn, page: page, routes: routes} do
conn = get(conn, "/sitemap.xml")

assert response(conn, 200) == """
&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;
&lt;urlset xmlns=&quot;http://www.sitemaps.org/schemas/sitemap/0.9&quot;&gt;
&lt;url&gt;
&lt;loc&gt;#{routes.beacon_page_url(conn, page)}&lt;/loc&gt;
&lt;lastmod&gt;#{DateTime.to_iso8601(page.updated_at)}&lt;/lastmod&gt;
&lt;/url&gt;
&lt;/urlset&gt;
"""

assert response_content_type(conn, :xml) =~ "charset=utf-8"
end
end
4 changes: 2 additions & 2 deletions test/mix/tasks/install_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ defmodule Mix.Tasks.Beacon.InstallTest do
project
|> Igniter.compose_task("beacon.install")
|> assert_has_patch(".formatter.exs", """
7 - | import_deps: [:ecto, :ecto_sql, :phoenix],
7 + | import_deps: [:beacon, :ecto, :ecto_sql, :phoenix],
8 - | import_deps: [:ecto, :ecto_sql, :phoenix],
8 + | import_deps: [:beacon, :ecto, :ecto_sql, :phoenix],
""")
end

Expand Down
6 changes: 6 additions & 0 deletions test/support/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ defmodule Beacon.BeaconTest.Router do
plug :put_secure_browser_headers
end

scope "/" do
pipe_through :browser
beacon_sitemap_index "/sitemap_index.xml"
end

scope "/nested" do
pipe_through :browser
beacon_site "/site", site: :booted
Expand All @@ -33,6 +38,7 @@ defmodule Beacon.BeaconTest.Router do
# `alias` is not really used but is present here to verify that `beacon_site` has no conflicts with custom aliases
scope path: "/", alias: AnyAlias do
pipe_through :browser

beacon_site "/other", site: :not_booted
beacon_site "/", site: :my_site
end
Expand Down
Loading