Skip to content

Breaking change (breadcrumbs): Add JSONLD support#22

Open
dbernheisel wants to merge 7 commits into
mainfrom
dbern/jsonld
Open

Breaking change (breadcrumbs): Add JSONLD support#22
dbernheisel wants to merge 7 commits into
mainfrom
dbern/jsonld

Conversation

@dbernheisel

@dbernheisel dbernheisel commented Apr 17, 2026

Copy link
Copy Markdown
Owner

Following on @Flo0807 great work, I decided to implement the full json-ld schema.org schema file and generate code from it to cover any remaining gaps for users. This is likely overkill for 90% of users, but niche apps will benefit.

I validated some generated output in both schema.org's validator and Google Rich Results validator

The already-implemented Breadcrumb json-ld module will go away since it was the only one implemented before, and moved into the generated module. There are some helper modules that can make it easier to use, so it's still there, but re-namespaced into SEO.JSONLD

Generate typed JSON-LD modules from Schema.org

Adds a code generator (priv/gen_json_ld.exs) that reads the full Schema.org vocabulary (priv/schemaorg.jsonld) and produces ~820 typed Elixir builder modules under SEO.JSONLD.*. Each module has a build/1 function that accepts snake_case Elixir maps/keyword lists and emits a JSON-LD-ready map with camelCase string keys, @context, and @type.

  • Typed attrs and output — every module defines @type attrs (input shape with required()/optional() per field) and @type t (output shape). Dialyzer catches missing required fields and wrong value types at compile time.
  • Enum atom conversion — enumeration fields accept atoms (:event_cancelled, :in_stock) that get converted to their https://schema.org/... URLs. Unknown atoms raise KeyError; non-atoms raise ArgumentError.
  • Struct coercionDate, DateTime, Time, Duration, and URI structs anywhere in the attrs map are automatically converted to their string forms (ISO 8601 / URI.to_string).
  • Inheritance-grouped docs — each module's @doc lists only its own fields, with links to ancestor modules for inherited properties. Matches how schema.org organizes its own type pages.
  • Google rich-result overlay — the ~24 types Google has structured data guides for get required() fields, curated examples with nested builder calls, and rich-result screenshots in their moduledocs.
  • Action input/output support — Action descendants recognize :inputs and :outputs pseudo-fields that expand into Schema.org's hyphenated <property>-input shorthand, with a composable SEO.JSONLD.Actions helper.

Hand-crafted convenience wrappers

Three modules are preserved across regeneration:

  • SEO.JSONLD.Breadcrumbs — compact [%{name: ..., item: ...}] list to BreadcrumbList + ListItem with auto-positioned entries.
  • SEO.JSONLD.FAQ[%{question: ..., answer: ...}] list to FAQPage + Question + Answer.
  • SEO.JSONLD.Actionsinput_spec/1 and inputs/1 for building Schema.org Action input constraint strings.

Config defaults

SEO.JSONLD.meta now merges site-wide config defaults (from use SEO, json_ld: %{...}) into each rendered payload. Item-supplied keys win.

@dbernheisel

Copy link
Copy Markdown
Owner Author

@Flo0807 would you mind trying out this branch and see if it works for you and your needs?

@Flo0807

Flo0807 commented Apr 18, 2026

Copy link
Copy Markdown
Contributor

@Flo0807 would you mind trying out this branch and see if it works for you and your needs?

sure

@Flo0807

Flo0807 commented May 13, 2026

Copy link
Copy Markdown
Contributor

@Flo0807 would you mind trying out this branch and see if it works for you and your needs?

sure

I switched to this branch in my app. In general it works for my needs, but Claude still located some issues we might want to fix before merging this. @dbernheisel

Likely bug

Config defaults shallow-merge into every item when Build.build/2 returns a list.

defp merge_config_defaults(items, config) when is_list(items) do
  Enum.map(items, &merge_config_defaults(&1, config))
end

If I set use SEO, json_ld: %{"publisher" => Organization} and my item emits [Organization, Article, BreadcrumbList], publisher gets glued onto the standalone Organization and onto the BreadcrumbList — neither of which has a publisher property (BreadcrumbList's @type attrs doesn't define it). Config defaults seem to make sense for a single primary item only; for lists they should probably apply to none, or only the first, or be opt-in per-type.

Smaller issues

Redundant @context on every nested typed call. Because each build/1 ends in Map.put_new("@context", "https://schema.org"), nesting Organization.build/1 inside Article.build/1's publisher produces a @context on every node. It's valid JSON-LD (per-node contexts are allowed), just noisier than convention. Either strip on nesting, or expose a build_nested/1 that skips it.

merge_config_defaults only stringifies top-level keys — the moduledoc warns about this, but the footgun remains: %{publisher: %{alternate_name: "Foo"}} produces a mixed-shape payload. Consider deep-stringifying or raising on nested atom keys in :dev.

Docs

SEO.Breadcrumb.* removal isn't reflected in the README. README.md L154-165 still shows defimpl SEO.Breadcrumb.Build, for: MyApp.Article do … SEO.Breadcrumb.List.build([…]) — that example won't compile against this branch. Migration note + updated example would save existing users the reverse-engineering.

Doc inconsistency on input key casing. README and the SEO.JSONLD.Build protocol moduledoc use camelCase atoms (datePublished:, mainEntityOfPage:), while every generated module's ## Example uses snake_case (date_published:, main_entity_of_page:). Both technically work — build_camelize_keys falls through to Atom.to_string/1 for unknown keys — but the inconsistency made me second-guess which one was canonical. Picking snake_case everywhere would match the rest of the typed API.

Smaller notes

  • 820 generated modules adds ~6s to a cold mix deps.compile phoenix_seo --force on my setup. Worth a README mention, and maybe a compile-time option to generate only the ~24 Google-rich-result types for users who don't need the long tail.
  • Plural/singular naming on convenience wrappers (Breadcrumbs, FAQ, Actions) is inconsistent with the singular generated types (Article, Organization). Not a blocker — just slightly jarring.

@dbernheisel

Copy link
Copy Markdown
Owner Author

Good feedback. Let me see what we can optimize here re: module count, I was concerned about that too.

@dbernheisel

dbernheisel commented May 13, 2026

Copy link
Copy Markdown
Owner Author

@Flo0807 I've pushed a change. It should be largely the same, but to optimize compilation, I've opted for a mix compiler with a config, so you'll want to do something like this:

# in mix.exs
compilers: [:seo_jsonld] ++ Mix.compilers()

# in config.exs
config :phoenix_seo, json_ld_types: :all

#  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"`).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants