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