Juli4nG/elm-json-render/3.0.0

JsonRender.Spec

The json-render flat Spec model and its fail-closed decoder, scoped to this package's component set and pinned to @json-render/core v0.19.0.

A Spec is the canonical flat form: { root, elements, state } where elements is a map keyed by id and children are referenced by string key (never nested inline).

Fail-closed. The decoder rejects, rather than silently dropping:

  • an unknown / off-catalog component type (json-render's renderer is fail-open here; we are not);
  • props that don't match the strict per-component shape;
  • a dangling child key, a missing root, or a repeat element with no children (the structural floor validateSpec enforces).

A rejected manifest never produces a partial tree: the host shows an error stub.

Spec

type alias Spec =
{ root : String
, elements : Dict String UIElement
, state : Value
}

A flat json-render spec: a root element key, a keyed map of elements, and the host-owned initial state.

decoder : Decoder Spec

Decode a flat json-render Spec, fail-closed. See the module doc for what is rejected. On any structural or off-catalog error the decoder fails with a diagnostic message; the host renders an error stub rather than a partial tree.

Why a decode failed

= UnknownCatalogSurface
| Malformed

Why a manifest failed the fail-closed decode, as far as the decoder's own diagnostics can tell. A host shows its audience one sentence, not a decoder dump, and this is what decides which sentence:

  • UnknownCatalogSurface — the manifest named a component type, an icon, or any other key this catalog does not have. "Any other key" is the whole rejectUnknownKeys surface, not just props: an unsupported element key (visible, watch), action-binding key (onSuccess), confirm key, repeat key, or Table column key all land here. The publisher is describing an interface some newer renderer would understand, so the honest reading is version skew, not a broken manifest.
  • Malformed — everything else: a missing required field, a wrong-shaped prop, a body that is not even JSON. Nothing here suggests a newer renderer would fare better.

This lives beside the Decode.fail arms it classifies, and reads the very strings those arms are built from, so the two cannot drift apart: rewording a diagnostic moves the marker with it.

errorKind : String -> ErrorKind

Classify a decode failure message (the Err from JsonRender.decodeString, which is Decode.errorToString output and so embeds the failing arm's own text).

Substring matching is the only tool available — Elm decode errors are strings by the time they reach a host. Decode.errorToString also prints offending JSON, so a manifest containing one of these markers verbatim as data would be read as skew. That mis-picks which reassuring sentence the reader sees and nothing else: both kinds refuse to render, which is the part that matters.

Elements

type alias UIElement =
{ componentType : ComponentType
, props : Props
, children : List String
, on : Dict String (List ActionBinding)
, repeat : Maybe Repeat
}

One element: its (validated) component type, its strictly-decoded props, its child keys, its event bindings, and an optional repeat. Mirrors json-render's UIElement shape (type/props/children/on/repeat), minus the visible/watch siblings this renderer does not support.

= Card
| Stack
| Text
| Badge
| Button
| Checkbox
| CountPills
| Iframe
| Table
| Alert
| Disclosure

The allowlisted component types. Anything else fails the decode.

The component type's wire name, for diagnostics.

Props

type Props
= CardP CardProps
| StackP StackProps
| TextP TextProps
| BadgeP BadgeProps
| ButtonP ButtonProps
| CheckboxP CheckboxProps
| CountPillsP CountPillsProps
| IframeP IframeProps
| TableP TableProps
| AlertP AlertProps
| DisclosureP DisclosureProps

Strictly-decoded props, one variant per component type. The variant always agrees with the element's ComponentType (both come from a single read of the type field).

= Row
| Col

Stack layout direction.

type alias CardProps =
{ title : Maybe Expr }

Card props. title is optional and may be any expression.

type alias StackProps =
{ direction : Direction
, gap : Int
}

Stack props: a layout direction and a numeric gap.

type alias TextProps =
{ value : Expr }

Text props: the displayed value expression.

type alias BadgeProps =
{ value : Expr
, variant : Maybe Expr
}

Badge props: the value expression (e.g. a status string mapped to a tone) and an optional variant expression. When variant is present its resolved string becomes the badge's data-state attribute (so a manifest can key styling on a stable token while showing different display text); when absent, data-state falls back to the resolved value text.

type alias ButtonProps =
{ label : Expr
, icon : Maybe String
, disabled : Maybe Expr
}

Button props: the label expression, an optional icon name, and an optional disabled expression. When disabled resolves truthy the button renders inert (the native disabled attribute, a jr-button--disabled class, and no press handler at all); absent means enabled, the historical behavior.

icon is a name from a closed set ("trash", "close", "external", "refresh"), never markup and never an expression: the renderer draws the glyph itself, so a manifest can ask for one of a handful of shapes and cannot inject an image. Any other name is refused at decode time, like every other off-catalog value. When the resolved label is non-empty it stays visible next to the glyph; an empty label with an icon renders the button icon-only, with the renderer supplying the accessible name.

type alias CheckboxProps =
{ label : Maybe Expr
, checked : Maybe Expr
}

Checkbox props: an optional label and an optional checked binding (typically a two-way $bindState / $bindItem).

type alias CountPillsProps =
{ bind : Expr
, groupBy : Maybe String
, groupOrder : Maybe (List String)
, itemNoun : Maybe String
, itemNounPlural : Maybe String
, emptyLabel : Maybe Expr
}

CountPills props: the bind expression pointing at an array of records, and the vocabulary for counting them.

Everything but bind is optional. groupBy, groupOrder, itemNoun and itemNounPlural fall back to the host's Render.CountPillDefaults when absent; emptyLabel is the exception and has no host default. That is deliberate: the words a publisher counts in — what a row is called, which field groups them, which group leads — are the publisher's, not the renderer's, and a manifest written before these keys existed must keep rendering in them.

Optional does not mean lenient. A key that is PRESENT but malformed (a null groupBy, a groupOrder carrying a non-string member, a $computed in emptyLabel) rejects the whole manifest rather than decoding as absent.

  • groupBy — the record field to group on.
  • groupOrder — the group values, in the order they should be shown. Groups outside the list sort after the listed ones, by descending count and then name, which is also what an empty order does to everything. Matching is case-insensitive, and a value listed more than once takes its FIRST position.
  • itemNoun / itemNounPlural — what one row and many rows are called, for the total.
  • emptyLabel — what a manifest says definitively about an empty table. Alone among these keys it has NO host default: absent, the renderer synthesizes "No <plural> yet" from whichever plural noun is in play. That fallback is right for a table that has nothing bound yet and wrong for a completed, genuinely empty one; only the publisher knows which it is, so it supplies the string. Resolving to an empty string means no empty-state node at all.
type alias IframeProps =
{ src : Expr
, title : Expr
}

Iframe props: the src URL expression and a title expression. Both are required. The renderer only emits an <iframe> when the resolved src is an https URL whose origin is in the host-provided allowlist; otherwise it shows a benign placeholder (fail-closed).

type alias TableProps =
{ columns : List Column
, bind : Expr
}

Table props: the columns to show (each a key/label pair) and a bind expression resolving to an array of row objects. Each cell reads row[column.key]; a missing key renders an empty cell.

type alias Column =
{ key : String
, label : String
}

One Table column: the row-object key to read and the header label to show.

type alias AlertProps =
{ tone : Tone
, title : Maybe Expr
, message : Expr
}

Alert props: a tone, an optional title expression, and the required message expression (which may itself be a $template or other binding).

type alias DisclosureProps =
{ label : Expr
, open : Bool
}

Disclosure props: a label expression shown in the always-visible summary, and an optional open literal bool (default False) that sets the initial expanded state. Children render inside the disclosure body.

type Tone
= Info
| Warning
| Danger

Alert severity tone, driving the jr-alert--<tone> modifier class. Any other string fails the decode (fail-closed).

Actions & iteration

type alias ActionBinding =
{ action : String
, params : Value
, confirm : Maybe Confirm
}

An event binding: a named verb, its (unresolved) params, and an optional confirm dialog. The key is action/params per the pinned format, never a URL.

type alias Confirm =
{ title : Expr
, message : Expr
, confirmLabel : Maybe String
, cancelLabel : Maybe String
, variant : String
}

A confirm dialog shown by the renderer before an action emits. title and message may be expressions (e.g. a $template interpolating a row field).

type alias Repeat =
{ statePath : String
, key : Maybe String
}

Element-level iteration: statePath points at a state array, key is the optional stable-list item field.