diff --git a/.formatter.exs b/.formatter.exs
index e24feee..c50f1d7 100644
--- a/.formatter.exs
+++ b/.formatter.exs
@@ -1,5 +1,9 @@
# Used by "mix format"
[
import_deps: [:phoenix],
- inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
+ inputs: [
+ "{mix,.formatter}.exs",
+ "{config,lib,test}/**/*.{ex,exs}",
+ "priv/wrappers/*.ex"
+ ]
]
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 28100a3..8ceeeb1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -16,15 +16,15 @@ jobs:
- uses: actions/checkout@v2
- uses: erlef/setup-beam@v1
with:
- otp-version: '27'
- elixir-version: '1.18'
+ otp-version: '28'
+ elixir-version: '1.19'
- run: mix deps.get --only dev
- run: mix format --check-formatted
- run: mix credo --strict
test:
runs-on: ubuntu-latest
- name: Tests on latest version
+ name: Tests on oldest version
env:
MIX_ENV: test
CI: "true"
@@ -32,10 +32,10 @@ jobs:
- uses: actions/checkout@v2
- uses: erlef/setup-beam@v1
with:
- otp-version: '27'
- elixir-version: '1.18'
+ otp-version: '25'
+ elixir-version: '1.17'
- run: rm mix.lock
- - run: mix deps.get --only test
+ - run: mix deps.get
- run: mix compile --force --warnings-as-errors
- run: mix test --warnings-as-errors
@@ -49,14 +49,11 @@ jobs:
- uses: actions/checkout@v2
- uses: erlef/setup-beam@v1
with:
- otp-version: '27'
- elixir-version: '1.18'
+ otp-version: '28'
+ elixir-version: '1.19'
- run: rm mix.lock
- - run: mix deps.get --only test
+ - run: mix deps.get
- run: mix compile --force --warnings-as-errors
- run: mix blend.get
- - run: BLEND=phoenix_live_view_v0_18 mix test
- - run: BLEND=phoenix_live_view_v0_19 mix test
- - run: BLEND=phoenix_live_view_v0_20 mix test
- - run: BLEND=phoenix_live_view_v1_0 mix test
- - run: BLEND=phoenix_live_view_v1_1 mix test
+ - run: BLEND=phoenix_live_view_v1_0 mix deps.get && mix test
+ - run: BLEND=phoenix_live_view_v1_1 mix deps.get && mix test
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c151168..761e224 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,24 @@
# Changelog
+## 0.3.0 (2026-04-16)
+
+- **breaking** Require Elixir >=1.17
+- **breaking** LLMs behaviours changed arity to thread the Plug.Conn through
+- **breaking** JSONLD support. See `SEO.JSONLD` modules. Thanks @Flo0807. Anyone using `SEO.Breadcrumb` will need to replace with `SEO.JSONLD.Breadcrumbs`
+- JSON-LD modules (`SEO.JSONLD.*`) are now generated at compile time via a
+ Mix compiler instead of being prebuilt in the package. Register the
+ compiler in your `mix.exs` (`compilers: [:seo_jsonld] ++ Mix.compilers()`)
+ and pick which Schema.org types to materialize via
+ `config :phoenix_seo, json_ld_types: :all` (default is `:google` — the
+ ~24 rich-result types plus their closure). Accepts a single entry or a
+ list. See the README "Installation" section for details.
+- The `:config` attr on `` is removed along with the
+ site-wide `json_ld:` config slot. `"@context"` is now applied to the
+ top-level node by `SEO.JSONLD.meta/1` only, so nested typed builders no
+ longer emit redundant per-node contexts.
+- Convenience wrappers (`SEO.JSONLD.Breadcrumbs`, `SEO.JSONLD.FAQ`,
+ `SEO.JSONLD.Actions`) are emitted only when their dependent modules are
+ present in the compiled closure.
## 0.2.1 (2026-04-13)
- Fixup llms.txt rendering with module/function configs.
diff --git a/README.md b/README.md
index f1ff4a8..fc4a106 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,50 @@ def deps do
end
```
+Register the JSON-LD compiler in your `mix.exs` so `SEO.JSONLD.*` builder
+modules are generated at compile time:
+
+```elixir
+def project do
+ [
+ # ...
+ compilers: [:seo_jsonld] ++ Mix.compilers()
+ ]
+end
+```
+
+By default this emits the ~24 types Google has rich-result guides for,
+along with the supporting types those guides reference (`Question`,
+`Answer`, `ListItem`) and the closure of any types referenced through
+field ranges and inheritance — about 200 modules in total.
+
+If you need the full vocabulary (~820 typed builder modules) or a
+different slice, override the default via application config — a single
+entry or a list of entries:
+
+```elixir
+# config/config.exs
+config :phoenix_seo, json_ld_types: :all
+# or mix-and-match:
+config :phoenix_seo, json_ld_types: [:google, SEO.JSONLD.SearchAction]
+```
+
+Available entries:
+
+- `:google` — types Google has rich-result guides for plus their
+ supporting types. **Default if `:json_ld_types` is not configured.**
+- `:all` — every regular Schema.org class.
+- Category atoms: `:medical`, `:place`, `:travel`, `:shopping`,
+ `:creative_work`, `:action`, `:financial`, etc. See
+ `Mix.Tasks.Compile.SeoJsonld.Generator.groups/0` for the full list.
+- Module names like `SEO.JSONLD.Article`, or strings like `"Article"` /
+ `"schema:Article"`.
+
+Inheritance ancestors and referenced types are pulled into the emitted
+set automatically — so the typespecs and module links always resolve.
+Modules are written to your application's `_build` directory, not into
+the dep tree.
+
## Usage
1. Define an SEO module for your web application and defaults
@@ -151,30 +195,26 @@ defimpl SEO.Unfurl.Build, for: MyApp.Article do
end
end
-defimpl SEO.Breadcrumb.Build, for: MyApp.Article do
+defimpl SEO.JSONLD.Build, for: MyApp.Article do
use MyAppWeb, :verified_routes
def build(article, conn) do
# Because of `Phoenix.Param`, structs will assume the key of `:id` when
- # interpolating the struct into the verified route.
- SEO.Breadcrumb.List.build([
- %{name: "Articles", item: url(conn, ~p"/articles")},
- %{name: article.title, item: url(conn, ~p"/articles/#{article}")}
- ])
- end
-end
-
-defimpl SEO.JsonLD.Build, for: MyApp.Article do
- use MyAppWeb, :verified_routes
-
- def build(article, conn) do
- SEO.JsonLD.Article.build(
- headline: article.title,
- description: article.description,
- datePublished: article.published_at,
- author: %{"@type" => "Person", "name" => article.author},
- mainEntityOfPage: url(conn, ~p"/articles/#{article}")
- )
+ # interpolating the struct into the verified route. Emit multiple JSON-LD
+ # entities by returning a list — breadcrumbs sit alongside the article.
+ [
+ SEO.JSONLD.Article.build(%{
+ headline: article.title,
+ description: article.description,
+ date_published: article.published_at,
+ author: SEO.JSONLD.Person.build(%{name: article.author}),
+ main_entity_of_page: url(conn, ~p"/articles/#{article}")
+ }),
+ SEO.JSONLD.Breadcrumbs.build([
+ %{name: "Articles", item: url(conn, ~p"/articles")},
+ %{name: article.title, item: url(conn, ~p"/articles/#{article}")}
+ ])
+ ]
end
end
```
@@ -277,13 +317,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 +458,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/assets/article-example.png b/assets/article-example.png
new file mode 100644
index 0000000..852339d
Binary files /dev/null and b/assets/article-example.png differ
diff --git a/assets/book-actions-example.png b/assets/book-actions-example.png
new file mode 100644
index 0000000..e0531a8
Binary files /dev/null and b/assets/book-actions-example.png differ
diff --git a/assets/breadcrumb-example.png b/assets/breadcrumb-example.png
index 160da3d..3859021 100644
Binary files a/assets/breadcrumb-example.png and b/assets/breadcrumb-example.png differ
diff --git a/assets/course-list-example.png b/assets/course-list-example.png
new file mode 100644
index 0000000..d001e95
Binary files /dev/null and b/assets/course-list-example.png differ
diff --git a/assets/dataset-search-example.png b/assets/dataset-search-example.png
new file mode 100644
index 0000000..8af297f
Binary files /dev/null and b/assets/dataset-search-example.png differ
diff --git a/assets/discussion-forum-example.png b/assets/discussion-forum-example.png
new file mode 100644
index 0000000..eb44c1e
Binary files /dev/null and b/assets/discussion-forum-example.png differ
diff --git a/assets/education-qa-example.png b/assets/education-qa-example.png
new file mode 100644
index 0000000..5224fc6
Binary files /dev/null and b/assets/education-qa-example.png differ
diff --git a/assets/employer-aggregate-rating-example.png b/assets/employer-aggregate-rating-example.png
new file mode 100644
index 0000000..7700b7f
Binary files /dev/null and b/assets/employer-aggregate-rating-example.png differ
diff --git a/assets/event-details-example.png b/assets/event-details-example.png
new file mode 100644
index 0000000..03d4e0b
Binary files /dev/null and b/assets/event-details-example.png differ
diff --git a/assets/event-result-example.png b/assets/event-result-example.png
new file mode 100644
index 0000000..6806c21
Binary files /dev/null and b/assets/event-result-example.png differ
diff --git a/assets/factcheck-example-result.png b/assets/factcheck-example-result.png
new file mode 100644
index 0000000..5b83362
Binary files /dev/null and b/assets/factcheck-example-result.png differ
diff --git a/assets/faqpage-example.png b/assets/faqpage-example.png
new file mode 100644
index 0000000..2457147
Binary files /dev/null and b/assets/faqpage-example.png differ
diff --git a/assets/image-metadata-example-2.png b/assets/image-metadata-example-2.png
new file mode 100644
index 0000000..9501f96
Binary files /dev/null and b/assets/image-metadata-example-2.png differ
diff --git a/assets/image-metadata-example.png b/assets/image-metadata-example.png
new file mode 100644
index 0000000..9501f96
Binary files /dev/null and b/assets/image-metadata-example.png differ
diff --git a/assets/jobs-search-example.png b/assets/jobs-search-example.png
new file mode 100644
index 0000000..e0064bb
Binary files /dev/null and b/assets/jobs-search-example.png differ
diff --git a/assets/local-business-example.png b/assets/local-business-example.png
new file mode 100644
index 0000000..0a7286d
Binary files /dev/null and b/assets/local-business-example.png differ
diff --git a/assets/loyalty-program-example.png b/assets/loyalty-program-example.png
new file mode 100644
index 0000000..af43b80
Binary files /dev/null and b/assets/loyalty-program-example.png differ
diff --git a/assets/math-solvers-example.png b/assets/math-solvers-example.png
new file mode 100644
index 0000000..1192d30
Binary files /dev/null and b/assets/math-solvers-example.png differ
diff --git a/assets/movie-carousel-example.png b/assets/movie-carousel-example.png
new file mode 100644
index 0000000..0f1d792
Binary files /dev/null and b/assets/movie-carousel-example.png differ
diff --git a/assets/organization-example.png b/assets/organization-example.png
new file mode 100644
index 0000000..676fce4
Binary files /dev/null and b/assets/organization-example.png differ
diff --git a/assets/product-snippet-example.png b/assets/product-snippet-example.png
new file mode 100644
index 0000000..ba5e717
Binary files /dev/null and b/assets/product-snippet-example.png differ
diff --git a/assets/product-variants-example.png b/assets/product-variants-example.png
new file mode 100644
index 0000000..cccb73b
Binary files /dev/null and b/assets/product-variants-example.png differ
diff --git a/assets/qa-example.png b/assets/qa-example.png
new file mode 100644
index 0000000..b3bd740
Binary files /dev/null and b/assets/qa-example.png differ
diff --git a/assets/recipe-example-2.png b/assets/recipe-example-2.png
new file mode 100644
index 0000000..bf558e0
Binary files /dev/null and b/assets/recipe-example-2.png differ
diff --git a/assets/recipe-example.png b/assets/recipe-example.png
new file mode 100644
index 0000000..3e7d51d
Binary files /dev/null and b/assets/recipe-example.png differ
diff --git a/assets/return-policy-example.png b/assets/return-policy-example.png
new file mode 100644
index 0000000..9bf9d67
Binary files /dev/null and b/assets/return-policy-example.png differ
diff --git a/assets/review-snippet-example.png b/assets/review-snippet-example.png
new file mode 100644
index 0000000..e65073d
Binary files /dev/null and b/assets/review-snippet-example.png differ
diff --git a/assets/shipping-policy-example.png b/assets/shipping-policy-example.png
new file mode 100644
index 0000000..7f61e2a
Binary files /dev/null and b/assets/shipping-policy-example.png differ
diff --git a/assets/shopping-knowledge-panel-example.png b/assets/shopping-knowledge-panel-example.png
new file mode 100644
index 0000000..d2bae0e
Binary files /dev/null and b/assets/shopping-knowledge-panel-example.png differ
diff --git a/assets/software-apps-example.png b/assets/software-apps-example.png
new file mode 100644
index 0000000..adba93b
Binary files /dev/null and b/assets/software-apps-example.png differ
diff --git a/assets/vacation-rental-example.png b/assets/vacation-rental-example.png
new file mode 100644
index 0000000..ccff579
Binary files /dev/null and b/assets/vacation-rental-example.png differ
diff --git a/assets/video-example.png b/assets/video-example.png
new file mode 100644
index 0000000..1080945
Binary files /dev/null and b/assets/video-example.png differ
diff --git a/blend.exs b/blend.exs
index df8a4e2..4254a47 100644
--- a/blend.exs
+++ b/blend.exs
@@ -1,7 +1,4 @@
%{
- phoenix_live_view_v0_18: [{:phoenix_live_view, "~> 0.18.0"}],
- phoenix_live_view_v0_19: [{:phoenix_live_view, "~> 0.19.0"}],
- phoenix_live_view_v0_20: [{:phoenix_live_view, "~> 0.20.0"}],
phoenix_live_view_v1_0: [{:phoenix_live_view, "~> 1.0.0"}],
phoenix_live_view_v1_1: [{:phoenix_live_view, "~> 1.1.0"}]
}
diff --git a/blend/phoenix_live_view_v0_18.mix.lock b/blend/phoenix_live_view_v0_18.mix.lock
deleted file mode 100644
index d384959..0000000
--- a/blend/phoenix_live_view_v0_18.mix.lock
+++ /dev/null
@@ -1,28 +0,0 @@
-%{
- "blend": {:hex, :blend, "0.4.1", "d81d39a1b98dddc91dd465d0d6381bc5029c02a9c2018a568f62bb55efd1f64e", [:mix], [], "hexpm", "75c9f391456fa648e973e85c86e5bb4ea9bb1d3750b7b71967e0662e837001ab"},
- "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
- "castore": {:hex, :castore, "1.0.10", "43bbeeac820f16c89f79721af1b3e092399b3a1ecc8df1a472738fd853574911", [:mix], [], "hexpm", "1b0b7ea14d889d9ea21202c43a4fa015eb913021cb535e8ed91946f4b77a8848"},
- "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [: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", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"},
- "earmark_parser": {:hex, :earmark_parser, "1.4.42", "f23d856f41919f17cd06a493923a722d87a2d684f143a1e663c04a2b93100682", [:mix], [], "hexpm", "6915b6ca369b5f7346636a2f41c6a6d78b5af419d61a611079189233358b8b8b"},
- "ex_doc": {:hex, :ex_doc, "0.36.1", "4197d034f93e0b89ec79fac56e226107824adcce8d2dd0a26f5ed3a95efc36b1", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "d7d26a7cf965dacadcd48f9fa7b5953d7d0cfa3b44fa7a65514427da44eafd89"},
- "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"},
- "floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "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, "0.1.2", "93a5ef3d28ed753215dba2d59cb40408b37cccb4a8205e53ef9b5319a992b700", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.16 or ~> 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", "6140eafb28215ad7182282fd21d9aa6dcffbfbe0eb876283bc6b768a6c57b0c3"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"},
- "makeup_html": {:hex, :makeup_html, "0.1.2", "19d4050c0978a4f1618ffe43054c0049f91fe5feeb9ae8d845b5dc79c6008ae5", [:mix], [{:makeup, "~> 1.2", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "b7fb9afedd617d167e6644a0430e49c1279764bfd3153da716d4d2459b0998c5"},
- "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
- "phoenix": {:hex, :phoenix, "1.7.18", "5310c21443514be44ed93c422e15870aef254cf1b3619e4f91538e7529d2b2e4", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "1797fcc82108442a66f2c77a643a62980f342bfeb63d6c9a515ab8294870004e"},
- "phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"},
- "phoenix_live_view": {:hex, :phoenix_live_view, "0.18.18", "1f38fbd7c363723f19aad1a04b5490ff3a178e37daaf6999594d5f34796c47fc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a5810d0472f3189ede6d2a95bda7f31c6113156b91784a3426cb0ab6a6d85214"},
- "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
- "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
- "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"},
- "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
- "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
- "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
- "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"},
-}
diff --git a/blend/phoenix_live_view_v0_19.mix.lock b/blend/phoenix_live_view_v0_19.mix.lock
deleted file mode 100644
index edb71af..0000000
--- a/blend/phoenix_live_view_v0_19.mix.lock
+++ /dev/null
@@ -1,28 +0,0 @@
-%{
- "blend": {:hex, :blend, "0.4.1", "d81d39a1b98dddc91dd465d0d6381bc5029c02a9c2018a568f62bb55efd1f64e", [:mix], [], "hexpm", "75c9f391456fa648e973e85c86e5bb4ea9bb1d3750b7b71967e0662e837001ab"},
- "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
- "castore": {:hex, :castore, "1.0.10", "43bbeeac820f16c89f79721af1b3e092399b3a1ecc8df1a472738fd853574911", [:mix], [], "hexpm", "1b0b7ea14d889d9ea21202c43a4fa015eb913021cb535e8ed91946f4b77a8848"},
- "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [: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", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"},
- "earmark_parser": {:hex, :earmark_parser, "1.4.42", "f23d856f41919f17cd06a493923a722d87a2d684f143a1e663c04a2b93100682", [:mix], [], "hexpm", "6915b6ca369b5f7346636a2f41c6a6d78b5af419d61a611079189233358b8b8b"},
- "ex_doc": {:hex, :ex_doc, "0.36.1", "4197d034f93e0b89ec79fac56e226107824adcce8d2dd0a26f5ed3a95efc36b1", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "d7d26a7cf965dacadcd48f9fa7b5953d7d0cfa3b44fa7a65514427da44eafd89"},
- "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"},
- "floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "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, "0.1.2", "93a5ef3d28ed753215dba2d59cb40408b37cccb4a8205e53ef9b5319a992b700", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.16 or ~> 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", "6140eafb28215ad7182282fd21d9aa6dcffbfbe0eb876283bc6b768a6c57b0c3"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"},
- "makeup_html": {:hex, :makeup_html, "0.1.2", "19d4050c0978a4f1618ffe43054c0049f91fe5feeb9ae8d845b5dc79c6008ae5", [:mix], [{:makeup, "~> 1.2", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "b7fb9afedd617d167e6644a0430e49c1279764bfd3153da716d4d2459b0998c5"},
- "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
- "phoenix": {:hex, :phoenix, "1.7.18", "5310c21443514be44ed93c422e15870aef254cf1b3619e4f91538e7529d2b2e4", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "1797fcc82108442a66f2c77a643a62980f342bfeb63d6c9a515ab8294870004e"},
- "phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"},
- "phoenix_live_view": {:hex, :phoenix_live_view, "0.19.5", "6e730595e8e9b8c5da230a814e557768828fd8dfeeb90377d2d8dbb52d4ec00a", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b2eaa0dd3cfb9bd7fb949b88217df9f25aed915e986a28ad5c8a0d054e7ca9d3"},
- "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
- "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
- "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"},
- "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
- "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
- "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
- "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"},
-}
diff --git a/blend/phoenix_live_view_v0_20.mix.lock b/blend/phoenix_live_view_v0_20.mix.lock
deleted file mode 100644
index cc6a3af..0000000
--- a/blend/phoenix_live_view_v0_20.mix.lock
+++ /dev/null
@@ -1,28 +0,0 @@
-%{
- "blend": {:hex, :blend, "0.4.1", "d81d39a1b98dddc91dd465d0d6381bc5029c02a9c2018a568f62bb55efd1f64e", [:mix], [], "hexpm", "75c9f391456fa648e973e85c86e5bb4ea9bb1d3750b7b71967e0662e837001ab"},
- "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
- "castore": {:hex, :castore, "1.0.10", "43bbeeac820f16c89f79721af1b3e092399b3a1ecc8df1a472738fd853574911", [:mix], [], "hexpm", "1b0b7ea14d889d9ea21202c43a4fa015eb913021cb535e8ed91946f4b77a8848"},
- "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [: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", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"},
- "earmark_parser": {:hex, :earmark_parser, "1.4.42", "f23d856f41919f17cd06a493923a722d87a2d684f143a1e663c04a2b93100682", [:mix], [], "hexpm", "6915b6ca369b5f7346636a2f41c6a6d78b5af419d61a611079189233358b8b8b"},
- "ex_doc": {:hex, :ex_doc, "0.36.1", "4197d034f93e0b89ec79fac56e226107824adcce8d2dd0a26f5ed3a95efc36b1", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "d7d26a7cf965dacadcd48f9fa7b5953d7d0cfa3b44fa7a65514427da44eafd89"},
- "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"},
- "floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "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, "0.1.2", "93a5ef3d28ed753215dba2d59cb40408b37cccb4a8205e53ef9b5319a992b700", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.16 or ~> 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", "6140eafb28215ad7182282fd21d9aa6dcffbfbe0eb876283bc6b768a6c57b0c3"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"},
- "makeup_html": {:hex, :makeup_html, "0.1.2", "19d4050c0978a4f1618ffe43054c0049f91fe5feeb9ae8d845b5dc79c6008ae5", [:mix], [{:makeup, "~> 1.2", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "b7fb9afedd617d167e6644a0430e49c1279764bfd3153da716d4d2459b0998c5"},
- "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
- "phoenix": {:hex, :phoenix, "1.7.18", "5310c21443514be44ed93c422e15870aef254cf1b3619e4f91538e7529d2b2e4", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "1797fcc82108442a66f2c77a643a62980f342bfeb63d6c9a515ab8294870004e"},
- "phoenix_html": {:hex, :phoenix_html, "4.2.0", "83a4d351b66f472ebcce242e4ae48af1b781866f00ef0eb34c15030d4e2069ac", [:mix], [], "hexpm", "9713b3f238d07043583a94296cc4bbdceacd3b3a6c74667f4df13971e7866ec8"},
- "phoenix_live_view": {:hex, :phoenix_live_view, "0.20.17", "f396bbdaf4ba227b82251eb75ac0afa6b3da5e509bc0d030206374237dfc9450", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61d741ffb78c85fdbca0de084da6a48f8ceb5261a79165b5a0b59e5f65ce98b"},
- "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
- "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
- "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"},
- "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
- "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
- "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
- "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"},
-}
diff --git a/blend/phoenix_live_view_v1_0.mix.lock b/blend/phoenix_live_view_v1_0.mix.lock
new file mode 100644
index 0000000..7bffe43
--- /dev/null
+++ b/blend/phoenix_live_view_v1_0.mix.lock
@@ -0,0 +1,27 @@
+%{
+ "blend": {:hex, :blend, "0.5.0", "d3cdc1a463f73b44a4d88ebf8ee2be9ed4f6774548ba374776b56155378c6e53", [:mix], [], "hexpm", "53772edbf11bf20e8c364a579987f44b919e2b8c9d6cba7f1c1aa060fadd4550"},
+ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
+ "credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [: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", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"},
+ "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
+ "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"},
+ "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"},
+ "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, "2.0.2", "88983b72aadb2e8408b06f7c9413804ce7eae2ca2a5a35cb738c6a9cb393c155", [: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.2.0 or ~> 1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "30ac121dda580298ff3378324ffaec94aad5a5b67e0cc6af177c67d5f45629b9"},
+ "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
+ "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"},
+ "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
+ "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
+ "phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
+ "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
+ "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.18", "943431edd0ef8295ffe4949f0897e2cb25c47d3d7ebba2b008d7c68598b887f1", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "724934fd0a68ecc57281cee863674454b06163fed7f5b8005b5e201ba4b23316"},
+ "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
+ "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
+ "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
+ "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
+ "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
+ "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
+ "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
+}
diff --git a/blend/phoenix_live_view_v1_1.mix.lock b/blend/phoenix_live_view_v1_1.mix.lock
new file mode 100644
index 0000000..b6abe80
--- /dev/null
+++ b/blend/phoenix_live_view_v1_1.mix.lock
@@ -0,0 +1,27 @@
+%{
+ "blend": {:hex, :blend, "0.5.0", "d3cdc1a463f73b44a4d88ebf8ee2be9ed4f6774548ba374776b56155378c6e53", [:mix], [], "hexpm", "53772edbf11bf20e8c364a579987f44b919e2b8c9d6cba7f1c1aa060fadd4550"},
+ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
+ "credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [: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", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"},
+ "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
+ "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"},
+ "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"},
+ "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, "2.0.2", "88983b72aadb2e8408b06f7c9413804ce7eae2ca2a5a35cb738c6a9cb393c155", [: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.2.0 or ~> 1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "30ac121dda580298ff3378324ffaec94aad5a5b67e0cc6af177c67d5f45629b9"},
+ "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
+ "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"},
+ "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
+ "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
+ "phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
+ "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
+ "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.28", "8a8e123d018025f756605a2fb02a4854f0d3cd7b207f710fef1fd5d9d72d0254", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24faad535b65089642c3a7d84088109dc58f49c1f1c5a978659855d643466353"},
+ "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
+ "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
+ "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
+ "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
+ "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
+ "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
+ "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
+}
diff --git a/config/config.exs b/config/config.exs
index 123a623..481dd38 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -1,3 +1,8 @@
import Config
+# This library's own compile materializes the full Schema.org vocabulary
+# so its ExDoc site and tests cover every emitted type. User apps get the
+# `:google` default unless they configure otherwise.
+config :phoenix_seo, json_ld_types: :all
+
if Mix.env() == :test, do: import_config("test.exs")
diff --git a/lib/mix/tasks/compile.seo_jsonld.ex b/lib/mix/tasks/compile.seo_jsonld.ex
new file mode 100644
index 0000000..ebbe7f2
--- /dev/null
+++ b/lib/mix/tasks/compile.seo_jsonld.ex
@@ -0,0 +1,167 @@
+defmodule Mix.Tasks.Compile.SeoJsonld do
+ @moduledoc """
+ Generates and compiles `SEO.JSONLD.*` typed builder modules from the
+ Schema.org vocabulary bundled with `:phoenix_seo`.
+
+ ## Usage
+
+ Add the compiler to your project's compiler list:
+
+ def project do
+ [
+ compilers: [:seo_jsonld] ++ Mix.compilers(),
+ # ...
+ ]
+ end
+
+ Pick which Schema.org types to materialize via application config.
+ Accepts a single entry or a list of entries:
+
+ config :phoenix_seo, json_ld_types: :all
+ config :phoenix_seo, json_ld_types: [:google, SEO.JSONLD.SearchAction]
+
+ ### Available config entries
+
+ - `:google` — the types Google has rich-result guides for plus their
+ supporting types (~200 modules with their closure). **This is the
+ default.**
+ - `:all` — every regular Schema.org class (~820 modules).
+ - Category atoms like `:medical`, `:place`, `:travel`, `:shopping`,
+ `:creative_work`, `:action`, etc. See
+ `Mix.Tasks.Compile.SeoJsonld.Generator.groups/0` for the full list.
+ - Module names like `SEO.JSONLD.Article` (or strings like
+ `"Article"` / `"schema:Article"`).
+
+ Ancestors and referenced types are pulled into the emit set
+ automatically, so the typespecs always resolve.
+
+ Defaults to `:google` when no config is supplied.
+ """
+ use Mix.Task.Compiler
+
+ alias Mix.Tasks.Compile.SeoJsonld.Generator
+
+ @manifest_version 2
+
+ @impl Mix.Task.Compiler
+ def run(_args) do
+ Mix.Task.run("loadpaths")
+
+ schema_path = Generator.schema_path()
+
+ unless File.exists?(schema_path) do
+ Mix.raise(
+ "phoenix_seo: schema file missing at #{schema_path}. " <>
+ "Run `mix deps.compile phoenix_seo` to repopulate it."
+ )
+ end
+
+ config = config_for_compile()
+ schema_mtime = File.stat!(schema_path).mtime
+ key = manifest_key(config, schema_mtime)
+
+ case read_manifest() do
+ %{version: @manifest_version, key: ^key} ->
+ {:noop, []}
+
+ previous ->
+ rebuild(config, key, previous)
+ end
+ end
+
+ @impl Mix.Task.Compiler
+ def manifests, do: [manifest_path()]
+
+ @impl Mix.Task.Compiler
+ def clean do
+ case read_manifest() do
+ %{modules: modules} -> Enum.each(modules, &File.rm(beam_path(&1)))
+ _ -> :ok
+ end
+
+ File.rm(manifest_path())
+ File.rm_rf(sources_dir())
+ :ok
+ end
+
+ ## Internal ------------------------------------------------------------
+
+ defp rebuild(config, key, previous) do
+ if previous, do: Enum.each(Map.get(previous, :modules, []), &File.rm(beam_path(&1)))
+ File.rm_rf!(sources_dir())
+
+ # Start the progress line; `parallel_compile` finishes it with the
+ # module count once the .beams are on disk.
+ IO.write("Generating SEO.JSONLD modules...")
+
+ iris = Generator.expand_types(config)
+ sources = Generator.emit_sources(iris)
+
+ src_dir = sources_dir()
+ ebin_dir = Mix.Project.compile_path()
+ File.mkdir_p!(src_dir)
+ File.mkdir_p!(ebin_dir)
+
+ paths =
+ Enum.map(sources, fn {_module, filename, source} ->
+ path = Path.join(src_dir, filename)
+ File.write!(path, source)
+ path
+ end)
+
+ modules = parallel_compile(paths, ebin_dir)
+
+ write_manifest(%{version: @manifest_version, key: key, modules: modules})
+
+ IO.puts(" generated #{length(modules)} modules")
+ {:ok, []}
+ end
+
+ defp parallel_compile(paths, ebin_dir) do
+ # Same module may already be loaded from a previous invocation in the
+ # same VM (e.g. `recompile` in iex). Allow re-definition rather than
+ # noisy warnings.
+ previous = Code.compiler_options()[:ignore_module_conflict]
+ Code.put_compiler_option(:ignore_module_conflict, true)
+
+ try do
+ case Kernel.ParallelCompiler.compile_to_path(paths, ebin_dir, return_diagnostics: true) do
+ {:ok, modules, _diagnostics} ->
+ modules
+
+ {:error, errors, _diagnostics} ->
+ Mix.raise(
+ "seo_jsonld: failed to compile #{length(errors)} module(s): #{inspect(errors)}"
+ )
+ end
+ after
+ Code.put_compiler_option(:ignore_module_conflict, previous || false)
+ end
+ end
+
+ defp config_for_compile do
+ Application.get_env(:phoenix_seo, :json_ld_types, :google)
+ end
+
+ defp manifest_key(config, mtime) do
+ :erlang.phash2({config, mtime})
+ end
+
+ defp manifest_path, do: Path.join(Mix.Project.manifest_path(), "compile.seo_jsonld")
+ defp sources_dir, do: Path.join(Mix.Project.app_path(), "seo_jsonld_generated")
+ defp beam_path(module), do: Path.join(Mix.Project.compile_path(), "#{module}.beam")
+
+ defp read_manifest do
+ case File.read(manifest_path()) do
+ {:ok, binary} -> :erlang.binary_to_term(binary)
+ {:error, _} -> nil
+ end
+ rescue
+ _ -> nil
+ end
+
+ defp write_manifest(data) do
+ File.mkdir_p!(Path.dirname(manifest_path()))
+ File.write!(manifest_path(), :erlang.term_to_binary(data))
+ end
+end
diff --git a/lib/mix/tasks/compile.seo_jsonld/generator.ex b/lib/mix/tasks/compile.seo_jsonld/generator.ex
new file mode 100644
index 0000000..cad8f46
--- /dev/null
+++ b/lib/mix/tasks/compile.seo_jsonld/generator.ex
@@ -0,0 +1,1173 @@
+defmodule Mix.Tasks.Compile.SeoJsonld.Generator do
+ @moduledoc false
+
+ # Walks Schema.org's vocabulary (priv/schemaorg.jsonld) and emits typed
+ # Elixir builder modules under `SEO.JSONLD.*`. Used by
+ # `Mix.Tasks.Compile.SeoJsonld` at consuming-app compile time — not at
+ # runtime.
+ #
+ # The public surface is:
+ #
+ # * `groups/0` - named config groups → schema IRIs
+ # * `expand_types/1` - normalize a config list to the closure of IRIs
+ # to emit (resolves groups, modules, and pulls in referenced types)
+ # * `emit_sources/1` - render `{module_name, source}` pairs for a given
+ # set of IRIs
+ # * `schema_path/0` - path to the bundled vocabulary file
+
+ # Schema.org's top-level Thing children, used both for the `:medical`,
+ # `:place`, etc. config groups AND for ExDoc grouping. Order matters:
+ # more specific categories (Action, Medical) come before broader ones
+ # (Intangible), so the walk picks the narrowest.
+ @category_labels [
+ {"schema:Patient", :medical},
+ {"schema:MedicalAudience", :medical},
+ {"schema:CDCPMDRecord", :medical},
+ {"schema:AlignmentObject", :education},
+ {"schema:EducationalAudience", :education},
+ {"schema:Researcher", :education},
+ {"schema:CategoryCode", :education},
+ {"schema:StatisticalPopulation", :education},
+ {"schema:StatisticalVariable", :education},
+ {"schema:ContactPoint", :organization},
+ {"schema:EmployeeRole", :organization},
+ {"schema:OrganizationRole", :organization},
+ {"schema:EntryPoint", :action},
+ {"schema:ActionAccessSpecification", :action},
+ {"schema:VideoGame", :gaming},
+ {"schema:VideoGameSeries", :gaming},
+ {"schema:VideoGameClip", :gaming},
+ {"schema:Game", :gaming},
+ {"schema:GameServer", :gaming},
+ {"schema:PlayGameAction", :gaming},
+ {"schema:HealthInsurancePlan", :health},
+ {"schema:HealthPlanFormulary", :health},
+ {"schema:HealthPlanNetwork", :health},
+ {"schema:HealthPlanCostSharingSpecification", :health},
+ {"schema:Diet", :health},
+ {"schema:ExercisePlan", :health},
+ {"schema:HealthAndBeautyBusiness", :health},
+ {"schema:HealthClub", :health},
+ {"schema:NutritionInformation", :health},
+ {"schema:EducationalOrganization", :education},
+ {"schema:Course", :education},
+ {"schema:Quiz", :education},
+ {"schema:EducationalOccupationalProgram", :education},
+ {"schema:LearningResource", :education},
+ {"schema:EducationEvent", :education},
+ {"schema:Attorney", :legal},
+ {"schema:LegalService", :legal},
+ {"schema:Courthouse", :legal},
+ {"schema:Notary", :legal},
+ {"schema:Permit", :legal},
+ {"schema:GovernmentPermit", :legal},
+ {"schema:MemberProgram", :legal},
+ {"schema:MemberProgramTier", :legal},
+ {"schema:ProgramMembership", :legal},
+ {"schema:DigitalDocumentPermission", :legal},
+ {"schema:GovernmentService", :legal},
+ {"schema:RealEstateAgent", :real_estate},
+ {"schema:Residence", :real_estate},
+ {"schema:Accommodation", :real_estate},
+ {"schema:RealEstateListing", :real_estate},
+ {"schema:BedDetails", :real_estate},
+ {"schema:FloorPlan", :real_estate},
+ {"schema:LocationFeatureSpecification", :real_estate},
+ {"schema:Trip", :travel},
+ {"schema:Reservation", :travel},
+ {"schema:LodgingBusiness", :travel},
+ {"schema:TravelAgency", :travel},
+ {"schema:Airport", :travel},
+ {"schema:BusStation", :travel},
+ {"schema:TrainStation", :travel},
+ {"schema:TaxiStand", :travel},
+ {"schema:Taxi", :travel},
+ {"schema:TaxiService", :travel},
+ {"schema:Seat", :travel},
+ {"schema:FinancialService", :financial},
+ {"schema:FinancialProduct", :financial},
+ {"schema:MonetaryAmount", :financial},
+ {"schema:MonetaryAmountDistribution", :financial},
+ {"schema:MonetaryGrant", :financial},
+ {"schema:Grant", :financial},
+ {"schema:DatedMoneySpecification", :financial},
+ {"schema:ExchangeRateSpecification", :financial},
+ {"schema:FinancialIncentive", :financial},
+ {"schema:RepaymentSpecification", :financial},
+ {"schema:Product", :shopping},
+ {"schema:Brand", :shopping},
+ {"schema:MenuItem", :shopping},
+ {"schema:OwnershipInfo", :shopping},
+ {"schema:TypeAndQuantityNode", :shopping},
+ {"schema:ServicePeriod", :shopping},
+ {"schema:EngineSpecification", :shopping},
+ {"schema:EnergyConsumptionDetails", :shopping},
+ {"schema:Offer", :shopping},
+ {"schema:OfferCatalog", :shopping},
+ {"schema:Demand", :shopping},
+ {"schema:Order", :shopping},
+ {"schema:OrderItem", :shopping},
+ {"schema:Invoice", :shopping},
+ {"schema:MerchantReturnPolicy", :shopping},
+ {"schema:MerchantReturnPolicySeasonalOverride", :shopping},
+ {"schema:Store", :shopping},
+ {"schema:PriceSpecification", :shopping},
+ {"schema:PaymentMethod", :shopping},
+ {"schema:ShippingService", :shopping},
+ {"schema:ShippingConditions", :shopping},
+ {"schema:ShippingDeliveryTime", :shopping},
+ {"schema:ShippingRateSettings", :shopping},
+ {"schema:OfferShippingDetails", :shopping},
+ {"schema:ParcelDelivery", :shopping},
+ {"schema:WarrantyPromise", :shopping},
+ {"schema:Ticket", :shopping},
+ {"schema:BroadcastService", :media},
+ {"schema:BroadcastChannel", :media},
+ {"schema:BroadcastFrequencySpecification", :media},
+ {"schema:CableOrSatelliteService", :media},
+ {"schema:MediaReview", :media},
+ {"schema:MediaSubscription", :media},
+ {"schema:MediaObject", :media},
+ {"schema:Schedule", :event},
+ {"schema:InstantaneousEvent", :event},
+ {"schema:Observation", :medical},
+ {"schema:ComputerLanguage", :creative_work},
+ {"schema:HowToItem", :creative_work},
+ {"schema:ListItem", :creative_work},
+ {"schema:ItemList", :creative_work},
+ {"schema:DataFeedItem", :creative_work},
+ {"schema:InteractionCounter", :creative_work},
+ {"schema:Series", :creative_work},
+ {"schema:OpeningHoursSpecification", :place},
+ {"schema:Occupation", :jobs},
+ {"schema:OccupationalExperienceRequirements", :jobs},
+ {"schema:GeoCoordinates", :location},
+ {"schema:GeoShape", :location},
+ {"schema:GeoCircle", :location},
+ {"schema:GeospatialGeometry", :location},
+ {"schema:PostalAddress", :location},
+ {"schema:PostalCodeRangeSpecification", :location},
+ {"schema:VirtualLocation", :location},
+ {"schema:Action", :action},
+ {"schema:CreativeWork", :creative_work},
+ {"schema:Event", :event},
+ {"schema:MedicalEntity", :medical},
+ {"schema:BioChemEntity", :medical},
+ {"schema:Taxon", :medical},
+ {"schema:Place", :place},
+ {"schema:Organization", :organization},
+ {"schema:Person", :person},
+ {"schema:Intangible", :intangible}
+ ]
+
+ # Schema.org types Google has rich-result guides for. Values are field
+ # atoms marked `required()` in each module's `@type t`, sourced from
+ # https://developers.google.com/search/docs/appearance/structured-data.
+ @google_required %{
+ "Article" => [:headline, :image, :date_published, :author],
+ "BreadcrumbList" => [:item_list_element],
+ "Course" => [:name, :description, :provider],
+ "Dataset" => [:name, :description],
+ "DiscussionForumPosting" => [:author, :text],
+ "EmployerAggregateRating" => [:item_reviewed, :rating_value, :rating_count],
+ "Event" => [:name, :start_date, :location],
+ "FAQPage" => [:main_entity],
+ "ImageObject" => [:content_url],
+ "JobPosting" => [:title, :description, :hiring_organization, :date_posted],
+ "LocalBusiness" => [:name, :address],
+ "MathSolver" => [:name, :url, :usage_info],
+ "Movie" => [:name],
+ "Organization" => [:name],
+ "Product" => [:name, :image],
+ "ProfilePage" => [:main_entity],
+ "QAPage" => [:main_entity],
+ "Quiz" => [:name, :has_part],
+ "Recipe" => [:name, :image],
+ "Review" => [:item_reviewed, :review_rating, :author],
+ "SoftwareApplication" => [:name, :operating_system, :application_category],
+ "SpeakableSpecification" => [],
+ "VacationRental" => [:name, :image, :address, :identifier],
+ "VideoObject" => [:name, :thumbnail_url, :upload_date]
+ }
+
+ @google_additional_types %{
+ "MathSolver" => ["LearningResource"]
+ }
+
+ @google_images %{
+ "Article" => ["article-example.png"],
+ "BreadcrumbList" => ["breadcrumb-example.png"],
+ "Course" => ["course-list-example.png"],
+ "Dataset" => ["dataset-search-example.png"],
+ "DiscussionForumPosting" => ["discussion-forum-example.png"],
+ "EmployerAggregateRating" => ["employer-aggregate-rating-example.png"],
+ "Event" => ["event-details-example.png", "event-result-example.png"],
+ "FAQPage" => ["faqpage-example.png"],
+ "ImageObject" => ["image-metadata-example.png", "image-metadata-example-2.png"],
+ "JobPosting" => ["jobs-search-example.png"],
+ "LocalBusiness" => ["local-business-example.png"],
+ "MathSolver" => ["math-solvers-example.png"],
+ "Movie" => ["movie-carousel-example.png"],
+ "Organization" => ["organization-example.png"],
+ "Product" => ["product-snippet-example.png", "product-variants-example.png"],
+ "QAPage" => ["qa-example.png"],
+ "Quiz" => ["education-qa-example.png"],
+ "Recipe" => ["recipe-example.png", "recipe-example-2.png"],
+ "Review" => ["review-snippet-example.png"],
+ "SoftwareApplication" => ["software-apps-example.png"],
+ "VacationRental" => ["vacation-rental-example.png"],
+ "VideoObject" => ["video-example.png"]
+ }
+
+ @range_primitive_types %{
+ "schema:Text" => :text,
+ "schema:URL" => :url,
+ "schema:PronounceableText" => :text,
+ "schema:CssSelectorType" => :text,
+ "schema:XPathType" => :text,
+ "schema:Number" => :number,
+ "schema:Integer" => :integer,
+ "schema:Float" => :float,
+ "schema:Boolean" => :boolean,
+ "schema:Date" => :date,
+ "schema:DateTime" => :datetime,
+ "schema:Time" => :time,
+ "schema:Duration" => :duration,
+ "schema:DateDuration" => :duration
+ }
+
+ @module_name_overrides %{
+ "3DModel" => "ThreeDimensionModel"
+ }
+
+ # Hand-written convenience wrappers shipped under priv/wrappers/.
+ # Each entry has:
+ #
+ # * `:file` — filename inside priv/wrappers/
+ # * `:when_modules` — emit only if every listed module is in the closure
+ # * `:when_descendant_of` — emit only if any module in the closure
+ # descends from this Schema.org class
+ #
+ # Entries use exactly one of `:when_modules` / `:when_descendant_of`.
+ @wrapper_specs %{
+ SEO.JSONLD.Breadcrumbs => %{
+ file: "breadcrumbs.ex",
+ when_modules: [SEO.JSONLD.BreadcrumbList, SEO.JSONLD.ListItem]
+ },
+ SEO.JSONLD.FAQ => %{
+ file: "faq.ex",
+ when_modules: [SEO.JSONLD.FAQPage, SEO.JSONLD.Question, SEO.JSONLD.Answer]
+ },
+ SEO.JSONLD.Actions => %{
+ file: "actions.ex",
+ when_descendant_of: "schema:Action"
+ }
+ }
+
+ # Schema.org classes that `:google` implicitly needs to author its
+ # rich-result payloads, even though Google doesn't list them as
+ # rich-result types themselves.
+ @google_implicit_iris ~w[schema:Question schema:Answer schema:ListItem]
+
+ ## Public API ----------------------------------------------------------
+
+ @doc "Path to the bundled Schema.org vocabulary file."
+ @spec schema_path() :: String.t()
+ def schema_path do
+ Path.join(:code.priv_dir(:phoenix_seo), "schemaorg.jsonld")
+ end
+
+ @doc "Path to the hand-written wrapper source files."
+ @spec wrappers_dir() :: String.t()
+ def wrappers_dir do
+ Path.join(:code.priv_dir(:phoenix_seo), "wrappers")
+ end
+
+ @doc """
+ Named groups available for `config :phoenix_seo, json_ld_types: [...]`.
+
+ Returns a map of group name → list of schema IRIs. `:all` and `:google`
+ are computed on demand; the rest come from `@category_labels`.
+ """
+ @spec groups() :: %{atom() => [String.t()]}
+ def groups do
+ schema = parse_schema()
+ regular = regular_classes(schema)
+
+ by_category =
+ Enum.reduce(regular, %{}, fn {id, _class}, acc ->
+ category = classify_category(id, schema.classes)
+ Map.update(acc, category, [id], &[id | &1])
+ end)
+
+ google_iris =
+ @google_required
+ |> Map.keys()
+ |> Enum.map(&("schema:" <> &1))
+ |> Enum.concat(@google_implicit_iris)
+ |> Enum.uniq()
+
+ Map.merge(by_category, %{
+ all: Map.keys(regular),
+ google: google_iris
+ })
+ end
+
+ @doc """
+ Normalizes a config list (atoms, modules, schema IRIs, bare names) into
+ the transitive closure of schema IRIs that need to be emitted.
+
+ Pulls in:
+ - inheritance ancestors (so doc links resolve)
+ - field-range references (so typespecs resolve)
+ """
+ @spec expand_types(atom() | module() | String.t() | list()) :: [String.t()]
+ def expand_types(config) do
+ config = List.wrap(config)
+ schema = parse_schema()
+ regular = regular_classes(schema)
+
+ # Fast path: `:all` is by definition the full set; skip the closure
+ # walk (which would otherwise iterate every property for every class
+ # only to confirm nothing's missing).
+ if config == [:all] do
+ regular |> Map.keys() |> Enum.sort()
+ else
+ by_module = module_to_iri(regular)
+ groups = groups()
+
+ config
+ |> Enum.flat_map(&resolve_one(&1, groups, by_module))
+ |> Enum.uniq()
+ |> MapSet.new()
+ |> close(schema, regular)
+ |> MapSet.to_list()
+ |> Enum.sort()
+ end
+ end
+
+ @doc """
+ Renders module sources for the given schema IRIs.
+
+ Returns a list of `{module_atom, filename, source_string}` tuples.
+ Sources are emitted unformatted — they're machine-generated and only
+ need to be valid Elixir, not pretty.
+ """
+ @spec emit_sources([String.t()]) :: [{module(), String.t(), String.t()}]
+ def emit_sources(iris) do
+ schema = parse_schema()
+ regular = regular_classes(schema)
+ id_to_module = build_module_name_map(regular)
+
+ classes = Enum.flat_map(iris, fn id -> List.wrap(Map.get(regular, id)) end)
+
+ # Render in parallel — each class is independent and template
+ # expansion dominates the runtime for `:all`.
+ generated =
+ classes
+ |> Task.async_stream(
+ fn class -> render_class_source(class, schema, regular, id_to_module) end,
+ ordered: false,
+ timeout: :infinity
+ )
+ |> Enum.map(fn {:ok, result} -> result end)
+
+ emitted_modules = MapSet.new(generated, fn {mod, _, _} -> mod end)
+ generated ++ emit_wrappers(emitted_modules, iris, schema.classes)
+ end
+
+ defp render_class_source(class, schema, _regular, id_to_module) do
+ fields = resolve_fields(class, schema.classes, schema.properties, id_to_module)
+ name = short_id(class["@id"])
+ required = Map.get(@google_required, name, [])
+ example = load_example(name)
+ images = Map.get(@google_images, name, [])
+ action? = "schema:Action" in inheritance_chain(class["@id"], schema.classes)
+
+ source =
+ render_module(
+ class,
+ fields,
+ id_to_module,
+ schema.enum_class_values,
+ required,
+ example,
+ images,
+ action?
+ )
+
+ module = Module.concat(SEO.JSONLD, module_name(class))
+ filename = Macro.underscore(module_name(class)) <> ".ex"
+ {module, filename, source}
+ end
+
+ # Hand-written wrappers ship only when the predicate they declare in
+ # `@wrapper_specs` is satisfied by the emitted closure.
+ defp emit_wrappers(emitted_modules, emitted_iris, classes) do
+ iri_set = MapSet.new(emitted_iris)
+
+ for {wrapper, spec} <- @wrapper_specs,
+ wrapper_satisfied?(spec, emitted_modules, iri_set, classes) do
+ source = wrappers_dir() |> Path.join(spec.file) |> File.read!()
+ {wrapper, spec.file, source}
+ end
+ end
+
+ defp wrapper_satisfied?(%{when_modules: mods}, emitted_modules, _iris, _classes) do
+ Enum.all?(mods, &MapSet.member?(emitted_modules, &1))
+ end
+
+ defp wrapper_satisfied?(%{when_descendant_of: ancestor}, _emitted_modules, iris, classes) do
+ Enum.any?(iris, fn iri ->
+ iri != ancestor and ancestor in inheritance_chain(iri, classes)
+ end)
+ end
+
+ ## Schema parsing ------------------------------------------------------
+
+ @doc false
+ def parse_schema do
+ raw = schema_path() |> File.read!() |> decode_json()
+ {classes, properties, enum_values_by_type} = walk_graph(raw["@graph"])
+ enum_class_ids = find_enum_class_ids(classes)
+ enum_class_values = build_enum_values_map(enum_class_ids, enum_values_by_type, classes)
+
+ %{
+ classes: classes,
+ properties: properties,
+ enum_values_by_type: enum_values_by_type,
+ enum_class_ids: enum_class_ids,
+ enum_class_values: enum_class_values
+ }
+ end
+
+ # Jason is reached transitively via phoenix_live_view → phoenix, so any
+ # phoenix_seo consumer has it on the build path. We defer the call via
+ # `apply/3` so the dispatch isn't resolved at compile time (would warn
+ # because :jason is dev/test-only in this library's own mix.exs).
+ defp decode_json(contents) do
+ unless Code.ensure_loaded?(Jason) do
+ Mix.raise(
+ "phoenix_seo: Jason is required at compile time to read the bundled " <>
+ "Schema.org vocabulary. Add `{:jason, \"~> 1.0\"}` to your deps."
+ )
+ end
+
+ # credo:disable-for-next-line Credo.Check.Refactor.Apply
+ apply(Jason, :decode!, [contents])
+ end
+
+ defp walk_graph(graph) do
+ Enum.reduce(graph, {%{}, %{}, %{}}, fn entry, {classes, props, enum_values} ->
+ types = list_wrap(entry["@type"])
+
+ cond do
+ "rdfs:Class" in types ->
+ {Map.put(classes, entry["@id"], entry), props, enum_values}
+
+ "rdf:Property" in types ->
+ {classes, Map.put(props, entry["@id"], entry), enum_values}
+
+ Enum.any?(types, &String.starts_with?(&1, "schema:")) ->
+ {classes, props, record_enum_value(types, entry, enum_values)}
+
+ true ->
+ {classes, props, enum_values}
+ end
+ end)
+ end
+
+ defp record_enum_value(types, entry, enum_values) do
+ types
+ |> Enum.filter(&String.starts_with?(&1, "schema:"))
+ |> Enum.reduce(enum_values, fn type, acc ->
+ Map.update(acc, type, [entry], &[entry | &1])
+ end)
+ end
+
+ defp regular_classes(schema) do
+ schema.classes
+ |> Enum.filter(fn {id, class} ->
+ schema_class?(id) and not data_type?(class, schema.classes) and
+ not MapSet.member?(schema.enum_class_ids, id)
+ end)
+ |> Map.new()
+ end
+
+ defp module_to_iri(regular) do
+ Map.new(regular, fn {id, class} -> {module_for(class), id} end)
+ end
+
+ defp module_for(class), do: Module.concat(SEO.JSONLD, module_name(class))
+
+ ## Config resolution ---------------------------------------------------
+
+ # `:all` and `:google` are special; other atoms are treated as category
+ # group names (`:medical`, `:place`, ...). Modules are looked up in the
+ # module → IRI map; bare strings ("Article") and IRIs ("schema:Article")
+ # are normalized in place.
+ defp resolve_one(name, groups, by_module) when is_atom(name) do
+ cond do
+ Map.has_key?(groups, name) ->
+ Map.fetch!(groups, name)
+
+ Map.has_key?(by_module, name) ->
+ [Map.fetch!(by_module, name)]
+
+ true ->
+ raise ArgumentError,
+ "unknown JSON-LD config entry #{inspect(name)}. " <>
+ "Expected a group (:all, :google, :medical, …), a module " <>
+ "like SEO.JSONLD.Article, or a Schema.org IRI string."
+ end
+ end
+
+ defp resolve_one("schema:" <> _ = iri, _groups, _by_module), do: [iri]
+ defp resolve_one(name, _groups, _by_module) when is_binary(name), do: ["schema:" <> name]
+
+ ## Closure -------------------------------------------------------------
+
+ # Iterate to fixpoint: every emitted class pulls in its inheritance
+ # ancestors (for doc links) and the classes its field ranges reference
+ # (for typespec references).
+ defp close(seeds, schema, regular) do
+ do_close(seeds, MapSet.new(), schema, regular)
+ end
+
+ defp do_close(set, visited, schema, regular) do
+ pending =
+ set
+ |> MapSet.difference(visited)
+ |> MapSet.to_list()
+
+ case pending do
+ [] ->
+ # Restrict to regular classes — enums and datatypes never get
+ # emitted as their own modules.
+ Enum.filter(set, &Map.has_key?(regular, &1)) |> MapSet.new()
+
+ ids ->
+ new_visited = MapSet.union(visited, MapSet.new(ids))
+
+ added =
+ ids
+ |> Enum.flat_map(&dependencies(&1, schema, regular))
+ |> MapSet.new()
+
+ do_close(MapSet.union(set, added), new_visited, schema, regular)
+ end
+ end
+
+ defp dependencies(id, schema, regular) do
+ class = Map.get(regular, id) || Map.get(schema.classes, id)
+ if class, do: deps_from_class(class, schema, regular), else: []
+ end
+
+ defp deps_from_class(class, schema, regular) do
+ ancestors = inheritance_chain(class["@id"], schema.classes)
+
+ field_ranges =
+ schema.properties
+ |> Enum.flat_map(fn {_prop_id, prop} ->
+ domain_ids = prop |> Map.get("schema:domainIncludes") |> list_wrap() |> ids()
+
+ if Enum.any?(domain_ids, &(&1 in ancestors)) do
+ prop |> Map.get("schema:rangeIncludes") |> list_wrap() |> ids()
+ else
+ []
+ end
+ end)
+
+ (ancestors ++ field_ranges)
+ |> Enum.uniq()
+ |> Enum.filter(&Map.has_key?(regular, &1))
+ end
+
+ ## Rendering -----------------------------------------------------------
+
+ defp render_module(
+ class,
+ field_groups,
+ id_to_module,
+ enum_class_values,
+ required,
+ example,
+ images,
+ action?
+ ) do
+ name = short_id(class["@id"])
+ module = module_name(class)
+
+ {map_specs, kw_specs, metadata} =
+ classify_fields(field_groups, id_to_module, enum_class_values, required)
+
+ type_value =
+ case Map.get(@google_additional_types, name) do
+ nil -> name
+ extras -> [name | extras]
+ end
+
+ action_io_step =
+ if action?, do: " |> SEO.Utils.build_expand_action_io()\n", else: ""
+
+ action_io_doc =
+ if action? do
+ """
+
+ ## Action inputs and outputs
+
+ This type descends from `SEO.JSONLD.Action`, so `build/1` recognizes
+ two pseudo-fields that expand into Schema.org's hyphenated
+ `-input` / `-output` shorthand (see
+ [Schema.org Actions, Part 4](https://schema.org/docs/actions.html)):
+
+ - `:inputs` — a keyword list of `{property, constraints}` entries
+ - `:outputs` — likewise, for `-output` annotations
+
+ Constraints are passed to `SEO.JSONLD.Actions.input_spec/1`.
+ """
+ else
+ ""
+ end
+
+ moduledoc = render_moduledoc(name, class, id_to_module, example, images) <> action_io_doc
+
+ fields_doc =
+ render_fields_doc(field_groups, id_to_module, enum_class_values, metadata, class["@id"])
+
+ """
+ # This file is generated by the :seo_jsonld Mix compiler. Do not edit directly.
+ defmodule SEO.JSONLD.#{module} do
+ @moduledoc \"\"\"
+ #{indent(moduledoc, 2)}
+ \"\"\"
+
+ @type attrs ::
+ %{
+ #{indent(Enum.join(map_specs, ",\n"), 10)}
+ }
+ | [
+ #{indent(Enum.join(kw_specs, ",\n"), 10)}
+ ]
+
+ @typedoc \"\"\"
+ A JSON-LD map ready to be nested or rendered. String-keyed, always
+ includes `"@type"` set to `#{inspect(type_value)}`, plus any camelCased
+ field keys the caller provided (see `build/1`). `"@context"` is added
+ at render time by `SEO.JSONLD.meta/1` on the top-level node only.
+ \"\"\"
+ @type t :: %{String.t() => term()}
+
+ @enum_fields #{inspect_enum_fields(metadata.enum_fields)}
+ @key_map #{inspect_key_map(metadata.key_map)}
+
+ @doc \"\"\"
+ Build a #{name} JSON-LD map.
+
+ ## Fields
+
+ #{indent(fields_doc, 2)}
+ \"\"\"
+ @spec build(attrs()) :: t()
+ def build(attrs) do
+ attrs
+ |> Enum.into(%{})
+ #{action_io_step} |> SEO.Utils.build_convert_enums(@enum_fields)
+ |> SEO.Utils.build_coerce_structs()
+ |> SEO.Utils.build_camelize_keys(@key_map)
+ |> Map.put_new("@type", #{inspect(type_value)})
+ end
+ end
+ """
+ end
+
+ ## Class graph helpers -------------------------------------------------
+
+ defp classify_category(class_id, classes) do
+ chain = inheritance_chain(class_id, classes)
+
+ Enum.find_value(@category_labels, :other, fn {ancestor_id, category} ->
+ if ancestor_id in chain, do: category
+ end)
+ end
+
+ defp load_example(class_name) do
+ path =
+ Path.join([
+ :code.priv_dir(:phoenix_seo),
+ "examples",
+ "jsonld",
+ Macro.underscore(class_name) <> ".exs"
+ ])
+
+ if File.exists?(path) do
+ path |> File.read!() |> String.trim_trailing()
+ end
+ end
+
+ defp find_enum_class_ids(classes) do
+ Enum.reduce(classes, MapSet.new(), fn {id, class}, acc ->
+ if in_enumeration_chain?(id, class, classes) do
+ MapSet.put(acc, id)
+ else
+ acc
+ end
+ end)
+ end
+
+ defp in_enumeration_chain?("schema:Enumeration", _class, _classes), do: true
+
+ defp in_enumeration_chain?(_id, class, classes) do
+ parent_ids = class |> Map.get("rdfs:subClassOf") |> list_wrap() |> ids()
+ Enum.any?(parent_ids, &descends_from_enumeration?(&1, classes, MapSet.new()))
+ end
+
+ defp descends_from_enumeration?("schema:Enumeration", _classes, _seen), do: true
+
+ defp descends_from_enumeration?(id, classes, seen) do
+ cond do
+ MapSet.member?(seen, id) ->
+ false
+
+ parent = Map.get(classes, id) ->
+ seen = MapSet.put(seen, id)
+ parent_ids = parent |> Map.get("rdfs:subClassOf") |> list_wrap() |> ids()
+ Enum.any?(parent_ids, &descends_from_enumeration?(&1, classes, seen))
+
+ true ->
+ false
+ end
+ end
+
+ defp build_enum_values_map(enum_class_ids, enum_values_by_type, classes) do
+ Map.new(enum_class_ids, fn id ->
+ instances = collect_enum_instances(id, enum_values_by_type, classes, MapSet.new())
+
+ value_map =
+ instances
+ |> Enum.uniq_by(& &1["@id"])
+ |> Enum.sort_by(&text(&1["rdfs:label"]))
+ |> Map.new(fn entry ->
+ label = text(entry["rdfs:label"]) || short_id(entry["@id"])
+ atom = label_to_atom(label)
+ url = schema_url(entry["@id"])
+ {atom, {url, text(entry["rdfs:comment"])}}
+ end)
+
+ {id, value_map}
+ end)
+ end
+
+ defp collect_enum_instances(id, enum_values_by_type, classes, seen) do
+ if MapSet.member?(seen, id) do
+ []
+ else
+ seen = MapSet.put(seen, id)
+ direct = Map.get(enum_values_by_type, id, [])
+
+ child_instances =
+ for {child_id, child_class} <- classes,
+ parent_ids = child_class |> Map.get("rdfs:subClassOf") |> list_wrap() |> ids(),
+ id in parent_ids,
+ instance <- collect_enum_instances(child_id, enum_values_by_type, classes, seen),
+ do: instance
+
+ direct ++ child_instances
+ end
+ end
+
+ defp data_type?(class, classes) do
+ descends_from_data_type?(class["@id"], class, classes, MapSet.new())
+ end
+
+ defp descends_from_data_type?("schema:DataType", _class, _classes, _seen), do: true
+ defp descends_from_data_type?(_id, nil, _classes, _seen), do: false
+
+ defp descends_from_data_type?(id, class, classes, seen) do
+ cond do
+ MapSet.member?(seen, id) ->
+ false
+
+ "schema:DataType" in (class |> Map.get("@type") |> list_wrap()) ->
+ true
+
+ true ->
+ seen = MapSet.put(seen, id)
+ parent_ids = class |> Map.get("rdfs:subClassOf") |> list_wrap() |> ids()
+
+ Enum.any?(parent_ids, fn pid ->
+ descends_from_data_type?(pid, Map.get(classes, pid), classes, seen)
+ end)
+ end
+ end
+
+ defp schema_class?("schema:" <> _), do: true
+ defp schema_class?(_), do: false
+
+ defp build_module_name_map(classes) do
+ Map.new(classes, fn {id, class} -> {id, module_name(class)} end)
+ end
+
+ defp module_name(class) do
+ raw = short_id(class["@id"])
+ Map.get(@module_name_overrides, raw, raw)
+ end
+
+ defp short_id("schema:" <> name), do: name
+ defp short_id(id), do: id
+
+ defp schema_url("schema:" <> name), do: "https://schema.org/" <> name
+
+ defp resolve_fields(class, classes, properties, id_to_module) do
+ chain = inheritance_chain(class["@id"], classes)
+
+ grouped =
+ Enum.reduce(properties, %{}, fn {prop_id, prop}, acc ->
+ domain_ids = prop |> Map.get("schema:domainIncludes") |> list_wrap() |> ids()
+
+ case Enum.find(chain, &(&1 in domain_ids)) do
+ nil ->
+ acc
+
+ owner ->
+ field = %{
+ id: prop_id,
+ original_name: short_id(prop_id),
+ atom_name: property_name_to_atom(prop),
+ comment: clean_comment(prop["rdfs:comment"], id_to_module),
+ ranges: prop |> Map.get("schema:rangeIncludes") |> list_wrap() |> ids()
+ }
+
+ Map.update(acc, owner, [field], &[field | &1])
+ end
+ end)
+
+ chain
+ |> Enum.map(fn id ->
+ fields =
+ grouped
+ |> Map.get(id, [])
+ |> Enum.uniq_by(& &1.atom_name)
+ |> Enum.sort_by(& &1.atom_name)
+
+ {id, fields}
+ end)
+ |> Enum.reject(fn {_id, fields} -> fields == [] end)
+ end
+
+ defp inheritance_chain(class_id, classes) do
+ do_inheritance_chain([class_id], MapSet.new(), [], classes)
+ end
+
+ defp do_inheritance_chain([], _seen, result, _classes), do: Enum.reverse(result)
+
+ defp do_inheritance_chain([id | rest], seen, result, classes) do
+ if MapSet.member?(seen, id) do
+ do_inheritance_chain(rest, seen, result, classes)
+ else
+ seen = MapSet.put(seen, id)
+
+ parents =
+ case Map.get(classes, id) do
+ nil -> []
+ class -> class |> Map.get("rdfs:subClassOf") |> list_wrap() |> ids()
+ end
+
+ do_inheritance_chain(rest ++ parents, seen, [id | result], classes)
+ end
+ end
+
+ defp property_name_to_atom(prop) do
+ (text(prop["rdfs:label"]) || short_id(prop["@id"]))
+ |> Macro.underscore()
+ |> String.to_atom()
+ end
+
+ defp text(nil), do: nil
+ defp text(%{"@value" => v}), do: v
+ defp text(v) when is_binary(v), do: v
+ defp text(list) when is_list(list), do: list |> Enum.map(&text/1) |> Enum.find(&is_binary/1)
+
+ ## Field classification ------------------------------------------------
+
+ defp classify_fields(field_groups, id_to_module, enum_class_values, required) do
+ init = %{enum_fields: %{}, key_map: %{}}
+ required_set = MapSet.new(required)
+ flat_fields = Enum.flat_map(field_groups, fn {_owner, fields} -> fields end)
+
+ {map_specs, kw_specs, metadata} =
+ Enum.reduce(flat_fields, {[], [], init}, fn field, {map_acc, kw_acc, meta} ->
+ {map_spec, kw_spec, meta} =
+ classify_field(field, id_to_module, enum_class_values, meta, required_set)
+
+ {[map_spec | map_acc], [kw_spec | kw_acc], meta}
+ end)
+
+ {Enum.reverse(map_specs), Enum.reverse(kw_specs), metadata}
+ end
+
+ defp classify_field(field, id_to_module, enum_class_values, meta, required_set) do
+ input_type_string = input_type(field.ranges, id_to_module, enum_class_values)
+
+ qualifier =
+ if MapSet.member?(required_set, field.atom_name), do: "required", else: "optional"
+
+ map_spec = "#{qualifier}(#{inspect(field.atom_name)}) => #{input_type_string}"
+ kw_spec = "#{field.atom_name}: #{input_type_string}"
+
+ meta =
+ meta
+ |> maybe_add_key_map(field)
+ |> classify_ranges(field, enum_class_values)
+
+ {map_spec, kw_spec, meta}
+ end
+
+ defp maybe_add_key_map(meta, field) do
+ if to_string(field.atom_name) == field.original_name do
+ meta
+ else
+ %{meta | key_map: Map.put(meta.key_map, field.atom_name, field.original_name)}
+ end
+ end
+
+ defp classify_ranges(meta, field, enum_class_values) do
+ Enum.reduce(field.ranges, meta, fn range, acc ->
+ merge_enum_values(acc, field, Map.get(enum_class_values, range))
+ end)
+ end
+
+ defp merge_enum_values(meta, _field, nil), do: meta
+
+ defp merge_enum_values(meta, field, range_values) do
+ values = Map.new(range_values, fn {atom, {url, _comment}} -> {atom, url} end)
+ current = Map.get(meta.enum_fields, field.atom_name, %{})
+
+ %{meta | enum_fields: Map.put(meta.enum_fields, field.atom_name, Map.merge(current, values))}
+ end
+
+ defp input_type(ranges, id_to_module, enum_class_values) do
+ ranges
+ |> Enum.flat_map(&range_to_input_type(&1, id_to_module, enum_class_values))
+ |> Enum.uniq()
+ |> Enum.join(" | ")
+ |> case do
+ "" -> "term()"
+ s -> s
+ end
+ end
+
+ defp range_to_input_type(range, id_to_module, enum_class_values) do
+ cond do
+ Map.has_key?(enum_class_values, range) ->
+ enum_class_values[range]
+ |> Map.keys()
+ |> Enum.sort()
+ |> Enum.map(&inspect/1)
+
+ prim = @range_primitive_types[range] ->
+ input_primitive_strings(prim)
+
+ module = id_to_module[range] ->
+ ["SEO.JSONLD.#{module}.t()", "map()"]
+
+ true ->
+ ["map()"]
+ end
+ end
+
+ defp input_primitive_strings(:text), do: ["String.t()"]
+ defp input_primitive_strings(:url), do: ["URI.t()", "String.t()"]
+ defp input_primitive_strings(:number), do: ["number()"]
+ defp input_primitive_strings(:integer), do: ["integer()"]
+ defp input_primitive_strings(:float), do: ["float()"]
+ defp input_primitive_strings(:boolean), do: ["boolean()"]
+ defp input_primitive_strings(:date), do: ["Date.t()", "String.t()"]
+ defp input_primitive_strings(:datetime), do: ["DateTime.t()", "NaiveDateTime.t()", "String.t()"]
+ defp input_primitive_strings(:time), do: ["Time.t()", "String.t()"]
+ defp input_primitive_strings(:duration), do: ["Duration.t()", "String.t()"]
+
+ ## Doc rendering -------------------------------------------------------
+
+ defp render_moduledoc(name, class, id_to_module, example, images) do
+ comment = clean_comment(class["rdfs:comment"], id_to_module)
+ url = "https://schema.org/#{name}"
+
+ images_block =
+ case images do
+ [] ->
+ nil
+
+ files ->
+ Enum.map_join(files, "\n", fn file ->
+ ""
+ end)
+ end
+
+ example_block =
+ if is_binary(example), do: "## Example\n\n```elixir\n#{example}\n```"
+
+ sections =
+ [
+ comment,
+ images_block,
+ example_block,
+ "Helper for building a Schema.org [#{name}](#{url}) JSON-LD structure."
+ ]
+ |> Enum.reject(&(&1 in [nil, ""]))
+
+ Enum.join(sections, "\n\n")
+ end
+
+ defp render_fields_doc(field_groups, id_to_module, enum_class_values, metadata, own_id) do
+ {own_group, inherited_groups} =
+ case Enum.split_with(field_groups, fn {id, _} -> id == own_id end) do
+ {[own], rest} -> {own, rest}
+ {[], rest} -> {{own_id, []}, rest}
+ end
+
+ own_section = render_own_fields(own_group, id_to_module, enum_class_values, metadata)
+ inherited_section = render_inherited_fields(inherited_groups, id_to_module)
+
+ [own_section, inherited_section]
+ |> Enum.reject(&(&1 in [nil, ""]))
+ |> Enum.join("\n\n")
+ end
+
+ defp render_own_fields({_owner_id, []}, _id_to_module, _enum_class_values, _metadata) do
+ "This type has no own properties. See the inherited properties below."
+ end
+
+ defp render_own_fields({_owner_id, fields}, _id_to_module, enum_class_values, metadata) do
+ Enum.map_join(fields, "\n", fn field ->
+ comment = field.comment || ""
+ enum_note = enum_value_list(field, enum_class_values, metadata)
+ "- `#{inspect(field.atom_name)}` - #{comment}#{enum_note}"
+ end)
+ end
+
+ defp render_inherited_fields([], _id_to_module), do: nil
+
+ defp render_inherited_fields(groups, id_to_module) do
+ links =
+ Enum.map_join(groups, "\n", fn {owner_id, _fields} ->
+ "- #{owner_link(owner_id, short_id(owner_id), id_to_module)}"
+ end)
+
+ """
+ ### Inherited properties
+
+ Additional properties are available through the inheritance chain. See
+ each ancestor's docs for its properties:
+
+ #{links}\
+ """
+ end
+
+ defp owner_link(owner_id, owner_name, id_to_module) do
+ case Map.get(id_to_module, owner_id) do
+ nil -> owner_name
+ _module -> "`SEO.JSONLD.#{Map.get(id_to_module, owner_id)}`"
+ end
+ end
+
+ defp enum_value_list(field, _enum_class_values, metadata) do
+ case Map.get(metadata.enum_fields, field.atom_name) do
+ nil ->
+ ""
+
+ values ->
+ atoms = values |> Map.keys() |> Enum.sort() |> Enum.map_join(", ", &"`#{inspect(&1)}`")
+ " One of: #{atoms}."
+ end
+ end
+
+ defp clean_comment(nil, _id_to_module), do: ""
+
+ defp clean_comment(comment, id_to_module) do
+ case text(comment) do
+ nil ->
+ ""
+
+ string ->
+ string
+ |> String.replace(~r/<[^>]+>/, "")
+ |> String.replace("\\n", "\n")
+ |> absolutize_schema_links()
+ |> linkify_refs(id_to_module)
+ |> String.replace(~r/[ \t]+/, " ")
+ |> String.replace(~r/ *\n */, "\n")
+ |> String.replace(~r/\n{3,}/, "\n\n")
+ |> String.trim()
+ end
+ end
+
+ defp absolutize_schema_links(text) do
+ Regex.replace(~r{\]\((/[^)]*)\)}, text, fn _, path ->
+ "](https://schema.org#{path})"
+ end)
+ end
+
+ defp linkify_refs(text, id_to_module) do
+ Regex.replace(~r/\[\[([A-Za-z0-9_]+)\]\]/, text, fn _, name ->
+ schema_id = "schema:" <> name
+
+ case Map.get(id_to_module, schema_id) do
+ nil -> "`#{name}`"
+ module -> "`SEO.JSONLD.#{module}`"
+ end
+ end)
+ end
+
+ defp inspect_enum_fields(enum_fields) when map_size(enum_fields) == 0, do: "%{}"
+
+ defp inspect_enum_fields(enum_fields) do
+ entries =
+ enum_fields
+ |> Enum.sort_by(fn {k, _} -> k end)
+ |> Enum.map_join(",\n", fn {field, values} ->
+ value_entries =
+ values
+ |> Enum.sort_by(fn {k, _} -> k end)
+ |> Enum.map_join(",\n", fn {atom, url} ->
+ " #{inspect(atom)} => #{inspect(url)}"
+ end)
+
+ "#{inspect(field)} => %{\n#{value_entries}\n}"
+ end)
+
+ "%{\n#{entries}\n}"
+ end
+
+ defp inspect_key_map(key_map) when map_size(key_map) == 0, do: "%{}"
+
+ defp inspect_key_map(key_map) do
+ entries =
+ key_map
+ |> Enum.sort_by(fn {k, _} -> k end)
+ |> Enum.map_join(",\n", fn {k, v} -> " #{inspect(k)} => #{inspect(v)}" end)
+
+ "%{\n#{entries}\n}"
+ end
+
+ defp list_wrap(nil), do: []
+ defp list_wrap(list) when is_list(list), do: list
+ defp list_wrap(x), do: [x]
+
+ defp ids(entries), do: Enum.map(entries, & &1["@id"])
+
+ defp label_to_atom(label) when is_binary(label) do
+ label |> Macro.underscore() |> String.to_atom()
+ end
+
+ defp indent(text, spaces) do
+ pad = String.duplicate(" ", spaces)
+
+ text
+ |> String.split("\n")
+ |> Enum.map_join("\n", fn
+ "" -> ""
+ line -> pad <> line
+ end)
+ end
+end
diff --git a/lib/seo.ex b/lib/seo.ex
index 963b6c0..f319926 100644
--- a/lib/seo.ex
+++ b/lib/seo.ex
@@ -13,8 +13,6 @@ defmodule SEO do
- `:unfurl` -> `SEO.Unfurl`
- `:facebook` -> `SEO.Facebook`
- `:twitter` -> `SEO.Twitter`
- - `:breadcrumb` -> `SEO.Breadcrumb`
- - `:json_ld` -> `SEO.JsonLD`
For example:
@@ -127,8 +125,7 @@ defmodule SEO do
-
-
+
"""
end
diff --git a/lib/seo/breadcrumb.ex b/lib/seo/breadcrumb.ex
deleted file mode 100644
index 6dab45a..0000000
--- a/lib/seo/breadcrumb.ex
+++ /dev/null
@@ -1,53 +0,0 @@
-defmodule SEO.Breadcrumb do
- @moduledoc """
- This is SEO for Google to display breadcrumbs in the search results.
- This allows the search result to search as multiple links.
-
- 
-
- ### Resources
-
- - https://developers.google.com/search/docs/data-types/breadcrumbs
- - https://json-ld.org/
- - https://search.google.com/test/rich-results
- - https://search.google.com/structured-data/testing-tool
-
- Rendered example inside a `
- """
- 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..9a2268d 100644
--- a/lib/seo/config.ex
+++ b/lib/seo/config.ex
@@ -6,9 +6,7 @@ defmodule SEO.Config do
facebook: %{},
twitter: %{},
unfurl: %{},
- open_graph: %{},
- breadcrumb: %{},
- json_ld: %{}
+ open_graph: %{}
]
@callback config() :: map()
diff --git a/lib/seo/json_ld.ex b/lib/seo/json_ld.ex
index bc006eb..5efa304 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 `