Skip to main content

lib/schema_org.ex

defmodule SchemaOrg do
  @moduledoc """
  Strictly-typed builder for Schema.org JSON-LD.

  Each Schema.org Class is a generated struct module under `SchemaOrg.*`
  (e.g. `SchemaOrg.Product`, `SchemaOrg.Offer`). Build a graph with ordinary
  struct literals — your editor auto-completes the valid fields and the compiler
  rejects the rest — then serialise it with `to_json_ld/1`.

      %SchemaOrg.Product{
        name: "MacBook Pro",
        offers: %SchemaOrg.Offer{price: 1999.0}
      }
      |> SchemaOrg.to_map()
      #=> %{
      #     "@type" => "Product",
      #     "name" => "MacBook Pro",
      #     "offers" => %{"@type" => "Offer", "price" => 1999.0}
      #   }

  A property field is untyped, so it accepts Schema.org's loose value model
  directly: a scalar or a nested struct (`brand: "Apple"` or
  `brand: %SchemaOrg.Brand{}`), and a single value or a list
  (`offers: %SchemaOrg.Offer{}` or `offers: [%SchemaOrg.Offer{}, ...]`).

  ## Multiple top-level nodes (`@graph`)

  Pass a **list** of structs to describe several independent nodes in one
  document (e.g. a landing page's `Organization`, `WebSite`, and
  `BreadcrumbList`). They are emitted under a single top-level `@graph`:

      [%SchemaOrg.Organization{name: "Acme"}, %SchemaOrg.WebSite{name: "Acme"}]
      |> SchemaOrg.to_json_ld()
      #=> {"@context":"https://schema.org","@graph":[{...},{...}]}

  ## Embedding in a page

  `to_script_tag/1` returns an HTML-safe `<script type="application/ld+json">`
  tag. In Phoenix, `SchemaOrg.HTML.json_ld/1` is a function component wrapping it
  (compiled only when `:phoenix_live_view` is available).
  """

  @context "https://schema.org"

  @doc """
  Serialises a generated SchemaOrg struct (or a list of them) into a JSON-LD string.

  Adds the top-level `@context`, recurses into nested structs, drops unset
  (`nil`) properties, and re-keys each field to its Schema.org camelCase name.
  A list of structs is wrapped in a top-level `@graph` array.
  """
  @spec to_json_ld(struct() | [struct()]) :: String.t()
  def to_json_ld(thing) do
    thing |> document_map() |> Jason.encode!()
  end

  @doc ~S'''
  Renders a struct (or list of structs) as a complete, HTML-safe
  `<script type="application/ld+json">` tag, ready to drop into a page `<head>`.

  The JSON is encoded with `escape: :html_safe`, so a value containing
  `</script>` (or `<!--`) cannot break out of the tag — the `<` is emitted as a
  `<` escape, which a JSON-LD parser reads back as `<`.

  Returns a plain string. In a Phoenix/HEEx template wrap it with
  `Phoenix.HTML.raw/1`, or use the `SchemaOrg.HTML.json_ld/1` component:

      <%= Phoenix.HTML.raw(SchemaOrg.to_script_tag(@product)) %>
  '''
  @spec to_script_tag(struct() | [struct()]) :: String.t()
  def to_script_tag(thing) do
    json = thing |> document_map() |> Jason.encode!(escape: :html_safe)
    ~s(<script type="application/ld+json">) <> json <> ~s(</script>)
  end

  # The full JSON-LD document map: the node(s) plus the top-level @context.
  defp document_map(thing) do
    thing
    |> to_map()
    |> Map.put("@context", @context)
  end

  @doc """
  Like `to_json_ld/1` but returns the bare JSON-LD map (no `@context`, not encoded).

  Useful for embedding inside a larger document or for assertions in tests. A
  list of structs returns `%{"@graph" => [...]}`.
  """
  @spec to_map(struct() | [struct()]) :: map()
  def to_map(nodes) when is_list(nodes) do
    %{"@graph" => Enum.map(nodes, &to_map/1)}
  end

  def to_map(%mod{} = thing) do
    meta = mod.__schema_org__()

    thing
    |> Map.from_struct()
    |> Enum.reduce(%{"@type" => meta.type}, fn
      {_field, nil}, acc ->
        acc

      {field, value}, acc ->
        Map.put(acc, Map.fetch!(meta.json_keys, field), encode_value(value))
    end)
  end

  # Recurse into nested SchemaOrg structs and lists; pass scalars through.
  defp encode_value(list) when is_list(list), do: Enum.map(list, &encode_value/1)

  defp encode_value(%mod{} = value) do
    if schema_struct?(mod), do: to_map(value), else: value
  end

  defp encode_value(value), do: value

  @doc """
  Returns `true` if `module` is a generated SchemaOrg type module.
  """
  @spec schema_struct?(module()) :: boolean()
  def schema_struct?(module) do
    Code.ensure_loaded?(module) and function_exported?(module, :__schema_org__, 0)
  end
end