# `DocShell.Json`
[🔗](https://github.com/futhr/doc_shell/blob/v2.0.0/lib/doc_shell/json.ex#L1)

Coerces arbitrary Elixir terms into something JSON can represent.

Documentation metadata is not JSON-shaped. The BEAM docs chunk hands back
atoms, charlists, tuples, and structs; Earmark node metadata carries atom
keys; frontmatter can hold anything YAML parses. All of it has to survive the
trip to a renderer, so `stringify/1` walks a term and coerces what JSON
cannot express:

  * atoms become strings — except `nil`, `true`, and `false`, which JSON has
    natively and which would be useless as `"nil"`
  * tuples become lists, since JSON has no tuple
  * map keys become strings, with anything exotic passed through `inspect/1`
    rather than dropped
  * lists and maps are walked recursively
  * structs become their `String.Chars` text where they have one, and their
    `inspect/1` form otherwise

Structs are not walked field by field, which would preserve more but is not
safe in general: a `Regex` carries a compiled `re_pattern` holding a
non-UTF-8 binary, and emitting that produces a map no JSON encoder can
encode. Losing the structure of an exotic value beats failing the build on
it.

Numbers and valid UTF-8 strings are preserved. Other terms and invalid binaries
become inspected text; improper list tails become a final array value. Preserving
native types matters: a version number that arrives as `1` should not reach a
renderer as `"1"`.

`normalize/1` rejects collisions between converted map keys. The legacy
`stringify/1` function remains lossy: string keys take precedence over other
keys that normalize to the same text. Use `normalize/1` at input boundaries.

## One implementation

Both the AST and ExDoc extractors go through this module. Two coercion passes
drifting apart would show up as one artifact spelling `:since` metadata
differently from another, and a renderer discovering it in production.

## Examples

    iex> DocShell.Json.stringify(%{since: "1.2.0", deprecated: nil})
    %{"since" => "1.2.0", "deprecated" => nil}

    iex> DocShell.Json.stringify({:ok, [:a, 1]})
    ["ok", ["a", 1]]

    iex> DocShell.Json.stringify(%{released: ~D[2026-08-05]})
    %{"released" => "2026-08-05"}

# `value`

```elixir
@type value() ::
  nil
  | boolean()
  | number()
  | String.t()
  | [value()]
  | %{optional(String.t()) =&gt; value()}
```

A native JSON value with UTF-8 string object keys.

# `decode`

```elixir
@spec decode(binary()) :: {:ok, value()} | {:error, term()}
```

Decodes native JSON values and rejects duplicate keys at every object depth.

# `encode_struct`

```elixir
@spec encode_struct(struct(), Jason.Encode.opts()) :: iodata()
```

Encodes a presentation struct as a plain string-keyed JSON object.

Presentation structs use this helper in their `Jason.Encoder` implementations
to emit all fields with string keys. This is distinct from `stringify/1`,
which treats arbitrary structs as textual metadata values.

# `normalize`

```elixir
@spec normalize(term()) :: {:ok, term()} | {:error, {:duplicate_json_key, String.t()}}
```

Normalizes metadata, returning an error when converted map keys collide.

# `stringify`

```elixir
@spec stringify(term()) :: term()
```

Recursively coerces a term into a JSON-encodable value with string keys.

# `valid?`

```elixir
@spec valid?(term()) :: boolean()
```

Checks that a value contains only native JSON values and UTF-8 string keys.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
