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 -> + "![#{name} rich-result example](./assets/#{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. - - ![Breadcrumb Example](./assets/breadcrumb-example.png) - - ### 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 `