DocShell.Json (DocShell v0.3.0)

Copy Markdown View Source

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"}

Summary

Functions

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

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

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

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

Functions

encode_struct(value, opts)

@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(value)

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

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

stringify(value)

@spec stringify(term()) :: term()

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

valid?(value)

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

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