From 4b277f3b42bbe1be9178c85eeb0c64d22e2b5c81 Mon Sep 17 00:00:00 2001 From: Leandro Pereira Date: Sun, 30 Aug 2026 17:11:50 -0400 Subject: [PATCH] ci: enforce cyclomatic complexity ceiling with strict linting --- .credo.exs | 43 + .github/workflows/javascript.yml | 33 + .github/workflows/quality.yml | 2 + assets/.eslintrc.js | 15 - assets/.oxlintrc.json | 27 + assets/js/beacon.js | 10 +- assets/package.json | 4 +- lib/beacon/actions/interpreter.ex | 28 +- lib/beacon/beacon.ex | 58 +- lib/beacon/cache.ex | 26 +- lib/beacon/circuit_breaker.ex | 23 +- lib/beacon/client/filters.ex | 14 +- lib/beacon/config.ex | 36 +- lib/beacon/content.ex | 151 ++- lib/beacon/content/component.ex | 7 +- lib/beacon/content/component_slot.ex | 6 +- lib/beacon/content/redirect_cache.ex | 20 +- lib/beacon/css/theme_parser.ex | 171 +-- lib/beacon/graphql/client.ex | 70 +- lib/beacon/graphql/introspection.ex | 30 +- lib/beacon/media_library/provider.ex | 2 +- lib/beacon/migrations/graphql_migrator.ex | 85 +- lib/beacon/migrations/v006.ex | 10 +- lib/beacon/page_render_cache.ex | 61 +- lib/beacon/proxy_endpoint.ex | 40 +- lib/beacon/runtime_renderer.ex | 1027 ++++++++--------- .../runtime_renderer/pub_sub_handler.ex | 1 - lib/beacon/seo/index_now.ex | 49 +- lib/beacon/seo/json_ld.ex | 77 +- lib/beacon/seo/link_extractor.ex | 22 +- lib/beacon/seo/metrics.ex | 163 +-- lib/beacon/template.ex | 14 +- lib/beacon/template/expression_parser.ex | 49 +- lib/beacon/template/formatter.ex | 1 + lib/beacon/template/heex_converter.ex | 204 ++-- lib/beacon/template/helpers.ex | 24 +- lib/beacon/web/api/ast_controller.ex | 17 +- lib/beacon/web/components/layouts.ex | 140 +-- lib/beacon/web/data_source.ex | 18 +- lib/beacon/web/live/page_live.ex | 241 ++-- mix.exs | 4 +- mix.lock | 2 + test/beacon/client/filters_test.exs | 2 +- test/beacon/content_test.exs | 4 +- test/beacon/runtime_renderer_test.exs | 1 - test/support/beacon_web.ex | 2 + test/support/bypass_helpers.ex | 2 + test/support/data_case.ex | 2 + test/support/endpoints.ex | 4 + test/support/page_fields.ex | 2 + test/support/routers.ex | 2 + 51 files changed, 1603 insertions(+), 1443 deletions(-) create mode 100644 .credo.exs create mode 100644 .github/workflows/javascript.yml delete mode 100644 assets/.eslintrc.js create mode 100644 assets/.oxlintrc.json diff --git a/.credo.exs b/.credo.exs new file mode 100644 index 000000000..e536faca0 --- /dev/null +++ b/.credo.exs @@ -0,0 +1,43 @@ +# Credo runs in strict mode, so `mix credo` locally reports exactly what CI +# reports. The settings below are the ones this repository pins on purpose; +# everything else is Credo's own default. +# +# Waived checks carry the reason and the number of sites they fired on when the +# waiver was written. The list is allowed to shrink and not to grow. +%{ + configs: [ + %{ + name: "default", + strict: true, + files: %{ + included: ["lib/", "src/", "test/", "config/", "mix.exs"], + excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] + }, + checks: %{ + extra: [ + # Classic McCabe cyclomatic complexity, at the ceiling shared by every + # project in this family, in Elixir and in JavaScript alike. + {Credo.Check.Refactor.CyclomaticComplexity, max_complexity: 9}, + # Credo's strict default, or `line_length` from .formatter.exs where + # that is larger, so the formatter and the linter cannot disagree + # about a line the formatter itself produced. + {Credo.Check.Readability.MaxLineLength, max_length: 150} + ], + disabled: [ + # TODO and FIXME notes are tracked in the issue tracker. Failing a + # build on one only encourages deleting the note. + {Credo.Check.Design.TagTODO, []}, + {Credo.Check.Design.TagFIXME, []}, + # 109 sites. Whether a call is written out or aliased is a question of + # naming, not of complexity, and a sweep would touch most files in the + # tree at once. Worth doing as a change of its own. + {Credo.Check.Design.AliasUsage, []}, + # 1 site: `Beacon.Config`, at 42 fields. That struct is the site + # configuration; every field is one documented option, and splitting + # it would change the public API rather than reduce anything. + {Credo.Check.Warning.StructFieldAmount, []} + ] + } + } + ] +} diff --git a/.github/workflows/javascript.yml b/.github/workflows/javascript.yml new file mode 100644 index 000000000..04d459fc5 --- /dev/null +++ b/.github/workflows/javascript.yml @@ -0,0 +1,33 @@ +name: JavaScript + +on: + push: + branches: + - main + paths: + - 'assets/**' + - '.github/workflows/javascript.yml' + pull_request: + paths: + - 'assets/**' + - '.github/workflows/javascript.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + # Run straight from the registry: oxlint is a single binary, and nothing + # else in `assets/` is needed to lint it. + - run: npx --yes oxlint@1.80.0 + working-directory: assets diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index e3092c2b1..b113d5626 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -55,3 +55,5 @@ jobs: - run: mix compile --warnings-as-errors - run: mix deps.unlock --check-unused + + - run: mix credo --strict diff --git a/assets/.eslintrc.js b/assets/.eslintrc.js deleted file mode 100644 index c06cf280a..000000000 --- a/assets/.eslintrc.js +++ /dev/null @@ -1,15 +0,0 @@ -module.exports = { - env: { - browser: true, - node: true, - es2021: true, - }, - extends: ["eslint:recommended", "prettier"], - globals: { - global: "writable", - }, - parserOptions: { - ecmaVersion: 12, - sourceType: "module", - }, -} diff --git a/assets/.oxlintrc.json b/assets/.oxlintrc.json new file mode 100644 index 000000000..c2f94f2ea --- /dev/null +++ b/assets/.oxlintrc.json @@ -0,0 +1,27 @@ +{ + // Every JavaScript file this repository authors, at oxlint's strict + // categories. `mix assets.lint` and the `javascript.yml` workflow both run + // oxlint from this directory, so a local run and the CI run report the same + // thing. + // + // Rules below are either configured (the rule stays on, with the option that + // matches how this repository writes) or waived, with the reason and the + // number of sites it fired on when it was written down. The waivers shrink. + "plugins": ["import", "node", "oxc", "promise", "unicorn"], + "categories": { + "correctness": "error", + "pedantic": "error", + "perf": "error", + "suspicious": "error" + }, + "ignorePatterns": ["node_modules/**", "vendor/**"], + "rules": { + // Classic McCabe complexity, at the ceiling `.credo.exs` enforces on the + // Elixir in this repository and every sibling project enforces on both. + "eslint/complexity": ["error", { "max": 9, "variant": "classic" }], + // A line count is not a complexity measure; `complexity` above is. + "eslint/max-lines": "off", + "eslint/max-lines-per-function": "off", + "eslint/no-inline-comments": "off" + } +} diff --git a/assets/js/beacon.js b/assets/js/beacon.js index 7ee3e275f..2b54b6579 100644 --- a/assets/js/beacon.js +++ b/assets/js/beacon.js @@ -5,18 +5,18 @@ // 2. phoenix js loaded from the host application window.addEventListener("phx:beacon:css-ready", (e) => { - let link = document.getElementById("beacon-runtime-stylesheet") + let link = document.querySelector("#beacon-runtime-stylesheet") if (link) { link.href = e.detail.href } }) window.addEventListener("phx:beacon:page-updated", (e) => { - if (e.detail.hasOwnProperty("runtime_css_path")) { - document.getElementById("beacon-runtime-stylesheet").href = e.detail.runtime_css_path + if (Object.prototype.hasOwnProperty.call(e.detail, "runtime_css_path")) { + document.querySelector("#beacon-runtime-stylesheet").href = e.detail.runtime_css_path } - if (e.detail.hasOwnProperty("meta_tags")) { + if (Object.prototype.hasOwnProperty.call(e.detail, "meta_tags")) { // remove current tags, except csrf-token document.querySelectorAll("meta:not([name='csrf-token'])").forEach((el) => el.remove()) @@ -28,7 +28,7 @@ window.addEventListener("phx:beacon:page-updated", (e) => { newMetaTag.setAttribute(key, metaTag[key]) }) - document.getElementsByTagName("head")[0].appendChild(newMetaTag) + document.head.append(newMetaTag) }) } }) diff --git a/assets/package.json b/assets/package.json index 6c0887f37..722b07d85 100644 --- a/assets/package.json +++ b/assets/package.json @@ -5,9 +5,11 @@ "repository": {}, "scripts": { "format": "prettier --write .", - "format-check": "prettier --check ." + "format-check": "prettier --check .", + "lint": "oxlint" }, "devDependencies": { + "oxlint": "^1.80", "prettier": "^3.2" }, "dependencies": { diff --git a/lib/beacon/actions/interpreter.ex b/lib/beacon/actions/interpreter.ex index 817f26ced..b6c3182a3 100644 --- a/lib/beacon/actions/interpreter.ex +++ b/lib/beacon/actions/interpreter.ex @@ -306,20 +306,7 @@ defmodule Beacon.Actions.Interpreter do end defp evaluate_test(%{"path" => path, "op" => op, "value" => expected}, ctx) do - actual = resolve_value("$" <> path, ctx) - - case op do - "eq" -> actual == expected - "neq" -> actual != expected - "gt" -> is_number(actual) and actual > expected - "lt" -> is_number(actual) and actual < expected - "gte" -> is_number(actual) and actual >= expected - "lte" -> is_number(actual) and actual <= expected - "contains" -> is_binary(actual) and String.contains?(actual, expected) - "exists" -> actual != nil - "not_exists" -> actual == nil - _ -> false - end + compare(op, resolve_value("$" <> path, ctx), expected) end defp evaluate_test(%{"field" => field, "op" => op, "value" => expected}, ctx) do @@ -328,10 +315,23 @@ defmodule Beacon.Actions.Interpreter do defp evaluate_test(_, _ctx), do: false + defp compare("eq", actual, expected), do: actual == expected + defp compare("neq", actual, expected), do: actual != expected + defp compare("gt", actual, expected), do: is_number(actual) and actual > expected + defp compare("lt", actual, expected), do: is_number(actual) and actual < expected + defp compare("gte", actual, expected), do: is_number(actual) and actual >= expected + defp compare("lte", actual, expected), do: is_number(actual) and actual <= expected + defp compare("contains", actual, expected), do: is_binary(actual) and String.contains?(actual, expected) + defp compare("exists", actual, _expected), do: actual != nil + defp compare("not_exists", actual, _expected), do: actual == nil + defp compare(_op, _actual, _expected), do: false + defp get_nested(nil, _), do: nil defp get_nested(value, []), do: value + defp get_nested(value, [key | rest]) when is_map(value) do get_nested(Map.get(value, key) || Map.get(value, String.to_atom(key)), rest) end + defp get_nested(_, _), do: nil end diff --git a/lib/beacon/beacon.ex b/lib/beacon/beacon.ex index 07c8cc0fc..fab834856 100644 --- a/lib/beacon/beacon.ex +++ b/lib/beacon/beacon.ex @@ -106,42 +106,46 @@ defmodule Beacon do [] end - site_children = - Enum.reduce(sites, [], fn opts, acc -> - config = Beacon.Config.new(opts) + site_children = Enum.reduce(sites, [], &maybe_start_site/2) - if Beacon.Config.env_test?() do - [site_child_spec(config) | acc] - else - # we only care about starting sites that are valid and reachable - case Beacon.Router.reachable(config) do - {:ok, _} -> - [site_child_spec(config) | acc] + Supervisor.init(finch_children ++ vault_children ++ site_children, strategy: :one_for_one) + end - {:error, {endpoint, host}} -> - Logger.warning(""" - site #{config.site} is not reachable on host #{host} and will not be started + # We only care about starting sites that are valid and reachable. + defp maybe_start_site(opts, acc) do + config = Beacon.Config.new(opts) - Check both the Router and #{inspect(endpoint)} configuratation + if Beacon.Config.env_test?() do + [site_child_spec(config) | acc] + else + case Beacon.Router.reachable(config) do + {:ok, _} -> [site_child_spec(config) | acc] + {:error, {endpoint, host}} -> warn_unreachable(config, endpoint, host, acc) + :error -> warn_invalid(config, acc) + end + end + end + + defp warn_unreachable(config, endpoint, host, acc) do + Logger.warning(""" + site #{config.site} is not reachable on host #{host} and will not be started - See https://hexdocs.pm/beacon/troubleshooting.html for more info. - """) + Check both the Router and #{inspect(endpoint)} configuratation - acc + See https://hexdocs.pm/beacon/troubleshooting.html for more info. + """) - :error -> - Logger.warning(""" - site #{config.site} is not reachable or is invalid, it will not be started + acc + end - See https://hexdocs.pm/beacon/troubleshooting.html for more info. - """) + defp warn_invalid(config, acc) do + Logger.warning(""" + site #{config.site} is not reachable or is invalid, it will not be started - acc - end - end - end) + See https://hexdocs.pm/beacon/troubleshooting.html for more info. + """) - Supervisor.init(finch_children ++ vault_children ++ site_children, strategy: :one_for_one) + acc end defp site_child_spec(%Beacon.Config{} = config) do diff --git a/lib/beacon/cache.ex b/lib/beacon/cache.ex index 59a034716..99969892a 100644 --- a/lib/beacon/cache.ex +++ b/lib/beacon/cache.ex @@ -70,11 +70,15 @@ defmodule Beacon.Cache do :ets.foldl( fn - {_key, {:__loading__, _, _}}, acc -> acc + {_key, {:__loading__, _, _}}, acc -> + acc + {key, {_value, inserted_at}}, acc when inserted_at < cutoff -> :ets.delete(table, key) acc + 1 - _, acc -> acc + + _, acc -> + acc end, 0, table @@ -90,16 +94,14 @@ defmodule Beacon.Cache do end defp run_load(table, key, ref, load_fun) do - try do - value = load_fun.() - :ets.insert(table, {key, {value, System.monotonic_time(:second)}}) - value - catch - kind, reason -> - # Clean up only OUR sentinel - :ets.match_delete(table, {key, {:__loading__, ref, self()}}) - :erlang.raise(kind, reason, __STACKTRACE__) - end + value = load_fun.() + :ets.insert(table, {key, {value, System.monotonic_time(:second)}}) + value + catch + kind, reason -> + # Clean up only OUR sentinel + :ets.match_delete(table, {key, {:__loading__, ref, self()}}) + :erlang.raise(kind, reason, __STACKTRACE__) end defp await_result(table, key, ref, loader_pid, load_fun, ttl) do diff --git a/lib/beacon/circuit_breaker.ex b/lib/beacon/circuit_breaker.ex index 8735acf8d..3ebd62781 100644 --- a/lib/beacon/circuit_breaker.ex +++ b/lib/beacon/circuit_breaker.ex @@ -29,19 +29,20 @@ defmodule Beacon.CircuitBreaker do :ok else case :ets.lookup(@table, {site, path}) do - [{_, tripped_at, ttl}] -> - elapsed = System.monotonic_time(:second) - tripped_at + [{_, tripped_at, ttl}] -> check_elapsed(site, path, tripped_at, ttl) + [] -> :ok + end + end + end - if elapsed < ttl do - {:tripped, ttl - elapsed} - else - :ets.delete(@table, {site, path}) - :ok - end + defp check_elapsed(site, path, tripped_at, ttl) do + elapsed = System.monotonic_time(:second) - tripped_at - [] -> - :ok - end + if elapsed < ttl do + {:tripped, ttl - elapsed} + else + :ets.delete(@table, {site, path}) + :ok end end diff --git a/lib/beacon/client/filters.ex b/lib/beacon/client/filters.ex index c93478a98..a4b174f1d 100644 --- a/lib/beacon/client/filters.ex +++ b/lib/beacon/client/filters.ex @@ -1,4 +1,8 @@ defmodule Beacon.Client.Filters do + # credo:disable-for-this-file Credo.Check.Refactor.Apply + # + # This module defines `apply/3` as its public entry point, and Credo reads the + # heads of the clauses whose third argument is a list as calls to `Kernel.apply/3`. @moduledoc """ Built-in filter implementations for Beacon template rendering. @@ -24,7 +28,9 @@ defmodule Beacon.Client.Filters do def apply("format_date", value, [format]) when is_binary(value) do case DateTime.from_iso8601(value) do - {:ok, dt, _} -> Calendar.strftime(dt, format) + {:ok, dt, _} -> + Calendar.strftime(dt, format) + _ -> case NaiveDateTime.from_iso8601(value) do {:ok, ndt} -> Calendar.strftime(ndt, format) @@ -108,7 +114,7 @@ defmodule Beacon.Client.Filters do def apply("first", [head | _], _), do: head def apply("first", _, _), do: nil - def apply("last", list, _) when is_list(list) and length(list) > 0, do: List.last(list) + def apply("last", [_ | _] = list, _), do: List.last(list) def apply("last", _, _), do: nil # -- Utility -- @@ -132,8 +138,8 @@ defmodule Beacon.Client.Filters do cond do diff < 60 -> "just now" diff < 3600 -> "#{div(diff, 60)} minutes ago" - diff < 86400 -> "#{div(diff, 3600)} hours ago" - diff < 2_592_000 -> "#{div(diff, 86400)} days ago" + diff < 86_400 -> "#{div(diff, 3600)} hours ago" + diff < 2_592_000 -> "#{div(diff, 86_400)} days ago" diff < 31_536_000 -> "#{div(diff, 2_592_000)} months ago" true -> "#{div(diff, 31_536_000)} years ago" end diff --git a/lib/beacon/config.ex b/lib/beacon/config.ex index 99513ef22..d846e948a 100644 --- a/lib/beacon/config.ex +++ b/lib/beacon/config.ex @@ -12,8 +12,8 @@ defmodule Beacon.Config do @doc false use GenServer - alias Beacon.Content alias Beacon.ConfigError + alias Beacon.Content @doc false def name(site) do @@ -537,10 +537,7 @@ defmodule Beacon.Config do def new(opts) do # TODO: validate opts, maybe use nimble_options - opts[:site] || raise ConfigError, "missing required option :site" - opts[:endpoint] || raise ConfigError, "missing required option :endpoint" - opts[:router] || raise ConfigError, "missing required option :router" - ensure_repo(opts[:repo]) + validate_required!(opts) tailwind_css = get_opt(opts, :tailwind_css, Path.join(Application.app_dir(:beacon, "priv"), "tailwind.css")) @@ -553,15 +550,7 @@ defmodule Beacon.Config do get_opt(opts, :template_formats, []) ) - lifecycle = [ - load_template: Keyword.merge(@default_load_template, get_in(opts, [:lifecycle, :load_template]) || []), - render_template: Keyword.merge(@default_render_template, get_in(opts, [:lifecycle, :render_template]) || []), - after_create_page: get_in(opts, [:lifecycle, :after_create_page]) || [], - after_update_page: get_in(opts, [:lifecycle, :after_update_page]) || [], - after_publish_page: get_in(opts, [:lifecycle, :after_publish_page]) || [], - after_unpublish_page: get_in(opts, [:lifecycle, :after_unpublish_page]) || [], - upload_asset: get_in(opts, [:lifecycle, :upload_asset]) || [thumbnail: &Beacon.Lifecycle.Asset.thumbnail/2] - ] + lifecycle = lifecycle_opts(opts) allowed_media_accept_types = get_opt(opts, :allowed_media_accept_types, @default_media_types) validate_allowed_media_accept_types!(allowed_media_accept_types) @@ -598,6 +587,25 @@ defmodule Beacon.Config do ) end + defp validate_required!(opts) do + opts[:site] || raise ConfigError, "missing required option :site" + opts[:endpoint] || raise ConfigError, "missing required option :endpoint" + opts[:router] || raise ConfigError, "missing required option :router" + ensure_repo(opts[:repo]) + end + + defp lifecycle_opts(opts) do + [ + load_template: Keyword.merge(@default_load_template, get_in(opts, [:lifecycle, :load_template]) || []), + render_template: Keyword.merge(@default_render_template, get_in(opts, [:lifecycle, :render_template]) || []), + after_create_page: get_in(opts, [:lifecycle, :after_create_page]) || [], + after_update_page: get_in(opts, [:lifecycle, :after_update_page]) || [], + after_publish_page: get_in(opts, [:lifecycle, :after_publish_page]) || [], + after_unpublish_page: get_in(opts, [:lifecycle, :after_unpublish_page]) || [], + upload_asset: get_in(opts, [:lifecycle, :upload_asset]) || [thumbnail: &Beacon.Lifecycle.Asset.thumbnail/2] + ] + end + # Get `key` from `opts` keyword, otherwise returns `default` even if the key is present but returns `nil`. defp get_opt(opts, key, default), do: Keyword.get(opts, key) || default diff --git a/lib/beacon/content.ex b/lib/beacon/content.ex index 03aad413c..e441102c3 100644 --- a/lib/beacon/content.ex +++ b/lib/beacon/content.ex @@ -38,19 +38,19 @@ defmodule Beacon.Content do alias Beacon.Content.EventHandler alias Beacon.Content.GraphQLEndpoint alias Beacon.Content.InfoHandler + alias Beacon.Content.InternalLink alias Beacon.Content.JSHook alias Beacon.Content.Layout alias Beacon.Content.LayoutEvent alias Beacon.Content.LayoutSnapshot alias Beacon.Content.Page - alias Beacon.Content.InternalLink alias Beacon.Content.PageEvent - alias Beacon.Content.Redirect - alias Beacon.Content.SEOSnapshot alias Beacon.Content.PageField alias Beacon.Content.PageQuery alias Beacon.Content.PageSnapshot alias Beacon.Content.PageVariant + alias Beacon.Content.Redirect + alias Beacon.Content.SEOSnapshot alias Beacon.Content.SiteSetting alias Beacon.Content.Snippets alias Beacon.Content.Stylesheet @@ -646,7 +646,7 @@ defmodule Beacon.Content do @doc type: :pages @spec list_stale_pages(Site.t(), non_neg_integer()) :: [Page.t()] def list_stale_pages(site, days \\ 90) when is_atom(site) do - cutoff = DateTime.utc_now() |> DateTime.add(-days * 86400, :second) + cutoff = DateTime.utc_now() |> DateTime.add(-days * 86_400, :second) from(p in Page, where: p.site == ^site and (is_nil(p.date_modified) or p.date_modified < ^cutoff), @@ -694,6 +694,15 @@ defmodule Beacon.Content do end # TODO: only publish if there were actual changes compared to the last snapshot + defp publish_page_snapshot(page) do + transact(repo(page), fn -> + with {:ok, event} <- create_page_event(page, "published"), + {:ok, _snapshot} <- create_page_snapshot(page, event) do + {:ok, page} + end + end) + end + @doc """ Publish multiple `pages`. @@ -703,18 +712,9 @@ defmodule Beacon.Content do @doc type: :pages @spec publish_pages([Page.t()]) :: {:ok, [Page.t()]} def publish_pages(pages) when is_list(pages) do - publish = fn page -> - transact(repo(page), fn -> - with {:ok, event} <- create_page_event(page, "published"), - {:ok, _snapshot} <- create_page_snapshot(page, event) do - {:ok, page} - end - end) - end - pages = pages - |> Enum.map(&publish.(&1)) + |> Enum.map(&publish_page_snapshot/1) |> Enum.map(fn {:ok, %Page{} = page} -> Lifecycle.Page.after_publish_page(page) _ -> nil @@ -796,7 +796,22 @@ defmodule Beacon.Content do "fields" => page.fields || %{} } - fields = [:site, :schema_version, :event_id, :page, :page_id, :path, :title, :template, :format, :extra, :ast, :date_modified, :collection_id, :fields] + fields = [ + :site, + :schema_version, + :event_id, + :page, + :page_id, + :path, + :title, + :template, + :format, + :extra, + :ast, + :date_modified, + :collection_id, + :fields + ] result = %PageSnapshot{} @@ -1260,6 +1275,7 @@ defmodule Beacon.Content do defp extract_page_snapshot(%{schema_version: 3, page: %Page{} = page, ast: ast}) do page = maybe_add_leading_slash(page) + case unwrap_ast(ast) do nodes when is_list(nodes) -> %{page | ast: nodes} _ -> page @@ -1273,6 +1289,7 @@ defmodule Beacon.Content do defp extract_page_snapshot(%{schema_version: 4, page: %Page{} = page, ast: ast}) do page = maybe_add_leading_slash(page) + case unwrap_ast(ast) do nodes when is_list(nodes) -> %{page | ast: nodes} _ -> page @@ -1286,6 +1303,7 @@ defmodule Beacon.Content do defp extract_page_snapshot(%{schema_version: 5, page: %Page{} = page, ast: ast}) do page = maybe_add_leading_slash(page) + case unwrap_ast(ast) do nodes when is_list(nodes) -> %{page | ast: nodes} _ -> page @@ -1299,6 +1317,7 @@ defmodule Beacon.Content do defp extract_page_snapshot(%{schema_version: 6, page: %Page{} = page, ast: ast}) do page = maybe_add_leading_slash(page) + case unwrap_ast(ast) do nodes when is_list(nodes) -> %{page | ast: nodes} _ -> page @@ -3303,17 +3322,19 @@ defmodule Beacon.Content do component {:error, changeset} -> - errors = - Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> - Regex.replace(~r"%{(\w+)}", msg, fn _, key -> - opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() - end) - end) - - raise "failed to create component: #{inspect(errors)}" + raise "failed to create component: #{inspect(traverse_interpolated_errors(changeset))}" end end + # Ecto's `%{count}` placeholders resolved against the error's own options. + defp traverse_interpolated_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Regex.replace(~r"%{(\w+)}", msg, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + end + @doc """ Updates a component. @@ -3562,25 +3583,24 @@ defmodule Beacon.Content do cond do value == nil -> changeset type == "any" or type == "global" -> changeset - type == "string" and is_binary(value) -> changeset - type == "string" -> Changeset.add_error(changeset, field, "it must be a string when type is 'string'") - type == "atom" and is_atom(value) -> changeset - type == "atom" -> Changeset.add_error(changeset, field, "it must be an atom when type is 'atom'") - type == "boolean" and is_boolean(value) -> changeset - type == "boolean" -> Changeset.add_error(changeset, field, "it must be a boolean when type is 'boolean'") - type == "integer" and is_integer(value) -> changeset - type == "integer" -> Changeset.add_error(changeset, field, "it must be a integer when type is 'integer'") - type == "float" and is_float(value) -> changeset - type == "float" -> Changeset.add_error(changeset, field, "it must be a float when type is 'float'") - type == "list" and is_list(value) -> changeset - type == "list" -> Changeset.add_error(changeset, field, "it must be a list when type is 'list'") - type == "map" and is_map(value) -> changeset - type == "map" -> Changeset.add_error(changeset, field, "it must be a map when type is 'map'") - type == "struct" and is_struct(value) -> changeset - type == "struct" -> Changeset.add_error(changeset, field, "it must be a struct when type is 'struct'") + value_matches_type?(type, value) -> changeset + true -> Changeset.add_error(changeset, field, "it must be #{type_article(type)} when type is '#{type}'") end end + # An unknown type raises here, as it did when this was one long `cond`. + defp value_matches_type?("string", value), do: is_binary(value) + defp value_matches_type?("atom", value), do: is_atom(value) + defp value_matches_type?("boolean", value), do: is_boolean(value) + defp value_matches_type?("integer", value), do: is_integer(value) + defp value_matches_type?("float", value), do: is_float(value) + defp value_matches_type?("list", value), do: is_list(value) + defp value_matches_type?("map", value), do: is_map(value) + defp value_matches_type?("struct", value), do: is_struct(value) + + defp type_article("atom"), do: "an atom" + defp type_article(type), do: "a #{type}" + # COMPONENT SLOT ATTR @doc """ @@ -4534,7 +4554,9 @@ defmodule Beacon.Content do clear_cache(site, id) case get_published_page(site, id) do - nil -> :skip + nil -> + :skip + page -> :ok = Beacon.RouterServer.add_page(page.site, page.id, page.path) Beacon.RuntimeRenderer.Loader.load_page(site, page) @@ -4546,21 +4568,7 @@ defmodule Beacon.Content do end defp do_publish_layout(layout) do - %{site: site} = layout - - publish = fn layout -> - changeset = Layout.changeset(layout, %{}) - - transact(repo(site), fn -> - with {:ok, _changeset} <- validate_layout_template(changeset), - {:ok, event} <- create_layout_event(layout, "published"), - {:ok, _snapshot} <- create_layout_snapshot(layout, event) do - {:ok, layout} - end - end) - end - - with {:ok, layout} <- publish.(layout), + with {:ok, layout} <- publish_layout_snapshot(layout), :ok <- Beacon.PubSub.layout_published(layout) do {:ok, layout} else @@ -4568,6 +4576,18 @@ defmodule Beacon.Content do end end + defp publish_layout_snapshot(%{site: site} = layout) do + changeset = Layout.changeset(layout, %{}) + + transact(repo(site), fn -> + with {:ok, _changeset} <- validate_layout_template(changeset), + {:ok, event} <- create_layout_event(layout, "published"), + {:ok, _snapshot} <- create_layout_snapshot(layout, event) do + {:ok, layout} + end + end) + end + # SITE SETTINGS @doc """ @@ -4911,7 +4931,7 @@ defmodule Beacon.Content do end query - |> order_by([c], [asc: c.sort_order, asc: c.name]) + |> order_by([c], asc: c.sort_order, asc: c.name) |> repo(site).all() end @@ -5101,10 +5121,12 @@ defmodule Beacon.Content do @doc type: :redirects @spec create_redirect(map()) :: {:ok, Redirect.t()} | {:error, Ecto.Changeset.t()} def create_redirect(attrs) when is_map(attrs) do - attrs = Map.new(attrs, fn - {key, val} when is_binary(key) -> {key, val} - {key, val} -> {Atom.to_string(key), val} - end) + attrs = + Map.new(attrs, fn + {key, val} when is_binary(key) -> {key, val} + {key, val} -> {Atom.to_string(key), val} + end) + {:ok, site} = Beacon.Types.Site.cast(attrs["site"]) # Flatten chains: if destination is another redirect's source, point to final destination @@ -5206,8 +5228,12 @@ defmodule Beacon.Content do old_path = get_last_published_path(page.site, page.id) case old_path do - nil -> :ok - ^old_path when old_path == page.path -> :ok + nil -> + :ok + + ^old_path when old_path == page.path -> + :ok + old_path -> create_redirect(%{ "site" => page.site, @@ -5270,5 +5296,4 @@ defmodule Beacon.Content do Beacon.Content.RedirectCache.invalidate(site) end - end diff --git a/lib/beacon/content/component.ex b/lib/beacon/content/component.ex index a62b30f33..fa30b9010 100644 --- a/lib/beacon/content/component.ex +++ b/lib/beacon/content/component.ex @@ -91,9 +91,8 @@ defmodule Beacon.Content.Component do try do module = Module.concat([String.to_existing_atom(struct_name)]) - with {:module, ^module} <- Code.ensure_loaded(module) do - changeset - else + case Code.ensure_loaded(module) do + {:module, ^module} -> changeset _ -> add_error(changeset, :struct_name, "the struct #{struct_name} is undefined") end rescue @@ -142,7 +141,7 @@ defmodule Beacon.Content.Component do not_allowed = Keyword.keys(opts) -- [:required, :default, :examples, :values, :doc] cond do - Enum.count(not_allowed) > 0 and type != "global" -> + not Enum.empty?(not_allowed) and type != "global" -> name = get_field(changeset, :name) add_error(changeset, :opts, "invalid opts for attribute #{inspect(name)}: #{inspect(not_allowed)}") diff --git a/lib/beacon/content/component_slot.ex b/lib/beacon/content/component_slot.ex index 6c76dc477..38700fc36 100644 --- a/lib/beacon/content/component_slot.ex +++ b/lib/beacon/content/component_slot.ex @@ -56,11 +56,11 @@ defmodule Beacon.Content.ComponentSlot do opts = get_field(changeset, :opts) |> maybe_binary_to_term() not_allowed = Keyword.keys(opts) -- [:required, :validate_attrs, :doc] - if Enum.count(not_allowed) > 0 do + if Enum.empty?(not_allowed) do + changeset + else name = get_field(changeset, :name) add_error(changeset, :opts, "invalid opts for slot #{inspect(name)}: #{inspect(not_allowed)}") - else - changeset end end diff --git a/lib/beacon/content/redirect_cache.ex b/lib/beacon/content/redirect_cache.ex index e6619df49..c9da39417 100644 --- a/lib/beacon/content/redirect_cache.ex +++ b/lib/beacon/content/redirect_cache.ex @@ -58,16 +58,16 @@ defmodule Beacon.Content.RedirectCache do patterns |> Enum.sort_by(fn [_pattern, priority, _dest, _status] -> priority end) |> Enum.find_value(fn [pattern, _priority, dest, status] -> - case Regex.compile(pattern) do - {:ok, regex} -> - if Regex.match?(regex, path) do - resolved_dest = Regex.replace(regex, path, dest) - {resolved_dest, status} - end - - _ -> - nil - end + match_pattern(pattern, path, dest, status) end) end + + defp match_pattern(pattern, path, dest, status) do + with {:ok, regex} <- Regex.compile(pattern), + true <- Regex.match?(regex, path) do + {Regex.replace(regex, path, dest), status} + else + _ -> nil + end + end end diff --git a/lib/beacon/css/theme_parser.ex b/lib/beacon/css/theme_parser.ex index e06d2fc85..248657dbb 100644 --- a/lib/beacon/css/theme_parser.ex +++ b/lib/beacon/css/theme_parser.ex @@ -48,22 +48,8 @@ defmodule Beacon.CSS.ThemeParser do # v4 expects --text-3xl: 2rem and --text-3xl--line-height: 2.5rem theme = case Map.get(theme, "fontSize") do - nil -> - theme - - font_sizes when is_map(font_sizes) -> - {split_sizes, _} = - Enum.reduce(font_sizes, {%{}, %{}}, fn {key, value}, {sizes, _} -> - case String.split(value, ",", parts: 2) do - [size, line_height] -> - {sizes |> Map.put(key, String.trim(size)) |> Map.put("#{key}--line-height", String.trim(line_height)), %{}} - - [_single] -> - {Map.put(sizes, key, value), %{}} - end - end) - - Map.put(theme, "fontSize", split_sizes) + nil -> theme + font_sizes when is_map(font_sizes) -> Map.put(theme, "fontSize", split_font_sizes(font_sizes)) end if map_size(theme) > 0 do @@ -71,6 +57,24 @@ defmodule Beacon.CSS.ThemeParser do end end + # v4 expects `--text-3xl: 2rem` and `--text-3xl--line-height: 2.5rem`, so an + # array value in the config becomes two entries. + defp split_font_sizes(font_sizes) do + Enum.reduce(font_sizes, %{}, fn {key, value}, sizes -> put_font_size(sizes, key, value) end) + end + + defp put_font_size(sizes, key, value) do + case String.split(value, ",", parts: 2) do + [size, line_height] -> + sizes + |> Map.put(key, String.trim(size)) + |> Map.put("#{key}--line-height", String.trim(line_height)) + + [_single] -> + Map.put(sizes, key, value) + end + end + # Extract a section like `colors: { ... }` from the JS config defp extract_section(js, section_name) do # Match: sectionName: { ... } (handling nested braces) @@ -119,6 +123,7 @@ defmodule Beacon.CSS.ThemeParser do defp parse_pairs(content, acc) do content = String.trim(content) + if content == "" or content == "," do acc else @@ -172,74 +177,88 @@ defmodule Beacon.CSS.ThemeParser do rest = String.trim(rest) cond do - # Nested object - String.starts_with?(rest, "{") -> - inner = extract_braced_content(rest, 0) - if inner do - consumed = 1 + byte_size(inner) + 1 - remaining = String.slice(rest, consumed..-1//1) - value = parse_js_object(inner) - {key, flatten_nested_colors(value), remaining} - end + String.starts_with?(rest, "{") -> parse_object_value(key, rest) + String.starts_with?(rest, "[") -> parse_array_value(key, rest) + String.starts_with?(rest, "'") or String.starts_with?(rest, "\"") -> parse_quoted_value(key, rest) + String.starts_with?(rest, "var(") -> parse_var_value(key, rest) + Regex.match?(~r/^[0-9]/, rest) -> parse_number_value(key, rest) + true -> skip_to_next_comma(key, rest) + end + end - # Array value like ['Plus Jakarta Sans', 'sans-serif'] - String.starts_with?(rest, "[") -> - case Regex.run(~r/^\[([^\]]*)\]/s, rest) do - [full, inner] -> - remaining = String.slice(rest, String.length(full)..-1//1) - # Join array into a single string (for fontFamily) - value = - inner - |> String.split(",") - |> Enum.map(&(&1 |> String.trim() |> String.trim("'") |> String.trim("\""))) - |> Enum.join(", ") - - {key, value, remaining} - - _ -> - nil - end + # Nested object + defp parse_object_value(key, rest) do + inner = extract_braced_content(rest, 0) - # String value (single or double quoted) - String.starts_with?(rest, "'") or String.starts_with?(rest, "\"") -> - quote_char = String.at(rest, 0) - case Regex.run(~r/^#{Regex.escape(quote_char)}([^#{Regex.escape(quote_char)}]*)#{Regex.escape(quote_char)}/s, rest) do - [full, value] -> - remaining = String.slice(rest, String.length(full)..-1//1) - {key, value, remaining} + if inner do + consumed = 1 + byte_size(inner) + 1 + remaining = String.slice(rest, consumed..-1//1) + value = parse_js_object(inner) + {key, flatten_nested_colors(value), remaining} + end + end - _ -> - nil - end + # Array value like ['Plus Jakarta Sans', 'sans-serif'], joined into a single + # string for fontFamily + defp parse_array_value(key, rest) do + case Regex.run(~r/^\[([^\]]*)\]/s, rest) do + [full, inner] -> + remaining = String.slice(rest, String.length(full)..-1//1) - # var(...) expression - String.starts_with?(rest, "var(") -> - case Regex.run(~r/^var\([^)]*\)/s, rest) do - [full] -> - remaining = String.slice(rest, String.length(full)..-1//1) - {key, full, remaining} + value = + inner + |> String.split(",") + |> Enum.map_join(", ", &(&1 |> String.trim() |> String.trim("'") |> String.trim("\""))) - _ -> - nil - end + {key, value, remaining} + + _ -> + nil + end + end - # Bare number - Regex.match?(~r/^[0-9]/, rest) -> - case Regex.run(~r/^([0-9.]+)/s, rest) do - [full, value] -> - remaining = String.slice(rest, String.length(full)..-1//1) - {key, value, remaining} + # String value, single or double quoted + defp parse_quoted_value(key, rest) do + quote_char = String.at(rest, 0) - _ -> - nil - end + case Regex.run(~r/^#{Regex.escape(quote_char)}([^#{Regex.escape(quote_char)}]*)#{Regex.escape(quote_char)}/s, rest) do + [full, value] -> + remaining = String.slice(rest, String.length(full)..-1//1) + {key, value, remaining} - true -> - # Skip to next comma - case String.split(rest, ",", parts: 2) do - [_, remaining] -> {key, nil, remaining} - _ -> nil - end + _ -> + nil + end + end + + # var(...) expression + defp parse_var_value(key, rest) do + case Regex.run(~r/^var\([^)]*\)/s, rest) do + [full] -> + remaining = String.slice(rest, String.length(full)..-1//1) + {key, full, remaining} + + _ -> + nil + end + end + + # Bare number + defp parse_number_value(key, rest) do + case Regex.run(~r/^([0-9.]+)/s, rest) do + [full, value] -> + remaining = String.slice(rest, String.length(full)..-1//1) + {key, value, remaining} + + _ -> + nil + end + end + + defp skip_to_next_comma(key, rest) do + case String.split(rest, ",", parts: 2) do + [_, remaining] -> {key, nil, remaining} + _ -> nil end end diff --git a/lib/beacon/graphql/client.ex b/lib/beacon/graphql/client.ex index 01026ded6..ce010a634 100644 --- a/lib/beacon/graphql/client.ex +++ b/lib/beacon/graphql/client.ex @@ -25,20 +25,7 @@ defmodule Beacon.GraphQL.Client do with {:ok, endpoint} <- EndpointCache.get_endpoint(site, endpoint_name), :ok <- OperationAllowlist.check(site, endpoint_name, extract_operation_name(query)) do result = do_execute(endpoint, query, variables, opts) - - # Trip circuit on network/server errors - case result do - {:error, {:network, _}} -> - ttl = endpoint.timeout_ms |> div(1000) |> max(30) - Beacon.CircuitBreaker.trip(site, breaker_key, ttl) - - {:error, {:http, status, _}} when status >= 500 -> - Beacon.CircuitBreaker.trip(site, breaker_key, 30) - - _ -> - :ok - end - + maybe_trip_circuit(result, site, breaker_key, endpoint) result else :error -> {:error, {:endpoint_not_found, endpoint_name}} @@ -77,6 +64,18 @@ defmodule Beacon.GraphQL.Client do do_execute(endpoint, query, variables, opts) end + # A network error, or a server error from the endpoint, opens the circuit. + defp maybe_trip_circuit({:error, {:network, _}}, site, breaker_key, endpoint) do + ttl = endpoint.timeout_ms |> div(1000) |> max(30) + Beacon.CircuitBreaker.trip(site, breaker_key, ttl) + end + + defp maybe_trip_circuit({:error, {:http, status, _}}, site, breaker_key, _endpoint) when status >= 500 do + Beacon.CircuitBreaker.trip(site, breaker_key, 30) + end + + defp maybe_trip_circuit(_result, _site, _breaker_key, _endpoint), do: :ok + defp do_execute(%GraphQLEndpoint{} = endpoint, query, variables, opts) do timeout = Keyword.get(opts, :timeout, endpoint.timeout_ms || 10_000) @@ -88,31 +87,28 @@ defmodule Beacon.GraphQL.Client do # Use a dedicated Finch pool for GraphQL requests to avoid connection # pool exhaustion when the server calls its own GraphQL endpoint # (self-call during page rendering). - case Req.post(endpoint.url, - json: body, - headers: headers, - receive_timeout: timeout, - retry: :transient, - max_retries: endpoint.max_retries || 2, - finch: Beacon.Finch - ) do - {:ok, %{status: 200, body: %{"data" => data, "errors" => errors}}} when is_list(errors) and errors != [] -> - {:partial, data, errors} - - {:ok, %{status: 200, body: %{"data" => data}}} -> - {:ok, data} - - {:ok, %{status: 200, body: %{"errors" => errors}}} -> - {:error, {:graphql, errors}} - - {:ok, %{status: status, body: body}} -> - {:error, {:http, status, body}} - - {:error, exception} -> - {:error, {:network, exception}} - end + endpoint.url + |> Req.post( + json: body, + headers: headers, + receive_timeout: timeout, + retry: :transient, + max_retries: endpoint.max_retries || 2, + finch: Beacon.Finch + ) + |> handle_response() end + defp handle_response({:ok, %{status: 200, body: %{"data" => data, "errors" => errors}}}) + when is_list(errors) and errors != [] do + {:partial, data, errors} + end + + defp handle_response({:ok, %{status: 200, body: %{"data" => data}}}), do: {:ok, data} + defp handle_response({:ok, %{status: 200, body: %{"errors" => errors}}}), do: {:error, {:graphql, errors}} + defp handle_response({:ok, %{status: status, body: body}}), do: {:error, {:http, status, body}} + defp handle_response({:error, exception}), do: {:error, {:network, exception}} + defp extract_operation_name(query) when is_binary(query) do case Regex.run(~r/(?:query|mutation|subscription)\s+(\w+)/, query) do [_, name] -> name diff --git a/lib/beacon/graphql/introspection.ex b/lib/beacon/graphql/introspection.ex index 751f8f433..3f824d0aa 100644 --- a/lib/beacon/graphql/introspection.ex +++ b/lib/beacon/graphql/introspection.ex @@ -152,6 +152,7 @@ defmodule Beacon.GraphQL.Introspection do |> Enum.map(fn [_, name, args_str, return_type] -> args = extract_sdl_args(args_str) + %{ "name" => name, "description" => nil, @@ -175,6 +176,7 @@ defmodule Beacon.GraphQL.Introspection do defp extract_sdl_args(""), do: [] defp extract_sdl_args(nil), do: [] + defp extract_sdl_args(args_str) do # Strip parens inner = String.trim_leading(args_str, "(") |> String.trim_trailing(")") @@ -262,6 +264,15 @@ defmodule Beacon.GraphQL.Introspection do end end + # The fields of the named type, normalized. Both queries and mutations are + # read this way; they differ only in which type they come from. + defp normalized_fields_of(types, type_name) do + case Enum.find(types, &(&1["name"] == type_name)) do + nil -> [] + type -> Enum.map(type["fields"] || [], &normalize_field/1) + end + end + defp normalize_schema(schema) do query_type_name = get_in(schema, ["queryType", "name"]) || "Query" mutation_type_name = get_in(schema, ["mutationType", "name"]) || "Mutation" @@ -274,23 +285,8 @@ defmodule Beacon.GraphQL.Introspection do |> Enum.reject(&String.starts_with?(&1["name"] || "", "__")) |> Enum.map(&normalize_type/1) - # Extract queries (fields of the Query type) - queries = - types - |> Enum.find(&(&1["name"] == query_type_name)) - |> case do - nil -> [] - query_type -> Enum.map(query_type["fields"] || [], &normalize_field/1) - end - - # Extract mutations (fields of the Mutation type) - mutations = - types - |> Enum.find(&(&1["name"] == mutation_type_name)) - |> case do - nil -> [] - mutation_type -> Enum.map(mutation_type["fields"] || [], &normalize_field/1) - end + queries = normalized_fields_of(types, query_type_name) + mutations = normalized_fields_of(types, mutation_type_name) %{ "queries" => queries, diff --git a/lib/beacon/media_library/provider.ex b/lib/beacon/media_library/provider.ex index f58a857ee..4a6b7c38c 100644 --- a/lib/beacon/media_library/provider.ex +++ b/lib/beacon/media_library/provider.ex @@ -11,8 +11,8 @@ defmodule Beacon.MediaLibrary.Provider do See `Beacon.Config` and the provider module doc for more info. """ - alias Beacon.MediaLibrary.UploadMetadata alias Beacon.MediaLibrary.Asset + alias Beacon.MediaLibrary.UploadMetadata import Ecto.Query import Beacon.Utils, only: [repo: 1] diff --git a/lib/beacon/migrations/graphql_migrator.ex b/lib/beacon/migrations/graphql_migrator.ex index 7845a7b07..45988b773 100644 --- a/lib/beacon/migrations/graphql_migrator.ex +++ b/lib/beacon/migrations/graphql_migrator.ex @@ -21,10 +21,11 @@ defmodule Beacon.Migrations.GraphQLMigrator do config = Beacon.Config.fetch!(site) repo = config.repo - %{rows: page_rows} = repo.query!( - "SELECT id, path, extra FROM beacon_pages WHERE site = $1", - [to_string(site)] - ) + %{rows: page_rows} = + repo.query!( + "SELECT id, path, extra FROM beacon_pages WHERE site = $1", + [to_string(site)] + ) pages_with_data_sources = page_rows @@ -37,10 +38,11 @@ defmodule Beacon.Migrations.GraphQLMigrator do %{id: id, path: path, data_sources: Map.get(extra || %{}, "data_sources", [])} end) - %{rows: handler_rows} = repo.query!( - "SELECT id, name, format FROM beacon_event_handlers WHERE site = $1", - [to_string(site)] - ) + %{rows: handler_rows} = + repo.query!( + "SELECT id, name, format FROM beacon_event_handlers WHERE site = $1", + [to_string(site)] + ) elixir_handlers = handler_rows @@ -67,39 +69,33 @@ defmodule Beacon.Migrations.GraphQLMigrator do steps = if report.pages_with_legacy_data_sources > 0 do - page_steps = - Enum.flat_map(report.pages, fn page -> - sources = Enum.map(page.data_sources, fn ds -> - source = ds["source"] || ds[:source] - " - Data source '#{source}' on page #{page.path}" - end) - - ["Pages with legacy data_sources in extra field (#{page.path}):"] ++ sources - end) - - steps ++ [ - "== Legacy Data Sources ==", - "#{report.pages_with_legacy_data_sources} page(s) have data_sources in their extra field.", - "Steps:", - " 1. Create a GraphQL endpoint in Beacon admin pointing to your host app's API", - " 2. For each data source, create a page query with the equivalent GraphQL query", - " 3. Remove the data_sources from page extra fields", - "" | page_steps - ] + page_steps = Enum.flat_map(report.pages, &legacy_data_source_steps/1) + + steps ++ + [ + "== Legacy Data Sources ==", + "#{report.pages_with_legacy_data_sources} page(s) have data_sources in their extra field.", + "Steps:", + " 1. Create a GraphQL endpoint in Beacon admin pointing to your host app's API", + " 2. For each data source, create a page query with the equivalent GraphQL query", + " 3. Remove the data_sources from page extra fields", + "" | page_steps + ] else steps ++ ["No legacy data sources found."] end steps = if report.elixir_event_handlers > 0 do - handler_names = Enum.map(report.handlers, & &1.name) |> Enum.join(", ") - - steps ++ [ - "", - "== Elixir Event Handlers ==", - "#{report.elixir_event_handlers} handler(s) use raw Elixir code: #{handler_names}", - "These can optionally be converted to declarative actions format." - ] + handler_names = Enum.map_join(report.handlers, ", ", & &1.name) + + steps ++ + [ + "", + "== Elixir Event Handlers ==", + "#{report.elixir_event_handlers} handler(s) use raw Elixir code: #{handler_names}", + "These can optionally be converted to declarative actions format." + ] else steps end @@ -107,6 +103,16 @@ defmodule Beacon.Migrations.GraphQLMigrator do steps end + defp legacy_data_source_steps(page) do + sources = + Enum.map(page.data_sources, fn ds -> + source = ds["source"] || ds[:source] + " - Data source '#{source}' on page #{page.path}" + end) + + ["Pages with legacy data_sources in extra field (#{page.path}):"] ++ sources + end + @doc """ Clean up legacy data_sources from page extra fields. """ @@ -115,10 +121,11 @@ defmodule Beacon.Migrations.GraphQLMigrator do config = Beacon.Config.fetch!(site) repo = config.repo - %{num_rows: count} = repo.query!( - "UPDATE beacon_pages SET extra = extra - 'data_sources' WHERE site = $1 AND extra ? 'data_sources'", - [to_string(site)] - ) + %{num_rows: count} = + repo.query!( + "UPDATE beacon_pages SET extra = extra - 'data_sources' WHERE site = $1 AND extra ? 'data_sources'", + [to_string(site)] + ) Logger.info("[GraphQLMigrator] Cleaned data_sources from #{count} pages on site #{site}") :ok diff --git a/lib/beacon/migrations/v006.ex b/lib/beacon/migrations/v006.ex index e1c387fa5..30ab4d08e 100644 --- a/lib/beacon/migrations/v006.ex +++ b/lib/beacon/migrations/v006.ex @@ -40,13 +40,11 @@ defmodule Beacon.Migrations.V006 do defp safe_extract_template(nil), do: :error defp safe_extract_template(binary) when is_binary(binary) do - try do - case :erlang.binary_to_term(binary) do - %{template: t} when is_binary(t) -> {:ok, t} - _ -> :error - end - rescue + case :erlang.binary_to_term(binary) do + %{template: t} when is_binary(t) -> {:ok, t} _ -> :error end + rescue + _ -> :error end end diff --git a/lib/beacon/page_render_cache.ex b/lib/beacon/page_render_cache.ex index afd1d1485..c1792b819 100644 --- a/lib/beacon/page_render_cache.ex +++ b/lib/beacon/page_render_cache.ex @@ -51,11 +51,15 @@ defmodule Beacon.PageRenderCache do add_to_dep_set(site, :graphql_endpoint, endpoint_name, page_id) end - :ets.insert(@table, {{site, :dep, :page_deps, page_id}, %{ - layout_id: layout_id, - components: components, - graphql_endpoints: graphql_endpoints - }}) + :ets.insert( + @table, + {{site, :dep, :page_deps, page_id}, + %{ + layout_id: layout_id, + components: components, + graphql_endpoints: graphql_endpoints + }} + ) :ok end @@ -174,51 +178,37 @@ defmodule Beacon.PageRenderCache do # Page path lookup # --------------------------------------------------------------------------- + # The pages that still have a path recorded, paired with it. + defp page_paths(site, page_ids) do + Enum.flat_map(page_ids, fn page_id -> + case lookup_page_path(site, page_id) do + {:ok, path} -> [{page_id, path}] + :error -> [] + end + end) + end + @spec pages_for_layout(atom(), String.t()) :: [{String.t(), String.t()}] def pages_for_layout(site, layout_id) do case :ets.lookup(@table, {site, :dep, :layout, layout_id}) do - [{_, page_ids}] -> - Enum.flat_map(page_ids, fn page_id -> - case lookup_page_path(site, page_id) do - {:ok, path} -> [{page_id, path}] - :error -> [] - end - end) - - [] -> - [] + [{_, page_ids}] -> page_paths(site, page_ids) + [] -> [] end end @spec pages_for_component(atom(), atom()) :: [{String.t(), String.t()}] def pages_for_component(site, component_name) do case :ets.lookup(@table, {site, :dep, :component, component_name}) do - [{_, page_ids}] -> - Enum.flat_map(page_ids, fn page_id -> - case lookup_page_path(site, page_id) do - {:ok, path} -> [{page_id, path}] - :error -> [] - end - end) - - [] -> - [] + [{_, page_ids}] -> page_paths(site, page_ids) + [] -> [] end end @spec pages_for_graphql_endpoint(atom(), binary()) :: [{String.t(), String.t()}] def pages_for_graphql_endpoint(site, endpoint_name) do case :ets.lookup(@table, {site, :dep, :graphql_endpoint, endpoint_name}) do - [{_, page_ids}] -> - Enum.flat_map(page_ids, fn page_id -> - case lookup_page_path(site, page_id) do - {:ok, path} -> [{page_id, path}] - :error -> [] - end - end) - - [] -> - [] + [{_, page_ids}] -> page_paths(site, page_ids) + [] -> [] end end @@ -255,6 +245,7 @@ defmodule Beacon.PageRenderCache do end defp extract_components({:eex, _expr}, acc), do: acc + defp extract_components({:eex_block, _expr, children}, acc) do extract_components(children, acc) end diff --git a/lib/beacon/proxy_endpoint.ex b/lib/beacon/proxy_endpoint.ex index 8ab694075..2c6eeb8ba 100644 --- a/lib/beacon/proxy_endpoint.ex +++ b/lib/beacon/proxy_endpoint.ex @@ -69,30 +69,25 @@ defmodule Beacon.ProxyEndpoint do # TODO: cache endpoint resolver defp proxy(%{host: host} = conn, opts) do - matching_endpoint = fn -> - Enum.reduce_while(Beacon.Registry.running_sites(), @__beacon_proxy_fallback__, fn site, default -> - %{endpoint: endpoint} = Beacon.Config.fetch!(site) - - if endpoint.host() == host do - {:halt, endpoint} - else - {:cont, default} - end - end) - end - # fallback endpoint has higher priority in case of conflicts, # for eg when all endpoints' host are localhost endpoint = if @__beacon_proxy_fallback__.host() == host do @__beacon_proxy_fallback__ else - matching_endpoint.() + endpoint_for_host(host) end endpoint.call(conn, endpoint.init(opts)) end + defp endpoint_for_host(host) do + Enum.reduce_while(Beacon.Registry.running_sites(), @__beacon_proxy_fallback__, fn site, default -> + %{endpoint: endpoint} = Beacon.Config.fetch!(site) + if endpoint.host() == host, do: {:halt, endpoint}, else: {:cont, default} + end) + end + @doc """ Check origin dynamically. @@ -131,12 +126,7 @@ defmodule Beacon.ProxyEndpoint do https = endpoint.config(:https) http = endpoint.config(:http) - {scheme, port} = - cond do - https -> {"https", https[:port] || 443} - http -> {"http", http[:port] || 80} - true -> {"http", 80} - end + {scheme, port} = scheme_and_port(https, http) scheme = url[:scheme] || scheme host = host_to_binary(host || "localhost") @@ -161,12 +151,7 @@ defmodule Beacon.ProxyEndpoint do https = proxy_endpoint.config(:https) http = proxy_endpoint.config(:http) - {scheme, port} = - cond do - https -> {"https", https[:port] || 443} - http -> {"http", http[:port] || 80} - true -> {"http", 80} - end + {scheme, port} = scheme_and_port(https, http) scheme = proxy_url[:scheme] || scheme host = host_to_binary(site_url[:host] || "localhost") @@ -180,6 +165,10 @@ defmodule Beacon.ProxyEndpoint do %URI{scheme: scheme, host: host, port: port, path: path} end + defp scheme_and_port(https, _http) when not is_nil(https) and https != false, do: {"https", https[:port] || 443} + defp scheme_and_port(_https, http) when not is_nil(http) and http != false, do: {"http", http[:port] || 80} + defp scheme_and_port(_https, _http), do: {"http", 80} + @doc """ Returns the public URL of a given `site`. @@ -220,6 +209,7 @@ defmodule Beacon.ProxyEndpoint do |> sites_per_host() |> Enum.flat_map(fn site -> config = Beacon.Config.fetch!(site) + Beacon.SEO.AICrawlers.robots_directives( config.ai_crawler_policy, config.ai_crawler_custom_rules diff --git a/lib/beacon/runtime_renderer.ex b/lib/beacon/runtime_renderer.ex index 758b3d7a7..9b638ac9b 100644 --- a/lib/beacon/runtime_renderer.ex +++ b/lib/beacon/runtime_renderer.ex @@ -153,23 +153,46 @@ defmodule Beacon.RuntimeRenderer do end end + defp component_ast_entry([name, binary]) do + ast = + case :erlang.binary_to_term(binary) do + %{ast: ast} when is_list(ast) -> ast + _ -> [] + end + + {to_string(name), ast} + end + + defp publish_layout_from_db(site, layout_id) do + case Beacon.Content.get_published_layout(site, layout_id) do + nil -> + :not_found + + layout -> + publish_layout(site, to_string(layout.id), layout.template, + meta_tags: layout.meta_tags || [], + resource_links: layout.resource_links || [], + default_og_image: Map.get(layout, :default_og_image), + default_twitter_card: Map.get(layout, :default_twitter_card) + ) + end + end + + defp publish_error_page_from_db(site, status_code) do + with error_pages when is_list(error_pages) <- Beacon.Content.list_error_pages(site, per_page: :infinity), + %{} = error_page <- Enum.find(error_pages, &(&1.status == status_code)) do + publish_error_page(site, error_page.status, error_page.template) + else + _ -> :not_found + end + end + defp build_component_registry(site) do # Build a map of component name → AST from stored component templates registry = case :ets.match(@table, {{site, :component, :"$1"}, :"$2"}) do - matches when is_list(matches) and matches != [] -> - Map.new(matches, fn [name, binary] -> - component = :erlang.binary_to_term(binary) - component_ast = - case component do - %{ast: ast} when is_list(ast) -> ast - _ -> [] - end - {to_string(name), component_ast} - end) - - _ -> - %{} + matches when is_list(matches) and matches != [] -> Map.new(matches, &component_ast_entry/1) + _ -> %{} end # If no components in ETS (lazy loading), try loading from DB @@ -236,20 +259,12 @@ defmodule Beacon.RuntimeRenderer do _ -> ttl = Beacon.Config.effective_ttl(Beacon.Config.fetch!(site), :layouts) - Beacon.Cache.fetch(@table, {site, :layout_load, layout_id}, fn -> - case Beacon.Content.get_published_layout(site, layout_id) do - nil -> - :not_found - - layout -> - publish_layout(site, to_string(layout.id), layout.template, - meta_tags: layout.meta_tags || [], - resource_links: layout.resource_links || [], - default_og_image: Map.get(layout, :default_og_image), - default_twitter_card: Map.get(layout, :default_twitter_card) - ) - end - end, ttl) + Beacon.Cache.fetch( + @table, + {site, :layout_load, layout_id}, + fn -> publish_layout_from_db(site, layout_id) end, + ttl + ) case :ets.lookup(@table, {site, :layout, layout_id}) do [{_, ast}] when is_list(ast) -> @@ -322,19 +337,12 @@ defmodule Beacon.RuntimeRenderer do _ -> ttl = Beacon.Config.effective_ttl(Beacon.Config.fetch!(site), :error_pages) - Beacon.Cache.fetch(@table, {site, :error_page_load, status_code}, fn -> - case Beacon.Content.list_error_pages(site, per_page: :infinity) do - error_pages when is_list(error_pages) -> - Enum.find(error_pages, &(&1.status == status_code)) - |> case do - nil -> :not_found - error_page -> publish_error_page(site, error_page.status, error_page.template) - end - - _ -> - :not_found - end - end, ttl) + Beacon.Cache.fetch( + @table, + {site, :error_page_load, status_code}, + fn -> publish_error_page_from_db(site, status_code) end, + ttl + ) case :ets.lookup(@table, {site, :error_page, status_code}) do [{_, ast}] when is_list(ast) -> @@ -360,6 +368,7 @@ defmodule Beacon.RuntimeRenderer do # Extract default attr values from component attrs attrs = Keyword.get(opts, :attrs, []) + defaults = Enum.reduce(attrs, %{}, fn %{name: attr_name, opts: attr_opts}, acc -> @@ -367,7 +376,9 @@ defmodule Beacon.RuntimeRenderer do nil -> acc default -> Map.put(acc, String.to_existing_atom(attr_name), default) end - _, acc -> acc + + _, acc -> + acc end) :ets.insert(@table, {{site, :component, name}, :erlang.term_to_binary(%{ast: component_ast, body: body, defaults: defaults})}) @@ -388,60 +399,33 @@ defmodule Beacon.RuntimeRenderer do "" end - defp do_render_component(site, name, assigns) do - case :ets.lookup(@table, {site, :component, name}) do - [{_, serialized}] -> - data = :erlang.binary_to_term(serialized) - defaults = Map.get(data, :defaults, %{}) - body = Map.get(data, :body, "") - ast = Map.get(data, :ast, []) + defp render_serialized_component(site, serialized, assigns) do + data = :erlang.binary_to_term(serialized) + assigns = Map.merge(Map.get(data, :defaults, %{}), assigns) + body_bindings = execute_component_body(Map.get(data, :body, ""), assigns) - assigns = Map.merge(defaults, assigns) - body_bindings = execute_component_body(body, assigns) + full_assigns = + assigns + |> Map.merge(body_bindings) + |> Map.delete(:__changed__) + |> Map.put_new(:inner_block, []) + |> Map.put_new(:beacon, %{site: site}) - full_assigns = - assigns - |> Map.merge(body_bindings) - |> Map.delete(:__changed__) - |> Map.put_new(:inner_block, []) - |> Map.put_new(:beacon, %{site: site}) + Beacon.Client.LiveViewCompiler.render(Map.get(data, :ast, []), full_assigns) + end - Beacon.Client.LiveViewCompiler.render(ast, full_assigns) + defp do_render_component(site, name, assigns) do + case :ets.lookup(@table, {site, :component, name}) do + [{_, serialized}] -> + render_serialized_component(site, serialized, assigns) [] -> - ttl = Beacon.Config.effective_ttl(Beacon.Config.fetch!(site), :components) - - Beacon.Cache.fetch(@table, {site, :component_load, name}, fn -> - case Beacon.Content.get_component_by(site, [name: name], preloads: [:attrs]) do - nil -> - :not_found - - component -> - component_attrs = (component.attrs || []) - attrs_list = Enum.map(component_attrs, fn a -> %{name: a.name, opts: a.opts || []} end) - publish_component(site, component.name, component.template, component.body || "", attrs: attrs_list) - end - end, ttl) + load_component(site, name) # Re-check after load case :ets.lookup(@table, {site, :component, name}) do [{_, serialized}] -> - data = :erlang.binary_to_term(serialized) - defaults = Map.get(data, :defaults, %{}) - body = Map.get(data, :body, "") - ast = Map.get(data, :ast, []) - - assigns = Map.merge(defaults, assigns) - body_bindings = execute_component_body(body, assigns) - - full_assigns = - assigns - |> Map.merge(body_bindings) - |> Map.delete(:__changed__) - |> Map.put_new(:inner_block, []) - |> Map.put_new(:beacon, %{site: site}) - - Beacon.Client.LiveViewCompiler.render(ast, full_assigns) + render_serialized_component(site, serialized, assigns) [] -> require Logger @@ -451,6 +435,23 @@ defmodule Beacon.RuntimeRenderer do end end + defp load_component(site, name) do + ttl = Beacon.Config.effective_ttl(Beacon.Config.fetch!(site), :components) + + Beacon.Cache.fetch(@table, {site, :component_load, name}, fn -> publish_component_from_db(site, name) end, ttl) + end + + defp publish_component_from_db(site, name) do + case Beacon.Content.get_component_by(site, [name: name], preloads: [:attrs]) do + nil -> + :not_found + + component -> + attrs_list = Enum.map(component.attrs || [], fn a -> %{name: a.name, opts: a.opts || []} end) + publish_component(site, component.name, component.template, component.body || "", attrs: attrs_list) + end + end + @doc false def render_ir_with_bindings(ir, assigns, bindings) do %Phoenix.LiveView.Rendered{ @@ -466,25 +467,25 @@ defmodule Beacon.RuntimeRenderer do changed = if track_changes?, do: Map.get(assigns, :__changed__), else: nil {results, _bindings} = - Enum.reduce(dynamics, {[], bindings}, fn %{deps: deps, expr: expr}, {acc, b} -> - case expr do - {:bind, name, value_expr} -> - value = eval_ir(value_expr, assigns, b) - {acc, Map.put(b, name, value)} - - _ -> - if changed != nil and deps != [] and not Enum.any?(deps, &Map.has_key?(changed, &1)) do - {[nil | acc], b} - else - result = eval_ir(expr, assigns, b) - {[safe_dynamic(result) | acc], b} - end - end - end) + Enum.reduce(dynamics, {[], bindings}, &evaluate_dynamic(&1, &2, assigns, changed)) Enum.reverse(results) end + # A `:bind` dynamic only adds to the bindings. Any other is skipped when + # change tracking says nothing it depends on changed. + defp evaluate_dynamic(%{expr: {:bind, name, value_expr}}, {acc, bindings}, assigns, _changed) do + {acc, Map.put(bindings, name, eval_ir(value_expr, assigns, bindings))} + end + + defp evaluate_dynamic(%{deps: deps, expr: expr}, {acc, bindings}, assigns, changed) do + if changed != nil and deps != [] and not Enum.any?(deps, &Map.has_key?(changed, &1)) do + {[nil | acc], bindings} + else + {[safe_dynamic(eval_ir(expr, assigns, bindings)) | acc], bindings} + end + end + defp execute_component_body(nil, _assigns), do: %{} defp execute_component_body("", _assigns), do: %{} @@ -561,15 +562,7 @@ defmodule Beacon.RuntimeRenderer do # Lazy-load snippet helpers for this site ttl = Beacon.Config.effective_ttl(Beacon.Config.fetch!(site), :snippets) - Beacon.Cache.fetch(@table, {site, :snippet_helpers_load}, fn -> - helpers = Beacon.Content.list_snippet_helpers(site) - - for helper <- helpers do - publish_snippet_helper(site, helper.name, helper.body) - end - - :loaded - end, ttl) + Beacon.Cache.fetch(@table, {site, :snippet_helpers_load}, fn -> publish_snippet_helpers(site) end, ttl) case :ets.lookup(@table, {site, :snippet_helper, helper_name}) do [{_, body}] -> @@ -582,6 +575,14 @@ defmodule Beacon.RuntimeRenderer do end end + defp publish_snippet_helpers(site) do + for helper <- Beacon.Content.list_snippet_helpers(site) do + publish_snippet_helper(site, helper.name, helper.body) + end + + :loaded + end + defp eval_snippet_helper_body(body, assigns) when is_binary(body) do ast = Code.string_to_quoted!(body) bindings = %{assigns: assigns} @@ -619,23 +620,28 @@ defmodule Beacon.RuntimeRenderer do {:ok, Beacon.Client.LiveViewCompiler.render(ast, full_assigns)} _ -> - # Lazy-load from DB - case Beacon.Content.get_site_setting(site, key) do - %{value: template} when is_binary(template) -> - publish_site_setting(site, key, template) - - case :ets.lookup(@table, {site, :site_setting, key}) do - [{_, ast}] when is_list(ast) -> - full_assigns = Map.delete(assigns, :__changed__) - {:ok, Beacon.Client.LiveViewCompiler.render(ast, full_assigns)} - - _ -> - {:error, :not_found} - end + load_and_render_site_setting(site, key, assigns) + end + end - nil -> - {:error, :not_found} - end + defp load_and_render_site_setting(site, key, assigns) do + case Beacon.Content.get_site_setting(site, key) do + %{value: template} when is_binary(template) -> + publish_site_setting(site, key, template) + render_cached_site_setting(site, key, assigns) + + nil -> + {:error, :not_found} + end + end + + defp render_cached_site_setting(site, key, assigns) do + case :ets.lookup(@table, {site, :site_setting, key}) do + [{_, ast}] when is_list(ast) -> + {:ok, Beacon.Client.LiveViewCompiler.render(ast, Map.delete(assigns, :__changed__))} + + _ -> + {:error, :not_found} end end @@ -682,22 +688,23 @@ defmodule Beacon.RuntimeRenderer do all_routes = :ets.match(@table, {{site, :route, :"$1"}, :"$2"}) Enum.find_value(all_routes, :error, fn [route_path, page_id] -> - route_segments = String.split(route_path, "/", trim: true) - - if length(route_segments) == length(request_segments) do - matches? = - Enum.zip(route_segments, request_segments) - |> Enum.all?(fn - {":" <> _, _} -> true - {"*" <> _, _} -> true - {a, b} -> a == b - end) - - if matches?, do: {:ok, page_id}, else: nil - end + if route_matches?(route_path, request_segments), do: {:ok, page_id} end) end + # A route matches when it has the same number of segments and every one is a + # `:param`, a `*glob`, or literally equal. + defp route_matches?(route_path, request_segments) do + route_segments = String.split(route_path, "/", trim: true) + + length(route_segments) == length(request_segments) and + route_segments |> Enum.zip(request_segments) |> Enum.all?(&segment_matches?/1) + end + + defp segment_matches?({":" <> _, _}), do: true + defp segment_matches?({"*" <> _, _}), do: true + defp segment_matches?({route_segment, request_segment}), do: route_segment == request_segment + defp load_page_by_path(site, path) do config = Beacon.Config.fetch!(site) @@ -711,59 +718,50 @@ defmodule Beacon.RuntimeRenderer do _ -> Beacon.Config.effective_ttl(config, :pages) end - Beacon.Cache.fetch(@table, {site, :page_load, path}, fn -> - wait_for_load_slot(site) + Beacon.Cache.fetch(@table, {site, :page_load, path}, fn -> load_page_from_db(site, path) end, ttl) + end - try do - case Beacon.Content.list_published_pages_for_paths(site, [path]) do - [page] -> - Beacon.RuntimeRenderer.Loader.load_page(site, page) - {:ok, page.id} + defp load_page_from_db(site, path) do + wait_for_load_slot(site) - _ -> - # No exact match — try matching against dynamic route patterns in the DB - match_dynamic_route_from_db(site, path) - end - after - release_load_slot(site) - end - end, ttl) + case Beacon.Content.list_published_pages_for_paths(site, [path]) do + [page] -> + Beacon.RuntimeRenderer.Loader.load_page(site, page) + {:ok, page.id} + + _ -> + # No exact match — try matching against dynamic route patterns in the DB + match_dynamic_route_from_db(site, path) + end + after + release_load_slot(site) end defp match_dynamic_route_from_db(site, path) do request_segments = String.split(path, "/", trim: true) # Query only paths (lightweight) to find a dynamic route pattern that matches - Beacon.Content.list_published_page_paths(site) + site + |> Beacon.Content.list_published_page_paths() |> Enum.find_value(:error, fn {_page_id, route_path} -> - route_segments = String.split(route_path, "/", trim: true) - - if length(route_segments) == length(request_segments) do - matches? = - Enum.zip(route_segments, request_segments) - |> Enum.all?(fn - {":" <> _, _} -> true - {"*" <> _, _} -> true - {a, b} -> a == b - end) + if route_matches?(route_path, request_segments), do: load_page_at_path(site, route_path) + end) + end - if matches? do - # Found a matching dynamic route — load the page by its pattern path - case Beacon.Content.list_published_pages_for_paths(site, [route_path]) do - [page] -> - Beacon.RuntimeRenderer.Loader.load_page(site, page) - {:ok, page.id} + # Found a matching dynamic route — load the page by its pattern path. + defp load_page_at_path(site, route_path) do + case Beacon.Content.list_published_pages_for_paths(site, [route_path]) do + [page] -> + Beacon.RuntimeRenderer.Loader.load_page(site, page) + {:ok, page.id} - _ -> - nil - end - end - end - end) + _ -> + nil + end end @doc """ - @doc """ + @doc \""" Registers a route (path → page_id) in ETS without loading the page IR. Used at boot to populate the route index for dynamic route matching. """ @@ -799,18 +797,27 @@ defmodule Beacon.RuntimeRenderer do config = Beacon.Config.fetch!(site) ttl = Beacon.Config.effective_ttl(config, :pages) - Beacon.Cache.fetch(@table, {site, :page_load, page_id}, fn -> - wait_for_load_slot(site) + Beacon.Cache.fetch( + @table, + {site, :page_load, page_id}, + fn -> + wait_for_load_slot(site) - try do - case Beacon.Content.get_published_page(site, page_id) do - nil -> :error - page -> Beacon.RuntimeRenderer.Loader.load_page(site, page); :ok + try do + case Beacon.Content.get_published_page(site, page_id) do + nil -> + :error + + page -> + Beacon.RuntimeRenderer.Loader.load_page(site, page) + :ok + end + after + release_load_slot(site) end - after - release_load_slot(site) - end - end, ttl) + end, + ttl + ) end defp wait_for_load_slot(site) do @@ -1078,16 +1085,13 @@ defmodule Beacon.RuntimeRenderer do Beacon.GraphQL.QueryExecutor.execute_page_queries(site, page_queries, path_params, query_params) # Convert string keys to atoms for template assigns - atomized = - Map.new(assigns, fn {k, v} -> - key = if is_binary(k), do: safe_to_existing_atom(k), else: k - {key, v} - end) - - {atomized, endpoint_names} + {Map.new(assigns, &atomize_assign_key/1), endpoint_names} end end + defp atomize_assign_key({key, value}) when is_binary(key), do: {safe_to_existing_atom(key), value} + defp atomize_assign_key({key, value}), do: {key, value} + defp load_page_queries(site, page_id) do # Page queries use binary_id (UUID) foreign keys. If page_id is not a valid UUID # (e.g., in tests using custom string IDs), there can't be any page queries. @@ -1333,31 +1337,32 @@ defmodule Beacon.RuntimeRenderer do # The pattern is a string like "{:incorrect_format, email}" that gets parsed # as an Elixir pattern and matched against the runtime message value. defp match_info_pattern(pattern_string, msg) when is_binary(pattern_string) do - try do - pattern_ast = Code.string_to_quoted!(pattern_string) - match_pattern(pattern_ast, msg, %{}) - rescue - _ -> :no_match - end + pattern_ast = Code.string_to_quoted!(pattern_string) + match_pattern(pattern_ast, msg, %{}) + rescue + _ -> :no_match end defp ensure_site_handlers_loaded(site, type) do ttl = Beacon.Config.effective_ttl(Beacon.Config.fetch!(site), :handlers) - Beacon.Cache.fetch(@table, {site, :handlers_load, type}, fn -> - handlers = - case type do - :event -> Beacon.Content.list_event_handlers(site) - :info -> Beacon.Content.list_info_handlers(site) - end + Beacon.Cache.fetch(@table, {site, :handlers_load, type}, fn -> store_site_handlers(site, type) end, ttl) + end - for handler <- handlers do - name = if type == :event, do: handler.name, else: handler.msg - store_site_handler(site, type, name, handler.code) + # Event handlers are keyed by name, info handlers by the message they match. + defp store_site_handlers(site, type) do + handlers = + case type do + :event -> Beacon.Content.list_event_handlers(site) + :info -> Beacon.Content.list_info_handlers(site) end - :loaded - end, ttl) + for handler <- handlers do + name = if type == :event, do: handler.name, else: handler.msg + store_site_handler(site, type, name, handler.code) + end + + :loaded end def unpublish_page(site, page_id) do @@ -1849,32 +1854,30 @@ defmodule Beacon.RuntimeRenderer do {:component_fun, nil, nil} end + # One key/value of a component's assigns map. `inner_block` carries the slots, + # each of which is itself a map whose `inner_block` is an expression. + defp transform_component_pair({:__changed__, _}), do: {:__changed__, {:literal, nil}} + + defp transform_component_pair({:inner_block, slots}) when is_list(slots) do + {:inner_block, {:literal, Enum.map(slots, &transform_slot/1)}} + end + + defp transform_component_pair({key, value}), do: {key, transform_expr(value)} + + defp transform_slot({:%{}, _, slot_pairs}) do + Enum.into(slot_pairs, %{}, fn + {:__slot__, name} -> {:__slot__, name} + {:inner_block, block_ast} -> {:inner_block, transform_expr(block_ast)} + {k, v} -> {k, transform_expr(v)} + end) + end + + defp transform_slot(other), do: other + # Extract component assigns map, transforming inner_block slots defp extract_component_assigns({:%{}, _, pairs}) do transformed = - Enum.map(pairs, fn - {:__changed__, _} -> - {:__changed__, {:literal, nil}} - - {:inner_block, slots} when is_list(slots) -> - slot_irs = - Enum.map(slots, fn - {:%{}, _, slot_pairs} -> - Enum.into(slot_pairs, %{}, fn - {:__slot__, name} -> {:__slot__, name} - {:inner_block, block_ast} -> {:inner_block, transform_expr(block_ast)} - {k, v} -> {k, transform_expr(v)} - end) - - other -> - other - end) - - {:inner_block, {:literal, slot_irs}} - - {key, value} -> - {key, transform_expr(value)} - end) + Enum.map(pairs, &transform_component_pair/1) {:component_assigns, transformed} end @@ -1886,36 +1889,14 @@ defmodule Beacon.RuntimeRenderer do case args do [{:%{}, _, base_pairs} | rest_args] -> # Extract the base pairs just like the normal map case - transformed_base = - Enum.map(base_pairs, fn - {:__changed__, _} -> - {:__changed__, {:literal, nil}} - - {:inner_block, slots} when is_list(slots) -> - slot_irs = - Enum.map(slots, fn - {:%{}, _, slot_pairs} -> - Enum.into(slot_pairs, %{}, fn - {:__slot__, name} -> {:__slot__, name} - {:inner_block, block_ast} -> {:inner_block, transform_expr(block_ast)} - {k, v} -> {k, transform_expr(v)} - end) - - other -> - other - end) - - {:inner_block, {:literal, slot_irs}} - - {key, value} -> - {key, transform_expr(value)} - end) + transformed_base = Enum.map(base_pairs, &transform_component_pair/1) # The rest map is the second argument — transform it for runtime merge - rest_ir = case rest_args do - [rest_map_ast | _] -> transform_expr(rest_map_ast) - _ -> {:literal, %{}} - end + rest_ir = + case rest_args do + [rest_map_ast | _] -> transform_expr(rest_map_ast) + _ -> {:literal, %{}} + end {:component_assigns_dynamic, transformed_base, rest_ir} @@ -2083,32 +2064,25 @@ defmodule Beacon.RuntimeRenderer do defp safe_to_map(_), do: %{} + # A component called with a single assigns argument; anything else has none. + defp single_arg_assigns([assigns_ir], a, b), do: safe_to_map(eval_ir(assigns_ir, a, b)) + defp single_arg_assigns(_args, _a, _b), do: %{} + # Build inner_block slot entries for CMS components from IR slot descriptors + # The assigns a CMS component receives: slots rendered, everything else + # evaluated, and `__changed__` cleared. + defp cms_component_assigns(pairs, a, b) do + Enum.reduce(pairs, %{}, fn + {:__changed__, _}, acc -> Map.put(acc, :__changed__, nil) + {:inner_block, {:literal, slot_irs}}, acc -> Map.put(acc, :inner_block, build_cms_inner_block(slot_irs, a, b)) + {key, value_ir}, acc -> Map.put(acc, key, eval_ir(value_ir, a, b)) + end) + end + defp build_cms_inner_block(slot_irs, a, b) when is_list(slot_irs) do Enum.map(slot_irs, fn %{__slot__: name, inner_block: block_ir} -> - inner_fn = fn _changed, slot_arg -> - case block_ir do - {:inner_block_ir, ir, let_var} when is_atom(let_var) and not is_nil(let_var) -> - inner_assigns = - Map.merge(a, b) - |> Map.put(let_var, slot_arg) - |> Map.delete(:__changed__) - - render_ir(ir, inner_assigns) - - {:inner_block_ir, ir, _} -> - render_ir(ir, Map.merge(a, b)) - - {:inner_block_ir, ir} -> - render_ir(ir, Map.merge(a, b)) - - _ -> - "" - end - end - - %{__slot__: name, inner_block: inner_fn} + %{__slot__: name, inner_block: slot_inner_fn(block_ir, a, b)} slot -> slot @@ -2117,6 +2091,26 @@ defmodule Beacon.RuntimeRenderer do defp build_cms_inner_block(_, _a, _b), do: [] + # Phoenix calls `entry.inner_block.(changed, argument)`: the first parameter is + # change tracking and the second is the slot argument, e.g. a form struct. + defp slot_inner_fn(block_ir, a, b) do + fn _changed, slot_arg -> render_slot_block(block_ir, slot_arg, a, b) end + end + + defp render_slot_block({:inner_block_ir, ir, let_var}, slot_arg, a, b) when is_atom(let_var) and not is_nil(let_var) do + inner_assigns = + a + |> Map.merge(b) + |> Map.put(let_var, slot_arg) + |> Map.delete(:__changed__) + + render_ir(ir, inner_assigns) + end + + defp render_slot_block({:inner_block_ir, ir, _let_var}, _slot_arg, a, b), do: render_ir(ir, Map.merge(a, b)) + defp render_slot_block({:inner_block_ir, ir}, _slot_arg, a, b), do: render_ir(ir, Map.merge(a, b)) + defp render_slot_block(_block_ir, _slot_arg, _a, _b), do: "" + # Bind a for-loop variable, handling destructuring patterns defp destructure_binding(bindings, {:destructure, :tuple, names}, item) when is_tuple(item) do values = Tuple.to_list(item) @@ -2149,23 +2143,7 @@ defmodule Beacon.RuntimeRenderer do defp evaluate_dynamics(dynamics, assigns, track_changes?) do changed = if track_changes?, do: Map.get(assigns, :__changed__), else: nil - {results, _bindings} = - Enum.reduce(dynamics, {[], %{}}, fn %{deps: deps, expr: expr}, {acc, bindings} -> - case expr do - {:bind, name, value_expr} -> - value = eval_ir(value_expr, assigns, bindings) - {acc, Map.put(bindings, name, value)} - - _ -> - if changed != nil and deps != [] and not Enum.any?(deps, &Map.has_key?(changed, &1)) do - {[nil | acc], bindings} - else - result = eval_ir(expr, assigns, bindings) - {[safe_dynamic(result) | acc], bindings} - end - end - end) - + {results, _bindings} = Enum.reduce(dynamics, {[], %{}}, &evaluate_dynamic(&1, &2, assigns, changed)) Enum.reverse(results) end @@ -2222,34 +2200,31 @@ defmodule Beacon.RuntimeRenderer do end # Anonymous function: fn args -> body end - defp eval_ir({:anon_fn, clauses}, assigns, bindings) do - fn_clauses = clauses - - # Build a function that pattern-matches the first clause - # (simplified: supports single-clause fns which covers the common case) - case fn_clauses do - [{:clause, param_names, body_ir}] -> - case length(param_names) do - 0 -> fn -> eval_ir(body_ir, assigns, bindings) end - 1 -> fn arg1 -> eval_ir(body_ir, assigns, Map.put(bindings, hd(param_names), arg1)) end - 2 -> fn arg1, arg2 -> - b = bindings |> Map.put(Enum.at(param_names, 0), arg1) |> Map.put(Enum.at(param_names, 1), arg2) - eval_ir(body_ir, assigns, b) - end - _ -> fn -> eval_ir(body_ir, assigns, bindings) end + # + # Only the first clause is built, which covers the common single-clause case; + # a multi-clause fn falls back to it. A single-clause fn additionally supports + # arity 2, which a multi-clause one does not. + defp eval_ir({:anon_fn, [{:clause, param_names, body_ir}]}, assigns, bindings) do + case length(param_names) do + 2 -> + fn arg1, arg2 -> + bindings = + bindings + |> Map.put(Enum.at(param_names, 0), arg1) + |> Map.put(Enum.at(param_names, 1), arg2) + + eval_ir(body_ir, assigns, bindings) end - _ -> - # Multi-clause: use first clause as fallback - [{:clause, param_names, body_ir} | _] = fn_clauses - case length(param_names) do - 0 -> fn -> eval_ir(body_ir, assigns, bindings) end - 1 -> fn arg1 -> eval_ir(body_ir, assigns, Map.put(bindings, hd(param_names), arg1)) end - _ -> fn -> eval_ir(body_ir, assigns, bindings) end - end + arity -> + anon_fn(arity, param_names, body_ir, assigns, bindings) end end + defp eval_ir({:anon_fn, [{:clause, param_names, body_ir} | _]}, assigns, bindings) do + anon_fn(length(param_names), param_names, body_ir, assigns, bindings) + end + # The bare `assigns` variable in HEEx refers to the entire assigns map defp eval_ir({:var, :assigns}, assigns, _bindings), do: assigns @@ -2286,46 +2261,15 @@ defmodule Beacon.RuntimeRenderer do end # Phoenix function component call — call the actual component function - defp eval_ir({:component_call, {:component_fun, mod, fun}, {:component_assigns, pairs}}, a, b) when is_atom(mod) and is_atom(fun) and not is_nil(mod) and not is_nil(fun) do + defp eval_ir({:component_call, {:component_fun, mod, fun}, {:component_assigns, pairs}}, a, b) + when is_atom(mod) and is_atom(fun) and not is_nil(mod) and not is_nil(fun) do component_assigns = Enum.reduce(pairs, %{}, fn {:__changed__, _}, acc -> Map.put(acc, :__changed__, nil) {:inner_block, {:literal, slot_irs}}, acc -> - rendered_slots = - Enum.map(slot_irs, fn - %{__slot__: name, inner_block: block_ir} -> - # Phoenix render_slot calls: entry.inner_block.(changed, argument) - # First param is change tracking, second is the slot argument (e.g. form struct) - inner_fn = fn _changed, slot_arg -> - case block_ir do - {:inner_block_ir, ir, let_var} when is_atom(let_var) and not is_nil(let_var) -> - inner_assigns = - Map.merge(a, b) - |> Map.put(let_var, slot_arg) - |> Map.delete(:__changed__) - - render_ir(ir, inner_assigns) - - {:inner_block_ir, ir, _} -> - render_ir(ir, Map.merge(a, b)) - - {:inner_block_ir, ir} -> - render_ir(ir, Map.merge(a, b)) - - _ -> - "" - end - end - - %{__slot__: name, inner_block: inner_fn} - - slot -> - slot - end) - - Map.put(acc, :inner_block, rendered_slots) + Map.put(acc, :inner_block, build_cms_inner_block(slot_irs, a, b)) {key, value_ir}, acc -> Map.put(acc, key, eval_ir(value_ir, a, b)) @@ -2346,13 +2290,7 @@ defmodule Beacon.RuntimeRenderer do if site do component_assigns = - Enum.reduce(pairs, %{}, fn - {:__changed__, _}, acc -> Map.put(acc, :__changed__, nil) - {:inner_block, {:literal, slot_irs}}, acc -> - rendered_slots = build_cms_inner_block(slot_irs, a, b) - Map.put(acc, :inner_block, rendered_slots) - {key, value_ir}, acc -> Map.put(acc, key, eval_ir(value_ir, a, b)) - end) + cms_component_assigns(pairs, a, b) render_component(site, component_name, component_assigns) else @@ -2362,7 +2300,8 @@ defmodule Beacon.RuntimeRenderer do end # Phoenix component call with dynamic assigns (@rest spread) - defp eval_ir({:component_call, {:component_fun, mod, fun}, {:component_assigns_dynamic, base_pairs, rest_ir}}, a, b) when is_atom(mod) and is_atom(fun) and not is_nil(mod) and not is_nil(fun) do + defp eval_ir({:component_call, {:component_fun, mod, fun}, {:component_assigns_dynamic, base_pairs, rest_ir}}, a, b) + when is_atom(mod) and is_atom(fun) and not is_nil(mod) and not is_nil(fun) do # Build base assigns from static pairs (handling inner_block slots) component_assigns = Enum.reduce(base_pairs, %{}, fn @@ -2378,12 +2317,7 @@ defmodule Beacon.RuntimeRenderer do end) |> Map.put_new(:__changed__, nil) - # Merge rest assigns - rest = eval_ir(rest_ir, a, b) - rest_map = if is_map(rest), do: rest, else: if(is_list(rest), do: Map.new(rest), else: %{}) - component_assigns = Map.merge(rest_map, component_assigns) - - apply(mod, fun, [component_assigns]) + apply(mod, fun, [merge_rest_assigns(component_assigns, rest_ir, a, b)]) end # Unresolved component with dynamic assigns — try as CMS component @@ -2396,17 +2330,9 @@ defmodule Beacon.RuntimeRenderer do if site do component_assigns = - Enum.reduce(base_pairs, %{}, fn - {:__changed__, _}, acc -> Map.put(acc, :__changed__, nil) - {:inner_block, {:literal, slot_irs}}, acc -> - rendered_slots = build_cms_inner_block(slot_irs, a, b) - Map.put(acc, :inner_block, rendered_slots) - {key, value_ir}, acc -> Map.put(acc, key, eval_ir(value_ir, a, b)) - end) - - rest = eval_ir(rest_ir, a, b) - rest_map = if is_map(rest), do: rest, else: if(is_list(rest), do: Map.new(rest), else: %{}) - component_assigns = Map.merge(rest_map, component_assigns) + base_pairs + |> cms_component_assigns(a, b) + |> merge_rest_assigns(rest_ir, a, b) render_component(site, component_name, component_assigns) else @@ -2554,24 +2480,10 @@ defmodule Beacon.RuntimeRenderer do if site do case :ets.lookup(@table, {site, :component, component_name}) do [{_, _}] -> - component_assigns = - case args do - [assigns_ir] -> - raw = eval_ir(assigns_ir, a, b) - safe_to_map(raw) - - [] -> - %{} - - _ -> - %{} - end - - render_component(site, component_name, component_assigns) + render_component(site, component_name, single_arg_assigns(args, a, b)) [] -> - evaluated_args = Enum.map(args, &eval_ir(&1, a, b)) - apply(mod, fun, evaluated_args) + apply(mod, fun, Enum.map(args, &eval_ir(&1, a, b))) end else evaluated_args = Enum.map(args, &eval_ir(&1, a, b)) @@ -2626,39 +2538,7 @@ defmodule Beacon.RuntimeRenderer do slot = eval_ir(slot_ir, a, b) slot_arg = if rest != [], do: eval_ir(hd(rest), a, b), else: nil - case slot do - nil -> "" - [] -> "" - content when is_binary(content) -> content - %Phoenix.LiveView.Rendered{} = rendered -> rendered - entries when is_list(entries) -> - # Standard Phoenix slot rendering: each entry has an :inner_block function - results = - Enum.map(entries, fn - %{inner_block: inner_fn} when is_function(inner_fn, 2) -> - inner_fn.(nil, slot_arg) - %{inner_block: inner_fn} when is_function(inner_fn, 1) -> - inner_fn.(slot_arg) - other -> - other - end) - - # For a single slot entry, return the result directly - # (avoids wrapping Rendered structs in a list which Phoenix.HTML.Safe can't handle) - case results do - [single] -> single - multiple -> - # Multiple slots: concatenate their string representations - multiple - |> Enum.map(fn - %Phoenix.LiveView.Rendered{} = r -> r |> Phoenix.HTML.Safe.to_iodata() |> IO.iodata_to_binary() - bin when is_binary(bin) -> bin - other -> safe_to_string(other) - end) - |> IO.iodata_to_binary() - end - _ -> "" - end + render_slot_content(slot, slot_arg) end # Beacon helper calls — look up and execute page helpers from ETS @@ -2672,21 +2552,8 @@ defmodule Beacon.RuntimeRenderer do if site && page_id do case :ets.lookup(@table, {site, page_id, :helper, name}) do - [{_, serialized}] -> - %{code: code_ast, args: args_pattern_ast} = :erlang.binary_to_term(serialized) - - # Match the args pattern against the provided helper_args - case match_pattern(args_pattern_ast, helper_args, %{}) do - {:ok, bindings} -> - result = eval_ast(code_ast, bindings) - safe_to_string(result) - - :no_match -> - "" - end - - [] -> - "" + [{_, serialized}] -> eval_page_helper(serialized, helper_args) + [] -> "" end else "" @@ -2707,30 +2574,77 @@ defmodule Beacon.RuntimeRenderer do if site do case :ets.lookup(@table, {site, :component, component_name}) do - [{_, _}] -> - component_assigns = - case args do - [assigns_ir] -> - raw = eval_ir(assigns_ir, a, b) - safe_to_map(raw) + [{_, _}] -> render_component(site, component_name, single_arg_assigns(args, a, b)) + [] -> apply_kernel_call(fun, args, a, b) + end + else + apply_kernel_call(fun, args, a, b) + end + end - [] -> - %{} + # `@rest` spreads: the rest map is evaluated and merged under the base assigns. + defp merge_rest_assigns(component_assigns, rest_ir, a, b) do + rest_map = + case eval_ir(rest_ir, a, b) do + rest when is_map(rest) -> rest + rest when is_list(rest) -> Map.new(rest) + _ -> %{} + end - _ -> - %{} - end + Map.merge(rest_map, component_assigns) + end - render_component(site, component_name, component_assigns) + # A page helper is stored with the pattern its arguments must match. + defp eval_page_helper(serialized, helper_args) do + %{code: code_ast, args: args_pattern_ast} = :erlang.binary_to_term(serialized) - [] -> - apply_kernel_call(fun, args, a, b) - end - else - apply_kernel_call(fun, args, a, b) + case match_pattern(args_pattern_ast, helper_args, %{}) do + {:ok, bindings} -> safe_to_string(eval_ast(code_ast, bindings)) + :no_match -> "" end end + defp render_slot_content(nil, _slot_arg), do: "" + defp render_slot_content([], _slot_arg), do: "" + defp render_slot_content(content, _slot_arg) when is_binary(content), do: content + defp render_slot_content(%Phoenix.LiveView.Rendered{} = rendered, _slot_arg), do: rendered + + defp render_slot_content(entries, slot_arg) when is_list(entries) do + entries + |> Enum.map(&render_slot_entry(&1, slot_arg)) + |> join_slot_results() + end + + defp render_slot_content(_slot, _slot_arg), do: "" + + # Standard Phoenix slot rendering: each entry has an :inner_block function. + defp render_slot_entry(%{inner_block: inner_fn}, slot_arg) when is_function(inner_fn, 2), do: inner_fn.(nil, slot_arg) + defp render_slot_entry(%{inner_block: inner_fn}, slot_arg) when is_function(inner_fn, 1), do: inner_fn.(slot_arg) + defp render_slot_entry(other, _slot_arg), do: other + + # A single entry is returned as-is, which keeps a Rendered struct out of a + # list that Phoenix.HTML.Safe cannot handle. Several are concatenated. + defp join_slot_results([single]), do: single + + defp join_slot_results(multiple) do + Enum.map_join(multiple, &slot_result_to_string/1) + end + + defp slot_result_to_string(%Phoenix.LiveView.Rendered{} = rendered) do + rendered |> Phoenix.HTML.Safe.to_iodata() |> IO.iodata_to_binary() + end + + defp slot_result_to_string(bin) when is_binary(bin), do: bin + defp slot_result_to_string(other), do: safe_to_string(other) + + defp anon_fn(1, param_names, body_ir, assigns, bindings) do + fn arg1 -> eval_ir(body_ir, assigns, Map.put(bindings, hd(param_names), arg1)) end + end + + defp anon_fn(_arity, _param_names, body_ir, assigns, bindings) do + fn -> eval_ir(body_ir, assigns, bindings) end + end + # Safely call a Kernel function, handling macros that can't be apply'd defp apply_kernel_call(fun, args, a, b) do evaluated_args = Enum.map(args, &eval_ir(&1, a, b)) @@ -2763,15 +2677,18 @@ defmodule Beacon.RuntimeRenderer do defp eval_kernel_macro(:unless, [cond, [do: body]]), do: if(!cond, do: body) defp eval_kernel_macro(:.., [a, b]), do: a..b defp eval_kernel_macro(:.., [a, b, step]), do: a..b//step + defp eval_kernel_macro(:sigil_r, [pattern, modifiers]) do Regex.compile!(pattern, List.to_string(modifiers)) end + defp eval_kernel_macro(:sigil_w, [string, modifiers]) do case modifiers do ~c"a" -> String.split(string) |> Enum.map(&String.to_existing_atom/1) _ -> String.split(string) end end + defp eval_kernel_macro(:sigil_s, [string, _modifiers]), do: string defp eval_kernel_macro(:sigil_S, [string, _modifiers]), do: string defp eval_kernel_macro(:hd, [list]), do: hd(list) @@ -2796,6 +2713,7 @@ defmodule Beacon.RuntimeRenderer do defp eval_kernel_macro(:inspect, [val]), do: inspect(val) defp eval_kernel_macro(:inspect, [val, opts]), do: inspect(val, opts) defp eval_kernel_macro(:throw, [val]), do: throw(val) + defp eval_kernel_macro(fun, args) do require Logger Logger.warning("[RuntimeRenderer] Unhandled kernel macro: #{fun}/#{length(args)}") @@ -2809,26 +2727,21 @@ defmodule Beacon.RuntimeRenderer do [] else Enum.map(enum, fn item -> - inner_bindings = destructure_binding(b, var_name, item) - result = eval_ir(body_ir, a, inner_bindings) - - case result do - list when is_list(list) -> Enum.map(list, &safe_dynamic/1) - single -> [safe_dynamic(single)] - end + b + |> destructure_binding(var_name, item) + |> then(&eval_ir(body_ir, a, &1)) + |> safe_dynamics() end) end end defp eval_comprehension_dynamics(other, a, b) do - result = eval_ir(other, a, b) - - case result do - list when is_list(list) -> Enum.map(list, &safe_dynamic/1) - single -> [safe_dynamic(single)] - end + other |> eval_ir(a, b) |> safe_dynamics() end + defp safe_dynamics(list) when is_list(list), do: Enum.map(list, &safe_dynamic/1) + defp safe_dynamics(single), do: [safe_dynamic(single)] + defp eval_interpolation_part({:literal, value}, _a, _b), do: value defp eval_interpolation_part(expr, a, b) do @@ -3068,25 +2981,7 @@ defmodule Beacon.RuntimeRenderer do apply(module, fun, [left_val | evaluated_args]) {fun, _, args} when is_atom(fun) and is_list(args) -> - evaluated_args = Enum.map(args, &eval_ast(&1, bindings)) - arity = length(evaluated_args) + 1 - - cond do - fun == :assign and arity in [2, 3] -> - apply(Phoenix.Component, :assign, [left_val | evaluated_args]) - fun == :put_flash and arity == 3 -> - apply(Phoenix.LiveView, :put_flash, [left_val | evaluated_args]) - fun == :push_event and arity == 3 -> - apply(Phoenix.LiveView, :push_event, [left_val | evaluated_args]) - fun == :redirect and arity == 2 -> - apply(Phoenix.LiveView, :redirect, [left_val | evaluated_args]) - fun == :push_navigate and arity == 2 -> - apply(Phoenix.LiveView, :push_navigate, [left_val | evaluated_args]) - function_exported?(Kernel, fun, arity) -> - apply(Kernel, fun, [left_val | evaluated_args]) - true -> - raise "unsupported pipe target: #{fun}/#{arity}" - end + apply_pipe(fun, left_val, Enum.map(args, &eval_ast(&1, bindings))) _ -> raise "unsupported pipe target: #{inspect(right)}" @@ -3102,10 +2997,10 @@ defmodule Beacon.RuntimeRenderer do # unless expression defp eval_ast({:unless, _, [condition, clauses]}, bindings) do - unless eval_ast(condition, bindings) do - eval_ast(Keyword.fetch!(clauses, :do), bindings) - else + if eval_ast(condition, bindings) do eval_ast(Keyword.get(clauses, :else), bindings) + else + eval_ast(Keyword.fetch!(clauses, :do), bindings) end end @@ -3131,12 +3026,7 @@ defmodule Beacon.RuntimeRenderer do eval_ast(do_body, final_bindings) {:error, unmatched} when is_list(else_clauses) -> - Enum.find_value(else_clauses, fn {:->, _, [[pattern], body]} -> - case match_pattern(pattern, unmatched, bindings) do - {:ok, new_bindings} -> eval_ast(body, new_bindings) - :no_match -> nil - end - end) + Enum.find_value(else_clauses, &eval_else_clause(&1, unmatched, bindings)) {:error, _} -> nil @@ -3187,19 +3077,7 @@ defmodule Beacon.RuntimeRenderer do defp eval_ast({:case, _, [expr, [do: clauses]]}, bindings) do value = eval_ast(expr, bindings) - Enum.find_value(clauses, fn {:->, _, [[pattern_or_guard], body]} -> - {pattern, guard} = extract_guard(pattern_or_guard) - - case match_pattern(pattern, value, bindings) do - {:ok, new_bindings} -> - if guard == nil or eval_ast(guard, new_bindings) do - eval_ast(body, new_bindings) - end - - :no_match -> - nil - end - end) + Enum.find_value(clauses, &eval_case_clause(&1, value, bindings)) end # Assignment @@ -3232,6 +3110,44 @@ defmodule Beacon.RuntimeRenderer do end) end + defp eval_else_clause({:->, _, [[pattern], body]}, unmatched, bindings) do + case match_pattern(pattern, unmatched, bindings) do + {:ok, new_bindings} -> eval_ast(body, new_bindings) + :no_match -> nil + end + end + + # A clause is taken when its pattern matches and its guard, if any, holds. + defp eval_case_clause({:->, _, [[pattern_or_guard], body]}, value, bindings) do + {pattern, guard} = extract_guard(pattern_or_guard) + + case match_pattern(pattern, value, bindings) do + {:ok, new_bindings} -> + if guard == nil or eval_ast(guard, new_bindings), do: eval_ast(body, new_bindings) + + :no_match -> + nil + end + end + + # The module a bare piped call belongs to. `Phoenix.Component` and + # `Phoenix.LiveView` own the socket helpers; everything else falls to Kernel. + defp apply_pipe(fun, left_val, args) do + arity = length(args) + 1 + + case pipe_module(fun, arity) do + nil -> raise "unsupported pipe target: #{fun}/#{arity}" + module -> apply(module, fun, [left_val | args]) + end + end + + defp pipe_module(:assign, arity) when arity in [2, 3], do: Phoenix.Component + defp pipe_module(:put_flash, 3), do: Phoenix.LiveView + defp pipe_module(:push_event, 3), do: Phoenix.LiveView + defp pipe_module(:redirect, 2), do: Phoenix.LiveView + defp pipe_module(:push_navigate, 2), do: Phoenix.LiveView + defp pipe_module(fun, arity), do: if(function_exported?(Kernel, fun, arity), do: Kernel) + defp eval_ast_with_bindings({:=, _, [pattern, value_ast]}, bindings) do value = eval_ast(value_ast, bindings) @@ -3258,29 +3174,33 @@ defmodule Beacon.RuntimeRenderer do |> elem(1) end + # A clause is taken when its arity matches, its patterns bind, and its guard, + # if any, holds. + defp eval_fn_clause({:->, _, [patterns, body]}, args, bindings) do + {bare_patterns, guard} = extract_fn_guard(patterns) + + if length(bare_patterns) == length(args) do + eval_fn_clause_body(bare_patterns, guard, body, args, bindings) + end + end + + defp eval_fn_clause(_clause, _args, _bindings), do: nil + + defp eval_fn_clause_body(bare_patterns, guard, body, args, bindings) do + case bind_patterns(bare_patterns, args, bindings) do + {:ok, clause_bindings} -> + if guard == nil or eval_ast(guard, clause_bindings), do: {:matched, eval_ast(body, clause_bindings)} + + :no_match -> + nil + end + end + defp fn_arity!([{:->, _, [patterns, _body]} | _rest]), do: length(patterns) defp fn_arity!(_), do: raise(ArgumentError, "anonymous functions must define at least one clause") defp eval_fn_clauses(clauses, args, bindings) do - Enum.find_value(clauses, fn - {:->, _, [patterns, body]} -> - {bare_patterns, guard} = extract_fn_guard(patterns) - - if length(bare_patterns) == length(args) do - case bind_patterns(bare_patterns, args, bindings) do - {:ok, clause_bindings} -> - if guard == nil or eval_ast(guard, clause_bindings) do - {:matched, eval_ast(body, clause_bindings)} - end - - :no_match -> - nil - end - end - - _ -> - nil - end) + Enum.find_value(clauses, &eval_fn_clause(&1, args, bindings)) |> case do {:matched, value} -> value nil -> raise FunctionClauseError, "no anonymous function clause matched #{inspect(args)}" @@ -3349,20 +3269,7 @@ defmodule Beacon.RuntimeRenderer do # Map/struct pattern like %{name: name} defp match_pattern({:%{}, _, pairs}, value, bindings) when is_map(value) do - Enum.reduce_while(pairs, {:ok, bindings}, fn {k, v_pattern}, {:ok, acc} -> - key = if is_atom(k), do: k, else: eval_ast(k, acc) - - case Map.fetch(value, key) do - {:ok, v} -> - case match_pattern(v_pattern, v, acc) do - {:ok, new_acc} -> {:cont, {:ok, new_acc}} - :no_match -> {:halt, :no_match} - end - - :error -> - {:halt, :no_match} - end - end) + Enum.reduce_while(pairs, {:ok, bindings}, &match_map_pair(&1, &2, value)) end # Pin operator: ^variable — match against existing binding value @@ -3395,6 +3302,22 @@ defmodule Beacon.RuntimeRenderer do defp match_pattern(_, _, _), do: :no_match + defp match_map_pair({k, v_pattern}, {:ok, acc}, value) do + key = if is_atom(k), do: k, else: eval_ast(k, acc) + + case Map.fetch(value, key) do + {:ok, v} -> match_map_value(v_pattern, v, acc) + :error -> {:halt, :no_match} + end + end + + defp match_map_value(v_pattern, v, acc) do + case match_pattern(v_pattern, v, acc) do + {:ok, new_acc} -> {:cont, {:ok, new_acc}} + :no_match -> {:halt, :no_match} + end + end + defp match_pattern_sequence(patterns, values, bindings) when length(patterns) == length(values) do Enum.zip(patterns, values) |> Enum.reduce_while({:ok, bindings}, fn {pattern, value}, {:ok, acc} -> diff --git a/lib/beacon/runtime_renderer/pub_sub_handler.ex b/lib/beacon/runtime_renderer/pub_sub_handler.ex index 7b0856529..04cad2133 100644 --- a/lib/beacon/runtime_renderer/pub_sub_handler.ex +++ b/lib/beacon/runtime_renderer/pub_sub_handler.ex @@ -128,7 +128,6 @@ defmodule Beacon.RuntimeRenderer.PubSubHandler do {:noreply, schedule_css_recompilation(state, site)} end - def handle_info({:content_updated, :info_handler, %{site: site}}, state) do RuntimeRenderer.Loader.reload_info_handlers(site) {:noreply, state} diff --git a/lib/beacon/seo/index_now.ex b/lib/beacon/seo/index_now.ex index 84d1efb88..fde1a48fc 100644 --- a/lib/beacon/seo/index_now.ex +++ b/lib/beacon/seo/index_now.ex @@ -35,18 +35,17 @@ defmodule Beacon.SEO.IndexNow do def notify(site, page_url) when is_atom(site) and is_binary(page_url) do config = Beacon.Config.fetch!(site) - unless config.index_now_enabled do - :ok - else + if config.index_now_enabled do key = config.index_now_key - unless key do - Logger.warning("[Beacon.SEO.IndexNow] index_now_enabled is true but index_now_key is not set for site #{site}") - {:error, :no_key} - else + if key do host = URI.parse(page_url).host || URI.parse(Beacon.RuntimeRenderer.public_site_url(site)).host do_notify(page_url, key, host) + else + warn_missing_key(site) end + else + :ok end end @@ -57,18 +56,17 @@ defmodule Beacon.SEO.IndexNow do def notify_batch(site, page_urls) when is_atom(site) and is_list(page_urls) do config = Beacon.Config.fetch!(site) - unless config.index_now_enabled do - :ok - else + if config.index_now_enabled do key = config.index_now_key - unless key do - Logger.warning("[Beacon.SEO.IndexNow] index_now_enabled is true but index_now_key is not set for site #{site}") - {:error, :no_key} - else + if key do host = URI.parse(Beacon.RuntimeRenderer.public_site_url(site)).host do_notify_batch(page_urls, key, host) + else + warn_missing_key(site) end + else + :ok end end @@ -87,7 +85,9 @@ defmodule Beacon.SEO.IndexNow do Task.start(fn -> case notify(site, page_url) do - :ok -> :ok + :ok -> + :ok + {:error, reason} -> Logger.warning("[Beacon.SEO.IndexNow] Failed to notify for #{page_url}: #{inspect(reason)}") end @@ -100,6 +100,11 @@ defmodule Beacon.SEO.IndexNow do # -- Private -- + defp warn_missing_key(site) do + Logger.warning("[Beacon.SEO.IndexNow] index_now_enabled is true but index_now_key is not set for site #{site}") + {:error, :no_key} + end + defp do_notify(url, key, _host) do query = URI.encode_query(%{url: url, key: key}) request_url = "#{@index_now_url}?#{query}" @@ -122,12 +127,13 @@ defmodule Beacon.SEO.IndexNow do end defp do_notify_batch(urls, key, host) do - body = Jason.encode!(%{ - host: host, - key: key, - keyLocation: "https://#{host}/#{key}.txt", - urlList: urls - }) + body = + Jason.encode!(%{ + host: host, + key: key, + keyLocation: "https://#{host}/#{key}.txt", + urlList: urls + }) Logger.info("[Beacon.SEO.IndexNow] Batch notifying #{length(urls)} URLs") @@ -154,6 +160,7 @@ defmodule Beacon.SEO.IndexNow do defp http_post(url, body) do headers = [{~c"content-type", ~c"application/json; charset=utf-8"}] + :httpc.request(:post, {String.to_charlist(url), headers, ~c"application/json", String.to_charlist(body)}, [timeout: 10_000], []) |> handle_httpc_response() end diff --git a/lib/beacon/seo/json_ld.ex b/lib/beacon/seo/json_ld.ex index ad7463d00..27397df7f 100644 --- a/lib/beacon/seo/json_ld.ex +++ b/lib/beacon/seo/json_ld.ex @@ -23,37 +23,28 @@ defmodule Beacon.SEO.JsonLd do @spec build(map(), map(), Beacon.Config.t()) :: [map()] def build(manifest, _layout_manifest, config) do base_url = Beacon.RuntimeRenderer.public_site_url(config.site) - schemas = [] - # Universal schemas - schemas = case breadcrumb_schema(manifest[:path], base_url) do - nil -> schemas - breadcrumb -> [breadcrumb | schemas] - end - - schemas = if root_page?(manifest[:path]) do - schemas = case organization_schema(config, base_url) do - nil -> schemas - org -> [org | schemas] - end + [] + |> prepend(breadcrumb_schema(manifest[:path], base_url)) + |> prepend_root_schemas(manifest, config, base_url) + |> prepend(resolve_collection_json_ld(manifest, config)) + |> Enum.reverse() + end - case website_schema(config, base_url) do - nil -> schemas - ws -> [ws | schemas] - end + # The organization and website schemas belong on the root page only. + defp prepend_root_schemas(schemas, manifest, config, base_url) do + if root_page?(manifest[:path]) do + schemas + |> prepend(organization_schema(config, base_url)) + |> prepend(website_schema(config, base_url)) else schemas end - - # Collection-defined JSON-LD - schemas = case resolve_collection_json_ld(manifest, config) do - nil -> schemas - col_schema -> [col_schema | schemas] - end - - Enum.reverse(schemas) end + defp prepend(schemas, nil), do: schemas + defp prepend(schemas, schema), do: [schema | schemas] + defp resolve_collection_json_ld(manifest, config) do case manifest[:collection] do %{json_ld_mapping: mapping} when is_map(mapping) and map_size(mapping) > 0 -> @@ -63,7 +54,9 @@ defmodule Beacon.SEO.JsonLd do manifest, config ) - _ -> nil + + _ -> + nil end end @@ -83,16 +76,13 @@ defmodule Beacon.SEO.JsonLd do |> String.split("/") |> Enum.reject(&(&1 == "")) - if length(segments) < 1 do + if segments == [] do nil else items = [{"Home", base_url} | build_breadcrumb_items(segments, base_url)] |> Enum.with_index(1) - |> Enum.map(fn {{name, url}, position} -> - item = %{"@type" => "ListItem", "position" => position, "name" => name} - if url, do: Map.put(item, "item", url), else: item - end) + |> Enum.map(&list_item/1) %{ "@context" => "https://schema.org", @@ -142,15 +132,16 @@ defmodule Beacon.SEO.JsonLd do "url" => base_url } - schema = if search_url = Map.get(config, :search_action_url_template) do - Map.put(schema, "potentialAction", %{ - "@type" => "SearchAction", - "target" => search_url, - "query-input" => "required name=search_term_string" - }) - else - schema - end + schema = + if search_url = Map.get(config, :search_action_url_template) do + Map.put(schema, "potentialAction", %{ + "@type" => "SearchAction", + "target" => search_url, + "query-input" => "required name=search_term_string" + }) + else + schema + end schema else @@ -162,7 +153,7 @@ defmodule Beacon.SEO.JsonLd do Builds a FAQPage schema from page FAQ items. - @doc """ + @doc \""" Merges auto-generated schemas with manual raw_schema entries. Manual entries take precedence — if a manual schema has the same `@type`, @@ -185,6 +176,11 @@ defmodule Beacon.SEO.JsonLd do defp root_page?("/"), do: true defp root_page?(_), do: false + defp list_item({{name, url}, position}) do + item = %{"@type" => "ListItem", "position" => position, "name" => name} + if url, do: Map.put(item, "item", url), else: item + end + defp build_breadcrumb_items(segments, base_url) do segments |> Enum.with_index() @@ -208,7 +204,6 @@ defmodule Beacon.SEO.JsonLd do end) end - defp put_if(map, _key, nil), do: map defp put_if(map, _key, ""), do: map defp put_if(map, _key, []), do: map diff --git a/lib/beacon/seo/link_extractor.ex b/lib/beacon/seo/link_extractor.ex index 57662510f..29a5ce464 100644 --- a/lib/beacon/seo/link_extractor.ex +++ b/lib/beacon/seo/link_extractor.ex @@ -32,25 +32,19 @@ defmodule Beacon.SEO.LinkExtractor do end end + # Anything that leaves the site, opens a client, or points within the page. + @external_prefixes ["#", "javascript:", "mailto:", "tel:", "http://", "https://", "//"] + defp internal_link?(%{target_path: href}) do - cond do - href == "" -> false - String.starts_with?(href, "#") -> false - String.starts_with?(href, "javascript:") -> false - String.starts_with?(href, "mailto:") -> false - String.starts_with?(href, "tel:") -> false - String.starts_with?(href, "http://") -> false - String.starts_with?(href, "https://") -> false - String.starts_with?(href, "//") -> false - String.starts_with?(href, "/") -> true - true -> false - end + String.starts_with?(href, "/") and not String.starts_with?(href, @external_prefixes) end defp normalize_path(path) do path - |> String.split("?") |> List.first() - |> String.split("#") |> List.first() + |> String.split("?") + |> List.first() + |> String.split("#") + |> List.first() |> String.trim_trailing("/") |> case do "" -> "/" diff --git a/lib/beacon/seo/metrics.ex b/lib/beacon/seo/metrics.ex index e664617b4..f32dd3616 100644 --- a/lib/beacon/seo/metrics.ex +++ b/lib/beacon/seo/metrics.ex @@ -15,88 +15,119 @@ defmodule Beacon.SEO.Metrics do repo = Beacon.Config.fetch!(site).repo site_str = Atom.to_string(site) - pages = repo.all(from p in "beacon_pages", where: p.site == ^site_str, - select: %{ - id: p.id, - meta_description: p.meta_description, - description: p.description, - og_image: p.og_image, - canonical_url: p.canonical_url, - twitter_card: p.twitter_card, - robots: p.robots, - date_modified: p.date_modified, - collection_id: p.collection_id, - title: p.title - }) + pages = + repo.all( + from p in "beacon_pages", + where: p.site == ^site_str, + select: %{ + id: p.id, + meta_description: p.meta_description, + description: p.description, + og_image: p.og_image, + canonical_url: p.canonical_url, + twitter_card: p.twitter_card, + robots: p.robots, + date_modified: p.date_modified, + collection_id: p.collection_id, + title: p.title + } + ) total = length(pages) - cutoff_90 = DateTime.utc_now() |> DateTime.add(-90 * 86400, :second) + cutoff_90 = DateTime.utc_now() |> DateTime.add(-90 * 86_400, :second) - # Structured data count - structured_count = repo.one( + structured_count = structured_count(repo, site_str) + orphan_count = orphan_count(repo, site_str) + broken_count = broken_count(repo, site_str) + redirect_count = redirect_count(repo, site_str) + + %{ + "total_pages" => total, + "pages_with_description" => count(pages, &has_description?/1), + "pages_with_og_image" => count(pages, &non_empty?(&1.og_image)), + "pages_with_structured_data" => structured_count, + "pages_with_canonical" => count(pages, &non_empty?(&1.canonical_url)), + "pages_with_collection" => count(pages, &(&1.collection_id != nil)), + "pages_with_twitter_card" => count(pages, &non_empty?(&1.twitter_card)), + "avg_seo_score" => average_score(pages, total), + "stale_pages_count" => count(pages, &stale?(&1, cutoff_90)), + "orphan_pages_count" => orphan_count, + "broken_links_count" => broken_count, + "redirect_count" => redirect_count + } + end + + defp structured_count(repo, site_str) do + repo.one( from p in "beacon_pages", - where: p.site == ^site_str and not is_nil(p.raw_schema) and p.raw_schema != ^[], - select: count() + where: p.site == ^site_str and not is_nil(p.raw_schema) and p.raw_schema != ^[], + select: count() ) || 0 + end + + # Pages no internal link points at. + defp orphan_count(repo, site_str) do + linked_ids = + from(l in "beacon_internal_links", + where: l.site == ^site_str and not is_nil(l.target_page_id), + select: l.target_page_id, + distinct: true + ) - # Orphan pages - linked_ids = from(l in "beacon_internal_links", - where: l.site == ^site_str and not is_nil(l.target_page_id), - select: l.target_page_id, distinct: true) - orphan_count = repo.one( + repo.one( from p in "beacon_pages", - where: p.site == ^site_str and p.id not in subquery(linked_ids), - select: count() + where: p.site == ^site_str and p.id not in subquery(linked_ids), + select: count() ) || 0 + end - # Broken links - broken_count = repo.one( + defp broken_count(repo, site_str) do + repo.one( from l in "beacon_internal_links", - where: l.site == ^site_str and is_nil(l.target_page_id), - select: count() + where: l.site == ^site_str and is_nil(l.target_page_id), + select: count() ) || 0 + end - # Redirect count - redirect_count = repo.one( - from r in "beacon_redirects", where: r.site == ^site_str, select: count() - ) || 0 + defp redirect_count(repo, site_str) do + repo.one(from r in "beacon_redirects", where: r.site == ^site_str, select: count()) || 0 + end - # SEO score computation - scores = Enum.map(pages, fn p -> - checks = [ - {10, non_empty?(p.title)}, - {10, non_empty?(p.title) and String.length(p.title || "") <= 60}, - {10, non_empty?(p.meta_description) or non_empty?(p.description)}, - {10, non_empty?(p.meta_description || p.description) and String.length(p.meta_description || p.description || "") <= 160}, - {15, non_empty?(p.og_image)}, - {5, non_empty?(p.canonical_url)}, - {5, non_empty?(p.twitter_card)} - ] - earned = checks |> Enum.filter(&elem(&1, 1)) |> Enum.map(&elem(&1, 0)) |> Enum.sum() - total_possible = checks |> Enum.map(&elem(&1, 0)) |> Enum.sum() - if total_possible > 0, do: earned / total_possible * 100, else: 0.0 - end) - - avg_score = if total > 0, do: Float.round(Enum.sum(scores) / total, 1), else: 0.0 + defp has_description?(page), do: non_empty?(page.meta_description) or non_empty?(page.description) - %{ - "total_pages" => total, - "pages_with_description" => count(pages, fn p -> non_empty?(p.meta_description) or non_empty?(p.description) end), - "pages_with_og_image" => count(pages, fn p -> non_empty?(p.og_image) end), - "pages_with_structured_data" => structured_count, - "pages_with_canonical" => count(pages, fn p -> non_empty?(p.canonical_url) end), - "pages_with_collection" => count(pages, fn p -> p.collection_id != nil end), - "pages_with_twitter_card" => count(pages, fn p -> non_empty?(p.twitter_card) end), - "avg_seo_score" => avg_score, - "stale_pages_count" => count(pages, fn p -> - p.date_modified == nil or DateTime.compare(p.date_modified, cutoff_90) == :lt - end), - "orphan_pages_count" => orphan_count, - "broken_links_count" => broken_count, - "redirect_count" => redirect_count - } + defp stale?(page, cutoff), do: page.date_modified == nil or DateTime.compare(page.date_modified, cutoff) == :lt + + defp average_score(_pages, 0), do: 0.0 + + defp average_score(pages, total) do + scores = Enum.map(pages, &page_score/1) + Float.round(Enum.sum(scores) / total, 1) + end + + # Percentage of the points a page earns across the SEO checks. + defp page_score(page) do + checks = score_checks(page) + earned = checks |> Enum.filter(&elem(&1, 1)) |> Enum.map(&elem(&1, 0)) |> Enum.sum() + total_possible = checks |> Enum.map(&elem(&1, 0)) |> Enum.sum() + if total_possible > 0, do: earned / total_possible * 100, else: 0.0 end + defp score_checks(page) do + description = page.meta_description || page.description + + [ + {10, non_empty?(page.title)}, + {10, within?(page.title, 60)}, + {10, has_description?(page)}, + {10, within?(description, 160)}, + {15, non_empty?(page.og_image)}, + {5, non_empty?(page.canonical_url)}, + {5, non_empty?(page.twitter_card)} + ] + end + + defp within?(value, max), do: non_empty?(value) and String.length(value || "") <= max + defp count(pages, fun), do: Enum.count(pages, fun) defp non_empty?(nil), do: false diff --git a/lib/beacon/template.ex b/lib/beacon/template.ex index 1a78fe382..79a8aad42 100644 --- a/lib/beacon/template.ex +++ b/lib/beacon/template.ex @@ -21,11 +21,13 @@ defmodule Beacon.Template do def render_path(site, path_info, query_params \\ %{}) when is_atom(site) and is_list(path_info) and is_map(query_params) do path = "/" <> Enum.join(path_info, "/") - with {:ok, page_id} <- Beacon.RuntimeRenderer.lookup_page(site, path) do - {:ok, params_assigns} = Beacon.RuntimeRenderer.handle_params_assigns(site, path, Map.drop(query_params, ["path"])) - Beacon.RuntimeRenderer.render_to_string(site, page_id, params_assigns) - else - :error -> :error + case Beacon.RuntimeRenderer.lookup_page(site, path) do + {:ok, page_id} -> + {:ok, params_assigns} = Beacon.RuntimeRenderer.handle_params_assigns(site, path, Map.drop(query_params, ["path"])) + Beacon.RuntimeRenderer.render_to_string(site, page_id, params_assigns) + + :error -> + :error end end @@ -45,12 +47,14 @@ defmodule Beacon.Template do @spec assigns(Beacon.Page.t()) :: map() def assigns(%Beacon.Content.Page{} = page) do path_info = for segment <- String.split(page.path, "/"), segment != "", do: segment + beacon_assigns = %Beacon.Web.BeaconAssigns{ site: page.site, path_params: Beacon.Router.path_params(page.path, path_info), query_params: %{}, page: %{path: page.path, title: page.title} } + route_assigns = Beacon.Private.route_assigns(page.site, page.path) route_assigns diff --git a/lib/beacon/template/expression_parser.ex b/lib/beacon/template/expression_parser.ex index 7905202cc..3a791e2f7 100644 --- a/lib/beacon/template/expression_parser.ex +++ b/lib/beacon/template/expression_parser.ex @@ -96,16 +96,25 @@ defmodule Beacon.Template.ExpressionParser do str |> String.graphemes() |> Enum.reduce({[], "", nil}, fn - "\"", {parts, current, nil} -> {parts, current <> "\"", "\""} - "\"", {parts, current, "\""} -> {parts, current <> "\"", nil} - "'", {parts, current, nil} -> {parts, current <> "'", "'"} - "'", {parts, current, "'"} -> {parts, current <> "'", nil} + "\"", {parts, current, nil} -> + {parts, current <> "\"", "\""} + + "\"", {parts, current, "\""} -> + {parts, current <> "\"", nil} + + "'", {parts, current, nil} -> + {parts, current <> "'", "'"} + + "'", {parts, current, "'"} -> + {parts, current <> "'", nil} + char, {parts, current, nil} -> if char == delimiter do {[current | parts], "", nil} else {parts, current <> char, nil} end + char, {parts, current, quote_char} -> {parts, current <> char, quote_char} end) @@ -155,22 +164,26 @@ defmodule Beacon.Template.ExpressionParser do Parse a literal value from a string. """ def parse_literal(str) do - str = String.trim(str) + case String.trim(str) do + "true" -> true + "false" -> false + "nil" -> nil + "null" -> nil + trimmed -> parse_quoted_or_number(trimmed) + end + end + defp parse_quoted_or_number(str) do cond do - str == "true" -> true - str == "false" -> false - str == "nil" or str == "null" -> nil - String.starts_with?(str, "\"") and String.ends_with?(str, "\"") -> - String.slice(str, 1..-2//1) - String.starts_with?(str, "'") and String.ends_with?(str, "'") -> - String.slice(str, 1..-2//1) - match?({_, ""}, Integer.parse(str)) -> - String.to_integer(str) - match?({_, ""}, Float.parse(str)) -> - String.to_float(str) - true -> - str + quoted?(str, "\"") -> String.slice(str, 1..-2//1) + quoted?(str, "'") -> String.slice(str, 1..-2//1) + match?({_, ""}, Integer.parse(str)) -> String.to_integer(str) + match?({_, ""}, Float.parse(str)) -> String.to_float(str) + true -> str end end + + defp quoted?(str, quote_char) do + String.starts_with?(str, quote_char) and String.ends_with?(str, quote_char) + end end diff --git a/lib/beacon/template/formatter.ex b/lib/beacon/template/formatter.ex index 448957813..b8c24a4a4 100644 --- a/lib/beacon/template/formatter.ex +++ b/lib/beacon/template/formatter.ex @@ -1,2 +1,3 @@ defmodule Beacon.Template.Formatter do + @moduledoc false end diff --git a/lib/beacon/template/heex_converter.ex b/lib/beacon/template/heex_converter.ex index 4842b5211..ddd2caffa 100644 --- a/lib/beacon/template/heex_converter.ex +++ b/lib/beacon/template/heex_converter.ex @@ -44,28 +44,30 @@ defmodule Beacon.Template.HEExConverter do @spec convert(binary()) :: {binary(), [binary()]} def convert(template) when is_binary(template) do - {result, warnings} = {template, []} - |> replace_known_functions() - |> convert_eex_expressions() - |> convert_assigns() - |> convert_bracket_access() - |> convert_phoenix_links() - |> convert_phx_events() - |> convert_simple_conditionals() - |> convert_simple_loops() - |> convert_eex_output_tags() - |> strip_heex_comments() - |> cleanup_eex_remnants() - |> flag_remaining_issues() + {result, warnings} = + {template, []} + |> replace_known_functions() + |> convert_eex_expressions() + |> convert_assigns() + |> convert_bracket_access() + |> convert_phoenix_links() + |> convert_phx_events() + |> convert_simple_conditionals() + |> convert_simple_loops() + |> convert_eex_output_tags() + |> strip_heex_comments() + |> cleanup_eex_remnants() + |> flag_remaining_issues() {result, Enum.uniq(Enum.reverse(warnings))} end # Replace known function calls with enriched field references defp replace_known_functions({template, warnings}) do - result = Enum.reduce(@function_replacements, template, fn {pattern, replacement}, acc -> - Regex.replace(pattern, acc, replacement) - end) + result = + Enum.reduce(@function_replacements, template, fn {pattern, replacement}, acc -> + Regex.replace(pattern, acc, replacement) + end) {result, warnings} end @@ -74,19 +76,21 @@ defmodule Beacon.Template.HEExConverter do defp convert_eex_expressions({template, warnings}) do # {expr} (HEEx expression tags) → {{ expr }} # But only for simple expressions, not control flow - result = Regex.replace( - ~r/\{([a-zA-Z_][a-zA-Z0-9_.| :"%-]+)\}/, - template, - fn full, expr -> - expr = String.trim(expr) - # Don't convert if it's an HTML attribute value, class expression, etc. - if String.contains?(expr, ":") and not String.contains?(expr, "|") do - full # Leave as-is (likely a keyword list or map) - else - "{{ #{expr} }}" + result = + Regex.replace( + ~r/\{([a-zA-Z_][a-zA-Z0-9_.| :"%-]+)\}/, + template, + fn full, expr -> + expr = String.trim(expr) + # Don't convert if it's an HTML attribute value, class expression, etc. + if String.contains?(expr, ":") and not String.contains?(expr, "|") do + # Leave as-is (likely a keyword list or map) + full + else + "{{ #{expr} }}" + end end - end - ) + ) {result, warnings} end @@ -100,11 +104,12 @@ defmodule Beacon.Template.HEExConverter do # var["key"] → var.key (nested bracket access to dot notation) defp convert_bracket_access({template, warnings}) do - result = template - |> do_bracket_to_dot() - |> do_bracket_to_dot() - |> do_bracket_to_dot() - |> do_bracket_to_dot() + result = + template + |> do_bracket_to_dot() + |> do_bracket_to_dot() + |> do_bracket_to_dot() + |> do_bracket_to_dot() {result, warnings} end @@ -119,49 +124,41 @@ defmodule Beacon.Template.HEExConverter do # <.link navigate={...}>... → ... defp convert_phoenix_links({template, warnings}) do - result = template - # Handle various <.link> attribute orderings - |> then(fn t -> - Regex.replace(~r/<\.link\s+([^>]*?)navigate=\{([^}]+)\}([^>]*)>/, t, fn _, before, path, after_ -> - attrs = String.trim("#{before}#{after_}") - if attrs == "" do - "" - else - "" - end - end) - end) - |> then(fn t -> - Regex.replace(~r/<\.link\s+([^>]*?)navigate="([^"]+)"([^>]*)>/, t, fn _, before, path, after_ -> - attrs = String.trim("#{before}#{after_}") - if attrs == "" do - "" - else - "" - end - end) - end) - |> then(fn t -> - Regex.replace(~r/<\.link\s+([^>]*?)href=\{([^}]+)\}([^>]*)>/, t, fn _, before, path, after_ -> - attrs = String.trim("#{before}#{after_}") - if attrs == "" do - "" - else - "" - end - end) - end) - |> String.replace("", "") + result = + template + # Handle various <.link> attribute orderings + |> then(fn t -> Regex.replace(~r/<\.link\s+([^>]*?)navigate=\{([^}]+)\}([^>]*)>/, t, &anchor_interpolated/4) end) + |> then(fn t -> Regex.replace(~r/<\.link\s+([^>]*?)navigate="([^"]+)"([^>]*)>/, t, &anchor_literal/4) end) + |> then(fn t -> Regex.replace(~r/<\.link\s+([^>]*?)href=\{([^}]+)\}([^>]*)>/, t, &anchor_interpolated/4) end) + |> String.replace("", "") {result, warnings} end + # `<.link navigate={@x}>` becomes ``, keeping any other + # attributes the tag carried. + defp anchor_interpolated(_match, before, path, after_) do + anchor(before, after_, "{{ #{String.trim(path)} }}") + end + + defp anchor_literal(_match, before, path, after_) do + anchor(before, after_, path) + end + + defp anchor(before, after_, href) do + case String.trim("#{before}#{after_}") do + "" -> "" + attrs -> "" + end + end + # phx-submit → @submit, phx-click → @click defp convert_phx_events({template, warnings}) do - result = template - |> then(&Regex.replace(~r/phx-submit="([^"]+)"/, &1, "@submit=\"\\1\"")) - |> then(&Regex.replace(~r/phx-click="([^"]+)"/, &1, "@click=\"\\1\"")) - |> then(&Regex.replace(~r/phx-change="([^"]+)"/, &1, "@change=\"\\1\"")) + result = + template + |> then(&Regex.replace(~r/phx-submit="([^"]+)"/, &1, "@submit=\"\\1\"")) + |> then(&Regex.replace(~r/phx-click="([^"]+)"/, &1, "@click=\"\\1\"")) + |> then(&Regex.replace(~r/phx-change="([^"]+)"/, &1, "@change=\"\\1\"")) {result, warnings} end @@ -171,11 +168,12 @@ defmodule Beacon.Template.HEExConverter do # Count if/end blocks if_count = length(Regex.scan(~r/<%=?\s*if\s/, template)) - new_warnings = if if_count > 0 do - ["#{if_count} if/end block(s) need manual conversion to :if/:else directives"] - else - [] - end + new_warnings = + if if_count > 0 do + ["#{if_count} if/end block(s) need manual conversion to :if/:else directives"] + else + [] + end {template, warnings ++ new_warnings} end @@ -184,25 +182,27 @@ defmodule Beacon.Template.HEExConverter do defp convert_simple_loops({template, warnings}) do for_count = length(Regex.scan(~r/<%=?\s*for\s/, template)) - new_warnings = if for_count > 0 do - ["#{for_count} for/end block(s) need manual conversion to :for directives"] - else - [] - end + new_warnings = + if for_count > 0 do + ["#{for_count} for/end block(s) need manual conversion to :for directives"] + else + [] + end {template, warnings ++ new_warnings} end # <%= expr %> → {{ expr }} for remaining output tags defp convert_eex_output_tags({template, warnings}) do - result = Regex.replace( - ~r/<%=\s*(.+?)\s*%>/s, - template, - fn _, expr -> - expr = String.trim(expr) - "{{ #{expr} }}" - end - ) + result = + Regex.replace( + ~r/<%=\s*(.+?)\s*%>/s, + template, + fn _, expr -> + expr = String.trim(expr) + "{{ #{expr} }}" + end + ) {result, warnings} end @@ -225,11 +225,11 @@ defmodule Beacon.Template.HEExConverter do # <% code %> non-output tags → flag remaining = Regex.scan(~r/<%[^=](.+?)%>/s, result) - new_warnings = if length(remaining) > 0 do - ["#{length(remaining)} non-output EEx tag(s) (<% ... %>) need manual review"] - else - [] - end + new_warnings = + case remaining do + [] -> [] + tags -> ["#{length(tags)} non-output EEx tag(s) (<% ... %>) need manual review"] + end {result, warnings ++ new_warnings} end @@ -240,15 +240,21 @@ defmodule Beacon.Template.HEExConverter do # Module function calls still present modules = Regex.scan(~r/([A-Z][a-zA-Z.]+\.[a-z_]+\([^)]*\))/, template) - new_warnings = new_warnings ++ Enum.map(modules, fn [full | _] -> - "Remaining function call: #{String.slice(full, 0, 80)}" - end) + + new_warnings = + new_warnings ++ + Enum.map(modules, fn [full | _] -> + "Remaining function call: #{String.slice(full, 0, 80)}" + end) # my_component calls components = Regex.scan(~r/my_component\("([^"]+)"/, template) - new_warnings = new_warnings ++ Enum.map(components, fn [_, name] -> - "my_component(\"#{name}\") needs component expansion" - end) + + new_warnings = + new_warnings ++ + Enum.map(components, fn [_, name] -> + "my_component(\"#{name}\") needs component expansion" + end) # raw() calls if Regex.match?(~r/raw\(/, template) do diff --git a/lib/beacon/template/helpers.ex b/lib/beacon/template/helpers.ex index 440aa1b48..47e259810 100644 --- a/lib/beacon/template/helpers.ex +++ b/lib/beacon/template/helpers.ex @@ -68,17 +68,23 @@ defmodule Beacon.Template.Helpers do def to_iso_date(value) when is_binary(value) do case DateTime.from_iso8601(value) do {:ok, dt, _} -> dt |> DateTime.to_date() |> Date.to_iso8601() - _ -> - case NaiveDateTime.from_iso8601(value) do - {:ok, ndt} -> ndt |> NaiveDateTime.to_date() |> Date.to_iso8601() - _ -> - case Date.from_iso8601(value) do - {:ok, d} -> Date.to_iso8601(d) - _ -> value - end - end + _ -> from_naive_or_date(value) end end def to_iso_date(_), do: "" + + defp from_naive_or_date(value) do + case NaiveDateTime.from_iso8601(value) do + {:ok, ndt} -> ndt |> NaiveDateTime.to_date() |> Date.to_iso8601() + _ -> from_date(value) + end + end + + defp from_date(value) do + case Date.from_iso8601(value) do + {:ok, date} -> Date.to_iso8601(date) + _ -> value + end + end end diff --git a/lib/beacon/web/api/ast_controller.ex b/lib/beacon/web/api/ast_controller.ex index e2cde19d1..fa6cb55af 100644 --- a/lib/beacon/web/api/ast_controller.ex +++ b/lib/beacon/web/api/ast_controller.ex @@ -125,13 +125,9 @@ defmodule Beacon.Web.API.ASTController do defp fetch_event_handlers(site, table) do case :ets.lookup(table, {site, :site_handler_index, :event}) do [{_, names}] -> - Map.new(names, fn name -> - case :ets.lookup(table, {site, :site_handler, :event, name}) do - [{_, {:actions, action_doc}}] -> {name, action_doc} - _ -> {name, nil} - end - end) - |> Enum.reject(fn {_, v} -> is_nil(v) end) + names + |> Enum.map(&{&1, event_action_doc(site, table, &1)}) + |> Enum.reject(fn {_name, doc} -> is_nil(doc) end) |> Map.new() _ -> @@ -139,6 +135,13 @@ defmodule Beacon.Web.API.ASTController do end end + defp event_action_doc(site, table, name) do + case :ets.lookup(table, {site, :site_handler, :event, name}) do + [{_, {:actions, action_doc}}] -> action_doc + _ -> nil + end + end + defp fetch_page_queries(site, page_id) do Beacon.Content.list_page_queries(site, page_id) |> Enum.map(fn q -> diff --git a/lib/beacon/web/components/layouts.ex b/lib/beacon/web/components/layouts.ex index f8b36130a..bd3d5c8a9 100644 --- a/lib/beacon/web/components/layouts.ex +++ b/lib/beacon/web/components/layouts.ex @@ -103,7 +103,9 @@ defmodule Beacon.Web.Layouts do true -> case Beacon.Config.fetch!(site).title_template do - nil -> rendered_title + nil -> + rendered_title + template -> template |> String.replace("{page_title}", rendered_title) @@ -153,90 +155,87 @@ defmodule Beacon.Web.Layouts do # layout, or config. When no SEO fields are configured, returns []. defp build_seo_meta_tags(%{beacon: %{site: site, private: %{page_id: page_id, layout_id: layout_id}}}) do manifest = Beacon.RuntimeRenderer.fetch_manifest!(site, page_id) - layout_manifest = case Beacon.RuntimeRenderer.fetch_layout_manifest(site, layout_id) do - {:ok, lm} -> lm - :error -> %{} - end + layout_manifest = fetch_layout_manifest(site, layout_id) config = Beacon.Config.fetch!(site) - # Only generate auto-tags when at least one SEO field is explicitly configured - has_page_seo = Enum.any?(~w(meta_description canonical_url og_title og_description og_image twitter_card)a, - fn field -> non_empty?(manifest[field]) end) - has_layout_seo = non_empty?(layout_manifest[:default_og_image]) or non_empty?(layout_manifest[:default_twitter_card]) - has_config_seo = non_empty?(config.site_name) or non_empty?(config.twitter_site) or - non_empty?(config.fb_app_id) or non_empty?(config.default_og_image) - - unless has_page_seo or has_layout_seo or has_config_seo do - return_empty() - else + if any_seo_configured?(manifest, layout_manifest, config) do do_build_seo_meta_tags(manifest, layout_manifest, config) + else + return_empty() end end defp build_seo_meta_tags(_assigns), do: [] - defp return_empty, do: [] - - defp do_build_seo_meta_tags(manifest, layout_manifest, config) do - tags = [] + defp fetch_layout_manifest(site, layout_id) do + case Beacon.RuntimeRenderer.fetch_layout_manifest(site, layout_id) do + {:ok, layout_manifest} -> layout_manifest + :error -> %{} + end + end - # description — from meta_description or fallback to description - desc = manifest[:meta_description] || manifest[:description] - tags = if non_empty?(desc), do: [%{"name" => "description", "content" => desc} | tags], else: tags + @seo_page_fields ~w(meta_description canonical_url og_title og_description og_image twitter_card)a - # og:title — from og_title or fallback to title - og_title = manifest[:og_title] || manifest[:title] - tags = if non_empty?(og_title), do: [%{"property" => "og:title", "content" => og_title} | tags], else: tags + # Auto-tags are only generated when at least one SEO field is set somewhere. + defp any_seo_configured?(manifest, layout_manifest, config) do + Enum.any?(@seo_page_fields, &non_empty?(manifest[&1])) or + Enum.any?([layout_manifest[:default_og_image], layout_manifest[:default_twitter_card]], &non_empty?/1) or + Enum.any?([config.site_name, config.twitter_site, config.fb_app_id, config.default_og_image], &non_empty?/1) + end - # og:description — from og_description or fallback to description - og_desc = manifest[:og_description] || desc - tags = if non_empty?(og_desc), do: [%{"property" => "og:description", "content" => og_desc} | tags], else: tags + defp return_empty, do: [] - # og:image — cascade: page → layout → config + defp do_build_seo_meta_tags(manifest, layout_manifest, config) do + # Each cascade falls back page → layout → config. + desc = manifest[:meta_description] || manifest[:description] og_image = manifest[:og_image] || layout_manifest[:default_og_image] || config.default_og_image - tags = if non_empty?(og_image), do: [%{"property" => "og:image", "content" => og_image} | tags], else: tags - - # og:image dimensions - tags = if non_empty?(og_image) do - case config.default_og_image_dimensions do - {w, h} -> - [%{"property" => "og:image:width", "content" => to_string(w)}, - %{"property" => "og:image:height", "content" => to_string(h)} | tags] - _ -> tags - end - else - tags - end - - # og:type — defaults to "website", template types override via meta_tag_mapping - tags = [%{"property" => "og:type", "content" => "website"} | tags] - - # og:url — canonical URL og_url = manifest[:canonical_url] || Beacon.RuntimeRenderer.public_page_url(config.site, %{path: manifest[:path]}) - tags = if non_empty?(og_url), do: [%{"property" => "og:url", "content" => og_url} | tags], else: tags - - # og:site_name - tags = if non_empty?(config.site_name), do: [%{"property" => "og:site_name", "content" => config.site_name} | tags], else: tags - - # twitter:card — cascade: page → layout → config twitter_card = manifest[:twitter_card] || layout_manifest[:default_twitter_card] || config.default_twitter_card - tags = if non_empty?(twitter_card), do: [%{"name" => "twitter:card", "content" => twitter_card} | tags], else: tags - # twitter:site - tags = if non_empty?(config.twitter_site), do: [%{"name" => "twitter:site", "content" => config.twitter_site} | tags], else: tags + [] + |> put_tag("name", "description", desc) + |> put_tag("property", "og:title", manifest[:og_title] || manifest[:title]) + |> put_tag("property", "og:description", manifest[:og_description] || desc) + |> put_tag("property", "og:image", og_image) + |> put_og_image_dimensions(og_image, config) + # og:type defaults to "website"; template types override via meta_tag_mapping + |> put_tag("property", "og:type", "website") + |> put_tag("property", "og:url", og_url) + |> put_tag("property", "og:site_name", config.site_name) + |> put_tag("name", "twitter:card", twitter_card) + |> put_tag("name", "twitter:site", config.twitter_site) + |> put_tag("property", "fb:app_id", config.fb_app_id) + |> put_collection_tags(manifest, config) + |> Enum.reverse() + end + + defp put_tag(tags, key, name, content) do + if non_empty?(content), do: [%{key => name, "content" => content} | tags], else: tags + end - # fb:app_id - tags = if non_empty?(config.fb_app_id), do: [%{"property" => "fb:app_id", "content" => config.fb_app_id} | tags], else: tags + defp put_og_image_dimensions(tags, og_image, config) do + case {non_empty?(og_image), config.default_og_image_dimensions} do + {true, {width, height}} -> + [ + %{"property" => "og:image:width", "content" => to_string(width)}, + %{"property" => "og:image:height", "content" => to_string(height)} | tags + ] - # Collection meta tags (resolved from mapping, integrated via dedup) - tags = case manifest[:collection] do - %{meta_tag_mapping: mapping} when is_list(mapping) and length(mapping) > 0 -> - col_tags = Beacon.Collection.MetaTagResolver.resolve(mapping, manifest[:fields] || %{}, manifest, config) - deduplicate_meta_tags(col_tags, tags) - _ -> tags + _ -> + tags end + end + + # Collection meta tags, resolved from the mapping and integrated via dedup. + defp put_collection_tags(tags, manifest, config) do + case manifest[:collection] do + %{meta_tag_mapping: [_ | _] = mapping} -> + collection_tags = Beacon.Collection.MetaTagResolver.resolve(mapping, manifest[:fields] || %{}, manifest, config) + deduplicate_meta_tags(collection_tags, tags) - Enum.reverse(tags) + _ -> + tags + end end defp non_empty?(nil), do: false @@ -332,10 +331,13 @@ defmodule Beacon.Web.Layouts do """ def render_schema(%{beacon: %{site: site, private: %{page_id: page_id, layout_id: layout_id}}} = assigns) do manifest = Beacon.RuntimeRenderer.fetch_manifest!(site, page_id) - layout_manifest = case Beacon.RuntimeRenderer.fetch_layout_manifest(site, layout_id) do - {:ok, lm} -> lm - :error -> %{} - end + + layout_manifest = + case Beacon.RuntimeRenderer.fetch_layout_manifest(site, layout_id) do + {:ok, lm} -> lm + :error -> %{} + end + config = Beacon.Config.fetch!(site) # Manual raw_schema from the page diff --git a/lib/beacon/web/data_source.ex b/lib/beacon/web/data_source.ex index 64f8fb8e0..81144ad82 100644 --- a/lib/beacon/web/data_source.ex +++ b/lib/beacon/web/data_source.ex @@ -11,9 +11,10 @@ defmodule Beacon.Web.DataSource do _ -> %{path: path, title: title} = page_assigns = page_assigns(page) - with {:ok, page_title} <- Beacon.Content.render_snippet(title, %{page: page_assigns, data: assigns}) do - page_title - else + case Beacon.Content.render_snippet(title, %{page: page_assigns, data: assigns}) do + {:ok, page_title} -> + page_title + {:error, error} -> Logger.error(""" failed to interpolate page title variables @@ -37,7 +38,16 @@ defmodule Beacon.Web.DataSource do def meta_tags(%{beacon: %{site: site, private: %{page_id: page_id}}} = assigns) do manifest = Beacon.RuntimeRenderer.fetch_manifest!(site, page_id) - page_assigns = %{site: site, id: page_id, path: manifest.path, title: manifest.title, description: manifest.description, meta_tags: manifest.meta_tags, fields: manifest[:fields] || %{}} + + page_assigns = %{ + site: site, + id: page_id, + path: manifest.path, + title: manifest.title, + description: manifest.description, + meta_tags: manifest.meta_tags, + fields: manifest[:fields] || %{} + } assigns |> Beacon.Web.Layouts.meta_tags() diff --git a/lib/beacon/web/live/page_live.ex b/lib/beacon/web/live/page_live.ex index 8294aaafa..7e7a0a732 100644 --- a/lib/beacon/web/live/page_live.ex +++ b/lib/beacon/web/live/page_live.ex @@ -20,46 +20,17 @@ defmodule Beacon.Web.PageLive do :ok = Beacon.PubSub.subscribe_to_page(site, path) end - variant_roll = - case session["beacon_variant_roll"] do - nil -> - Logger.warning(""" - Beacon.Plug is missing from the Router pipeline. - - Page Variants will not be used. - """) - - nil - - roll -> - roll - end + variant_roll = variant_roll(session) # Check if CSS is ready before blocking on page rendering warming = not Beacon.RuntimeCSS.css_ready?(site) - - if warming do - Beacon.RuntimeCSS.compile_async(site) - - if connected?(socket) do - Beacon.PubSub.subscribe_to_css(site) - end - end + if warming, do: start_css_warming(site, socket) path_str = "/" <> Enum.join(path, "/") {:ok, assigns} = Beacon.RuntimeRenderer.mount_assigns(site, path_str, variant_roll: variant_roll) - # Subscribe to GraphQL cache invalidation topics if config.mode == :live and connected?(socket) do - for endpoint_name <- Map.get(assigns.beacon.private, :graphql_endpoint_names, []) do - Beacon.PubSub.subscribe_to_graphql(site, endpoint_name) - end - end - - # Subscribe to page render cache updates - if config.mode == :live and connected?(socket) do - path_str = "/" <> Enum.join(path, "/") - Beacon.PubSub.subscribe_to_page_render(site, path_str) + subscribe_to_render_topics(site, path_str, assigns) end socket = @@ -71,6 +42,36 @@ defmodule Beacon.Web.PageLive do {:ok, socket, layout: {Beacon.Web.Layouts, :dynamic}} end + defp variant_roll(session) do + case session["beacon_variant_roll"] do + nil -> + Logger.warning(""" + Beacon.Plug is missing from the Router pipeline. + + Page Variants will not be used. + """) + + nil + + roll -> + roll + end + end + + defp start_css_warming(site, socket) do + Beacon.RuntimeCSS.compile_async(site) + if connected?(socket), do: Beacon.PubSub.subscribe_to_css(site) + end + + # GraphQL cache invalidation and page render cache updates. + defp subscribe_to_render_topics(site, path_str, assigns) do + for endpoint_name <- Map.get(assigns.beacon.private, :graphql_endpoint_names, []) do + Beacon.PubSub.subscribe_to_graphql(site, endpoint_name) + end + + Beacon.PubSub.subscribe_to_page_render(site, path_str) + end + def render(%{beacon_warming: true} = assigns) do %{beacon: %{site: site}} = assigns warming_html = Beacon.Web.Warming.render(site) @@ -90,66 +91,80 @@ defmodule Beacon.Web.PageLive do if update_available, do: Logger.info("[PageLive] Rendering with update notification") if update_available do - config = Beacon.Config.fetch!(site) - - case config.update_notification_component do - nil -> - notification_assigns = Map.put(assigns, :beacon_page_content, rendered) - - case Beacon.RuntimeRenderer.render_site_setting(site, "notification_template", notification_assigns) do - {:ok, notification_rendered} -> - assigns = - assigns - |> Map.put(:beacon_page_content, rendered) - |> Map.put(:beacon_notification_rendered, notification_rendered) - - ~H""" - <%= @beacon_page_content %> - <%= @beacon_notification_rendered %> - """ - - {:error, :not_found} -> - assigns = Map.put(assigns, :beacon_page_content, rendered) - - ~H""" - <%= @beacon_page_content %> -
- This page has been updated - - -
- """ - end - - custom_mod -> - assigns = - assigns - |> Map.put(:beacon_page_content, rendered) - |> Map.put(:beacon_notification_component, custom_mod) - - ~H""" - <%= @beacon_page_content %> - <%= @beacon_notification_component.render(assigns) %> - """ - end + render_with_update_notification(assigns, site, rendered) else rendered end end + # The site can supply its own notification component, or a + # `notification_template` site setting; the built-in banner is the fallback. + defp render_with_update_notification(assigns, site, rendered) do + case Beacon.Config.fetch!(site).update_notification_component do + nil -> render_notification_template(assigns, site, rendered) + custom_mod -> render_notification_component(assigns, custom_mod, rendered) + end + end + + defp render_notification_template(assigns, site, rendered) do + notification_assigns = Map.put(assigns, :beacon_page_content, rendered) + + case Beacon.RuntimeRenderer.render_site_setting(site, "notification_template", notification_assigns) do + {:ok, notification_rendered} -> render_custom_notification(assigns, rendered, notification_rendered) + {:error, :not_found} -> render_default_notification(assigns, rendered) + end + end + + defp render_custom_notification(assigns, rendered, notification_rendered) do + assigns = + assigns + |> Map.put(:beacon_page_content, rendered) + |> Map.put(:beacon_notification_rendered, notification_rendered) + + ~H""" + <%= @beacon_page_content %> + <%= @beacon_notification_rendered %> + """ + end + + defp render_default_notification(assigns, rendered) do + assigns = Map.put(assigns, :beacon_page_content, rendered) + + ~H""" + <%= @beacon_page_content %> +
+ This page has been updated + + +
+ """ + end + + defp render_notification_component(assigns, custom_mod, rendered) do + assigns = + assigns + |> Map.put(:beacon_page_content, rendered) + |> Map.put(:beacon_notification_component, custom_mod) + + ~H""" + <%= @beacon_page_content %> + <%= @beacon_notification_component.render(assigns) %> + """ + end + def handle_info({:page_render_updated, %{site: msg_site, page_id: _page_id}}, socket) do %{beacon: %{site: site}} = socket.assigns Logger.info("[PageLive] Received page_render_updated for site #{msg_site}, my site: #{site}") @@ -203,13 +218,7 @@ defmodule Beacon.Web.PageLive do {new_assigns, _} = Beacon.GraphQL.QueryExecutor.execute_page_queries(site, page_queries, path_params, query_params) - updated_socket = - Enum.reduce(new_assigns, socket, fn {key, value}, acc -> - assign_key = if is_binary(key), do: String.to_existing_atom(key), else: key - Component.assign(acc, assign_key, value) - end) - - {:noreply, updated_socket} + {:noreply, Enum.reduce(new_assigns, socket, &assign_query_result/2)} else {:noreply, socket} end @@ -247,6 +256,11 @@ defmodule Beacon.Web.PageLive do end end + defp assign_query_result({key, value}, socket) do + assign_key = if is_binary(key), do: String.to_existing_atom(key), else: key + Component.assign(socket, assign_key, value) + end + def handle_event("beacon:apply-update", _params, socket) do {:noreply, updated_socket} = do_live_update(socket) {:noreply, Component.assign(updated_socket, :beacon_update_available, false)} @@ -292,24 +306,9 @@ defmodule Beacon.Web.PageLive do {:ok, params_assigns} = Beacon.RuntimeRenderer.handle_params_assigns(site, path_str, params) - # Update GraphQL subscriptions if navigating to a different page - old_endpoints = Map.get(socket.assigns.beacon.private, :graphql_endpoint_names, []) - new_endpoints = Map.get(params_assigns.beacon.private, :graphql_endpoint_names, []) - if connected?(socket) do - for ep <- old_endpoints -- new_endpoints, do: Beacon.PubSub.unsubscribe_from_graphql(site, ep) - for ep <- new_endpoints -- old_endpoints, do: Beacon.PubSub.subscribe_to_graphql(site, ep) - end - - # Update page render cache subscriptions when navigating between pages - if connected?(socket) do - old_path = "/" <> Enum.join(socket.assigns.beacon.private.live_path, "/") - new_path = "/" <> Enum.join(path_info, "/") - - if old_path != new_path do - Beacon.PubSub.unsubscribe_from_page_render(site, old_path) - Beacon.PubSub.subscribe_to_page_render(site, new_path) - end + resubscribe_graphql(site, socket.assigns, params_assigns) + resubscribe_page_render(site, socket.assigns, path_info) end socket = @@ -322,6 +321,25 @@ defmodule Beacon.Web.PageLive do end end + # Navigating to another page changes which GraphQL endpoints matter. + defp resubscribe_graphql(site, assigns, params_assigns) do + old_endpoints = Map.get(assigns.beacon.private, :graphql_endpoint_names, []) + new_endpoints = Map.get(params_assigns.beacon.private, :graphql_endpoint_names, []) + + for ep <- old_endpoints -- new_endpoints, do: Beacon.PubSub.unsubscribe_from_graphql(site, ep) + for ep <- new_endpoints -- old_endpoints, do: Beacon.PubSub.subscribe_to_graphql(site, ep) + end + + defp resubscribe_page_render(site, assigns, path_info) do + old_path = "/" <> Enum.join(assigns.beacon.private.live_path, "/") + new_path = "/" <> Enum.join(path_info, "/") + + if old_path != new_path do + Beacon.PubSub.unsubscribe_from_page_render(site, old_path) + Beacon.PubSub.subscribe_to_page_render(site, new_path) + end + end + defp do_live_update(socket) do %{beacon: %{site: site, private: %{live_path: path_info}}} = socket.assigns path_str = "/" <> Enum.join(path_info, "/") @@ -344,5 +362,4 @@ defmodule Beacon.Web.PageLive do def make_env(_site) do __ENV__ end - end diff --git a/mix.exs b/mix.exs index faa0bca2b..c76e011c8 100644 --- a/mix.exs +++ b/mix.exs @@ -90,6 +90,7 @@ defmodule Beacon.MixProject do {:tailwind_compiler, github: "BeaconCMS/tailwind_compiler", tag: "v0.0.7"}, esbuild_version(), # Dev, Test, Docs + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:bandit, "~> 1.0", only: :dev, optional: true}, {:phoenix_view, "~> 2.0", only: [:dev, :test]}, {:ex_doc, "~> 0.29", only: :dev}, @@ -127,7 +128,8 @@ defmodule Beacon.MixProject do "esbuild.install --if-missing", "cmd npm install --prefix assets" ], - "assets.build": ["esbuild cdn", "esbuild cdn_min", "esbuild tailwind_bundle"] + "assets.build": ["esbuild cdn", "esbuild cdn_min", "esbuild tailwind_bundle"], + "assets.lint": ["cmd --cd assets npm run lint"] ] end diff --git a/mix.lock b/mix.lock index 984fd9d7e..27ca45b21 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,7 @@ %{ "accent": {:hex, :accent, "1.1.1", "20257356446d45078b19b91608f74669b407b39af891ee3db9ee6824d1cae19d", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.3", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "6d5afa50d4886e3370e04fa501468cbaa6c4b5fe926f72ccfa844ad9e259adae"}, "bandit": {:hex, :bandit, "1.10.4", "02b9734c67c5916a008e7eb7e2ba68aaea6f8177094a5f8d95f1fb99069aac17", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "a5faf501042ac1f31d736d9d4a813b3db4ef812e634583b6a457b0928798a51d"}, + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, "castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, @@ -10,6 +11,7 @@ "cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"}, + "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, diff --git a/test/beacon/client/filters_test.exs b/test/beacon/client/filters_test.exs index 1ddc101ae..484276e34 100644 --- a/test/beacon/client/filters_test.exs +++ b/test/beacon/client/filters_test.exs @@ -55,7 +55,7 @@ defmodule Beacon.Client.FiltersTest do describe "format_number" do test "integer with thousands separator" do - assert Filters.apply("format_number", 1234567, []) == "1,234,567" + assert Filters.apply("format_number", 1_234_567, []) == "1,234,567" end test "float with precision" do diff --git a/test/beacon/content_test.exs b/test/beacon/content_test.exs index 1d0abf1b3..9db6ab3fd 100644 --- a/test/beacon/content_test.exs +++ b/test/beacon/content_test.exs @@ -3,6 +3,7 @@ defmodule Beacon.ContentTest do use Beacon.Test + alias Beacon.BeaconTest.Repo alias Beacon.Content alias Beacon.Content.Component alias Beacon.Content.ErrorPage @@ -16,7 +17,6 @@ defmodule Beacon.ContentTest do alias Beacon.Content.PageEvent alias Beacon.Content.PageSnapshot alias Beacon.Content.PageVariant - alias Beacon.BeaconTest.Repo alias Ecto.Changeset describe "layouts" do @@ -180,7 +180,6 @@ defmodule Beacon.ContentTest do assert {:ok, _} = Content.update_page(page, %{"template" => "
invalid"}) - end test "publish page creates a published event" do @@ -941,7 +940,6 @@ defmodule Beacon.ContentTest do end end - describe "info_handlers" do setup do code = ~S""" diff --git a/test/beacon/runtime_renderer_test.exs b/test/beacon/runtime_renderer_test.exs index af8bc817c..a78880d0a 100644 --- a/test/beacon/runtime_renderer_test.exs +++ b/test/beacon/runtime_renderer_test.exs @@ -371,7 +371,6 @@ defmodule Beacon.RuntimeRendererTest do end - describe "full lifecycle" do test "mount → handle_params → render → handle_event" do RuntimeRenderer.publish_page(@site, "full_1", %{ diff --git a/test/support/beacon_web.ex b/test/support/beacon_web.ex index 23e73272c..5c0b46288 100644 --- a/test/support/beacon_web.ex +++ b/test/support/beacon_web.ex @@ -1,4 +1,6 @@ defmodule Beacon.BeaconTest.Web do + @moduledoc false + defmacro __using__(which) when is_atom(which) do apply(__MODULE__, which, []) end diff --git a/test/support/bypass_helpers.ex b/test/support/bypass_helpers.ex index 2e23261dd..b8daaccb4 100644 --- a/test/support/bypass_helpers.ex +++ b/test/support/bypass_helpers.ex @@ -1,4 +1,6 @@ defmodule Beacon.Support.BypassHelpers do + @moduledoc false + # port 5555 is selected because we are matching the aws test creds in test.exs def start_bypass(_) do bypass = Bypass.open(port: 5555) diff --git a/test/support/data_case.ex b/test/support/data_case.ex index 4597b32e9..9d848196c 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -1,4 +1,6 @@ defmodule Beacon.DataCase do + @moduledoc false + use ExUnit.CaseTemplate using do diff --git a/test/support/endpoints.ex b/test/support/endpoints.ex index b763d1732..48875862e 100644 --- a/test/support/endpoints.ex +++ b/test/support/endpoints.ex @@ -1,4 +1,6 @@ defmodule Beacon.BeaconTest.ProxyEndpoint do + @moduledoc false + use Beacon.ProxyEndpoint, otp_app: :beacon, session_options: Application.compile_env!(:beacon, :session_options), @@ -18,6 +20,8 @@ defmodule Beacon.BeaconTest.Endpoint do end defmodule Beacon.BeaconTest.EndpointB do + @moduledoc false + # The otp app needs to be beacon otherwise Phoenix LiveView will not be # able to build the static path since it tries to get from `Application.app_dir` # which expects that a real "application" is settled. diff --git a/test/support/page_fields.ex b/test/support/page_fields.ex index ee63e134b..e16a03513 100644 --- a/test/support/page_fields.ex +++ b/test/support/page_fields.ex @@ -1,4 +1,6 @@ defmodule Beacon.BeaconTest.PageFields.TagsField do + @moduledoc false + use Phoenix.Component import Beacon.Web.CoreComponents import Ecto.Changeset diff --git a/test/support/routers.ex b/test/support/routers.ex index 3dff2d9f0..f4668c0e5 100644 --- a/test/support/routers.ex +++ b/test/support/routers.ex @@ -69,6 +69,8 @@ defmodule Beacon.BeaconTest.Router do end defmodule Beacon.BeaconTest.OnMount do + @moduledoc false + import Phoenix.Component def on_mount(_scope, _params, _session, socket) do