Current section
Files
Jump to
Current section
Files
lib/jsonl_ld.ex
defmodule JsonlLd do
@moduledoc """
A stateful, streaming parser and encoder for JSONL-LD (Newline-Delimited JSON-LD).
Adheres to the "Agent A11y" specification for bandwidth-efficient linked data.
"""
@doc """
Takes an Enumerable (like `File.stream!` or an HTTP response body stream) of JSONL-LD strings
and yields a lazy stream of fully resolved JSON-LD maps.
## Options
* `:initial_context` - Sets the ActiveContext from an HTTP Link header before parsing starts.
"""
@spec parse_stream(Enumerable.t(), keyword()) :: Enumerable.t()
def parse_stream(stream, opts \\ []) do
initial_context = Keyword.get(opts, :initial_context, nil)
# Stream.transform allows us to lazily map over the stream while keeping
# track of an accumulator (the active_context).
Stream.transform(stream, initial_context, fn line, active_context ->
line_str = String.trim(line)
if line_str == "" do
# Ignore empty lines, keep context the same
{[], active_context}
else
parsed_map = Jason.decode!(line_str)
process_parsed_line(parsed_map, active_context)
end
end)
end
# Helper: Determines what kind of line we just parsed and how to handle it.
defp process_parsed_line(parsed_map, active_context) do
has_context? = Map.has_key?(parsed_map, "@context")
keys_count = map_size(parsed_map)
cond do
# 1. It is a "Context Line" (Only contains @context).
# We update the global state and yield NO data lines to the final stream.
has_context? and keys_count == 1 ->
new_context = Map.get(parsed_map, "@context")
{[], new_context}
# 2. It is a Data Line WITH an inline context.
# Spec: Use it for this line only, do NOT update global state.
has_context? ->
{[parsed_map], active_context}
# 3. It is a Data Line WITHOUT a context.
# Spec: Inject the active_context into the object.
active_context != nil ->
injected_map = Map.put(parsed_map, "@context", active_context)
{[injected_map], active_context}
# 4. Fallback: No context available anywhere. Just yield the map.
true ->
{[parsed_map], active_context}
end
end
@doc """
Takes an Enumerable of complete JSON-LD maps and lazy-encodes them into a JSONL-LD string stream.
It automatically strips redundant `@context` keys to save bandwidth, emitting a
standalone "Context Line" only when the context changes.
"""
@spec encode_stream(Enumerable.t()) :: Enumerable.t()
def encode_stream(stream) do
Stream.transform(stream, nil, fn item, active_context ->
item_context = Map.get(item, "@context")
cond do
# 1. The item's context matches our active context.
# Strip it to save bandwidth, yield just the data.
item_context == active_context ->
data_line = item |> Map.delete("@context") |> Jason.encode!()
{[data_line <> "\n"], active_context}
# 2. The item has a new context.
# Yield a Context Line, then the Data line (with context stripped), and update state.
item_context != nil ->
context_line = Jason.encode!(%{"@context" => item_context})
data_line = item |> Map.delete("@context") |> Jason.encode!()
{[context_line <> "\n", data_line <> "\n"], item_context}
# 3. The item has no context. Just encode it.
true ->
data_line = Jason.encode!(item)
{[data_line <> "\n"], active_context}
end
end)
end
end