From 3aa712bbed1f2bce24b5b9aa738b04d5474ba19c Mon Sep 17 00:00:00 2001 From: David Bernheisel Date: Thu, 16 Apr 2026 09:19:44 -0400 Subject: [PATCH 1/7] fixup credo --- mix.exs | 1 + mix.lock | 1 + test/support/helpers.ex | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index f4d6311..f234df1 100644 --- a/mix.exs +++ b/mix.exs @@ -131,6 +131,7 @@ defmodule SEO.MixProject do # Dev / Test {:blend, "~> 0.4.1", only: [:dev, :test]}, {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, + {:jump_credo_checks, "~> 0.1.0", only: [:dev, :test], runtime: false}, {:ex_doc, "~> 0.27", only: :dev, warn_if_outdated: true, runtime: false}, {:jason, "~> 1.0", only: [:dev, :test]}, {:floki, "~> 0.35", only: [:dev, :test]}, diff --git a/mix.lock b/mix.lock index 0422315..913b828 100644 --- a/mix.lock +++ b/mix.lock @@ -9,6 +9,7 @@ "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "floki": {:hex, :floki, "0.38.1", "f002ccac94b3bcb21d40d9b34cc2cc9fd88a8311879120330075b5dde657ebee", [:mix], [], "hexpm", "e744bf0db7ee34b2c8b62767f04071107af0516a81144b9a2f73fe0494200e5b"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "jump_credo_checks": {:hex, :jump_credo_checks, "0.1.0", "8ff038eb868d36bfce6b47916619c68df99ea802adae2a95702b59463120ebb1", [:mix], [{:credo, "~> 1.7", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "bb76a8bff31a1f42289a9ba03f4f5666e6ae061168f6f13280550ad072851a1f"}, "lumis": {:hex, :lumis, "0.3.0", "41b8dee31b25f02b7bb5c2bfb663caf4b28cde852eefc7f478ed4ad1b0537f0a", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4850729521da4115d2feb9bbca5369f259e63ea8674f241d2492f6371abfa856"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_eex": {:hex, :makeup_eex, "1.0.0", "436d4c00204c250b17a775d64e197798aaf374627e6a4f2d3fd3074a8db61db4", [:mix], [{:makeup, "~> 1.2.1 or ~> 1.3", [hex: :makeup, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_html, "~> 0.1.0 or ~> 1.0", [hex: :makeup_html, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "3bb699bc519e4f509f1bf8a2e0ba0e08429edf3580053cd31a4f9c1bc5da86c8"}, diff --git a/test/support/helpers.ex b/test/support/helpers.ex index cf1c8bd..d7e289d 100644 --- a/test/support/helpers.ex +++ b/test/support/helpers.ex @@ -47,7 +47,7 @@ defmodule SEO.Test.Helpers do [{"script", _, json}] -> Jason.decode!(json) - scripts when is_list(scripts) and length(scripts) > 0 -> + scripts when is_list(scripts) and scripts != [] -> Enum.map(scripts, fn {"script", _, json} -> Jason.decode!(json) end) _ -> From cf090c65ac1da1ab53f9b422bde0b0416db3eaad Mon Sep 17 00:00:00 2001 From: David Bernheisel Date: Thu, 16 Apr 2026 09:34:18 -0400 Subject: [PATCH 2/7] JsonLD -> JSONLD --- README.md | 12 +++++------ lib/seo.ex | 4 ++-- lib/seo/json_ld.ex | 16 +++++++-------- lib/seo/json_ld/article.ex | 4 ++-- lib/seo/json_ld/build.ex | 10 ++++----- lib/seo/json_ld/event.ex | 4 ++-- lib/seo/json_ld/faq.ex | 4 ++-- lib/seo/json_ld/local_business.ex | 4 ++-- lib/seo/json_ld/organization.ex | 4 ++-- lib/seo/json_ld/product.ex | 4 ++-- mix.exs | 16 +++++++-------- test/seo/json_ld_test.exs | 34 +++++++++++++++---------------- test/support/helpers.ex | 11 +++------- test/support/my_app_web_impl.ex | 4 ++-- 14 files changed, 63 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index f1ff4a8..101852f 100644 --- a/README.md +++ b/README.md @@ -164,11 +164,11 @@ defimpl SEO.Breadcrumb.Build, for: MyApp.Article do end end -defimpl SEO.JsonLD.Build, for: MyApp.Article do +defimpl SEO.JSONLD.Build, for: MyApp.Article do use MyAppWeb, :verified_routes def build(article, conn) do - SEO.JsonLD.Article.build( + SEO.JSONLD.Article.build( headline: article.title, description: article.description, datePublished: article.published_at, @@ -277,13 +277,13 @@ defmodule MyAppWeb.ArticleMD do """ end - # llms.txt entry — called by your Provider + # llms.txt entry — called by your Provider with the current conn @impl SEO.LLMs - def entry(article) do + def entry(article, conn) do SEO.LLMs.Entry.build( section: "Articles", title: article.title, - url: ~p"/articles/#{article}", + url: url(conn, ~p"/articles/#{article}"), description: article.summary ) end @@ -418,4 +418,4 @@ integration, nested sub-sections, and inline markdown content. > defaults. Technically, your implementation doesn't have to return an > `SEO.OpenGraph` struct, but it's very handy since documentation is present on the > build function so your editor can quickly show you what is available. Knowing is -> half the battle! +> half the battle! \ No newline at end of file diff --git a/lib/seo.ex b/lib/seo.ex index 963b6c0..3e5922b 100644 --- a/lib/seo.ex +++ b/lib/seo.ex @@ -14,7 +14,7 @@ defmodule SEO do - `:facebook` -> `SEO.Facebook` - `:twitter` -> `SEO.Twitter` - `:breadcrumb` -> `SEO.Breadcrumb` - - `:json_ld` -> `SEO.JsonLD` + - `:json_ld` -> `SEO.JSONLD` For example: @@ -128,7 +128,7 @@ defmodule SEO do - + """ end diff --git a/lib/seo/json_ld.ex b/lib/seo/json_ld.ex index bc006eb..1699b7f 100644 --- a/lib/seo/json_ld.ex +++ b/lib/seo/json_ld.ex @@ -1,4 +1,4 @@ -defmodule SEO.JsonLD do +defmodule SEO.JSONLD do @moduledoc """ Renders JSON-LD structured data as ` - """ - end -end diff --git a/lib/seo/breadcrumb/build.ex b/lib/seo/breadcrumb/build.ex deleted file mode 100644 index 45227b6..0000000 --- a/lib/seo/breadcrumb/build.ex +++ /dev/null @@ -1,13 +0,0 @@ -defprotocol SEO.Breadcrumb.Build do - @fallback_to_any true - @moduledoc """ - Implement `build/2` which receives your item and conn and returns a `SEO.Breadcrumb.List.t` or `nil` - """ - - @spec build(term, Plug.Conn.t()) :: SEO.Breadcrumb.List.t() | nil - def build(list_of_items, conn) -end - -defimpl SEO.Breadcrumb.Build, for: Any do - def build(item, _conn), do: SEO.Breadcrumb.List.build(item) -end diff --git a/lib/seo/breadcrumb/list.ex b/lib/seo/breadcrumb/list.ex deleted file mode 100644 index 867080f..0000000 --- a/lib/seo/breadcrumb/list.ex +++ /dev/null @@ -1,90 +0,0 @@ -defmodule SEO.Breadcrumb.List do - @moduledoc """ - A `SEO.Breadcrumb.List` is list of items consisting of a chain of linked Web pages, typically - described using at least their URL and their name, and typically ending with the current page. - - The `:position` property is used to reconstruct the order of the items in a `SEO.Breadcrumb.List`. - The convention is that a breadcrumb list has an `itemListOrder` of `ItemListOrderAscending` - (lower values listed first), and that the first items in this list correspond to the "top" - or beginning of the breadcrumb trail, e.g. with a site or section homepage. The specific values - of 'position' are not assigned meaning for a BreadcrumbList, but they should be integers, - e.g. beginning with '1' for the first item in the list. - """ - - alias SEO.Breadcrumb.ListItem - - defstruct "@context": "https://schema.org", - "@type": "BreadcrumbList", - itemListElement: [] - - @type t :: %__MODULE__{ - "@context": String.t(), - "@type": String.t(), - itemListElement: list(ListItem.t()) - } - - @doc """ - Build a list of items that represent breadcrumbs for your item. - - You may build the list with `SEO.Breadcrumb.ListItem` or with attributes that will build - a ListItem. The position will be inferred from the list provided, but if you need to - supply the position manually, you must supply `SEO.Breadcrumb.ListItem`. - - For example: - - ```elixir - SEO.Breadcrumb.List.build([ - %{name: "Posts", item: Routes.blog_url(@endpoint, :index)}, - %{name: "My Post", item: Routes.blog_url(@endpoint, :show, my_id)} - ]) - ``` - """ - - @spec build(t() | list(map() | Keyword.t()) | nil, SEO.config()) :: t() | nil - def build(attrs, _default \\ nil) - - def build([], _default), do: nil - - def build(attrs, _default) when is_list(attrs) do - case format_items(attrs) do - [] -> nil - items -> %__MODULE__{itemListElement: items} - end - end - - def build(%__MODULE__{} = attrs, _default), do: attrs - - def build(_attrs, _default), do: nil - - @doc false - def to_map(%__MODULE__{} = item) do - %{item | itemListElement: Enum.map(item.itemListElement, &ListItem.to_map/1)} - |> Map.from_struct() - end - - defp reject_empty(items) do - Enum.reject(items, fn - %{} = map when map_size(map) == 0 -> true - [] -> true - _ -> false - end) - end - - defp format_items(items) do - items - |> reject_empty() - |> Enum.with_index(1) - |> Enum.map(fn {item, i} -> - case item do - %ListItem{position: pos} = list_item -> - %{list_item | position: pos || i} - - attrs when is_list(attrs) or is_map(attrs) -> - ListItem.build(attrs, position: i) - - {k, v} -> - ListItem.build([{k, v}], position: i) - end - end) - end -end diff --git a/lib/seo/breadcrumb/list_item.ex b/lib/seo/breadcrumb/list_item.ex deleted file mode 100644 index 73ee0ef..0000000 --- a/lib/seo/breadcrumb/list_item.ex +++ /dev/null @@ -1,53 +0,0 @@ -defmodule SEO.Breadcrumb.ListItem do - @moduledoc """ - One item in a `SEO.Breadcrumb.List` - """ - - defstruct [ - :position, - :item, - :name, - "@type": "ListItem" - ] - - @type t :: %__MODULE__{ - position: pos_integer(), - "@type": String.t(), - item: String.t() | URI.t() - } - - @doc """ - One item within a breadcrumb list. - - The two required keys for every item are: - - - `:item` - The URL of the item, eg: "https://example.com/cats". - - `:name` - The name of the item, eg: "Cats" - - You may optionally provide the position. If unset, then one will be generated - for you based on its position in the list. (1-based) - - - `:position` - The position in the breadcrumb list, eg: 1 (first) - - For example: - - ```elixir - SEO.Breadcrumb.ListItem.build(%{ - name: "Posts", - item: Routes.blog_url(@endpoint, :index) - }) - ``` - """ - - @spec build(SEO.attrs(), SEO.config()) :: t() | nil - def build(attrs, default \\ nil) - - def build(attrs, default) do - SEO.Utils.merge_defaults(__MODULE__, attrs, default) - end - - @doc false - def to_map(%__MODULE__{} = list_item) do - list_item |> Map.from_struct() - end -end diff --git a/lib/seo/config.ex b/lib/seo/config.ex index 56649b9..20b06ea 100644 --- a/lib/seo/config.ex +++ b/lib/seo/config.ex @@ -7,7 +7,6 @@ defmodule SEO.Config do twitter: %{}, unfurl: %{}, open_graph: %{}, - breadcrumb: %{}, json_ld: %{} ] diff --git a/lib/seo/json_ld.ex b/lib/seo/json_ld.ex index 1699b7f..e96745f 100644 --- a/lib/seo/json_ld.ex +++ b/lib/seo/json_ld.ex @@ -22,6 +22,24 @@ defmodule SEO.JSONLD do - `SEO.JSONLD.LocalBusiness` - `SEO.JSONLD.Event` + ## Config defaults + + Site-wide defaults configured via `use SEO, json_ld: %{...}` are merged + into each rendered JSON-LD payload as defaults — any key the item already + supplies wins. The config map uses the string keys the renderer produces, + so the cleanest way to author nested values is with one of the typed + helpers: + + use SEO, + json_ld: %{ + "inLanguage" => "en-US", + "publisher" => SEO.JSONLD.Organization.build(%{name: "Acme"}) + } + + Top-level atom keys are accepted and stringified; nested values are left + untouched, so build them with the typed helpers (or write raw + string-keyed maps) so they match the JSON-LD output shape. + ### Resources - https://json-ld.org/ @@ -37,7 +55,12 @@ defmodule SEO.JSONLD do attr :config, :any, default: nil def meta(assigns) do - assigns = assign(assigns, :item, sanitize(assigns[:item])) + item = + assigns[:item] + |> merge_config_defaults(assigns[:config]) + |> sanitize() + + assigns = assign(assigns, :item, item) ~H"""