# `MishkaCmsCore.Runtime.Handlers.UiKitHandler`
[🔗](https://github.com/mishka-group/mishka_cms_core/blob/v0.0.1/lib/mishka_cms_core/runtime/handlers/ui_kit_handler.ex#L5)

Trivial loader for `mishka.ui_kit.bundle.v3` JSON bundles.

The bundle is the install plan. Each entry under `components` is the
exact shape `Ash.create!(MishkaCmsCore.Runtime.Component, params,
action: :master_create)` accepts. Each entry under `js_hooks` is the
same for `Runtime.JsHook`. This module performs no transformation
beyond key atomization, slug-based cleanup of pre-existing rows, and
optional per-install `site_id` override.

All semantic decisions (slugged names, sibling cross-call rewriting,
prelude dedup, type-resolved attrs, dropped struct defaults) live in
the upstream converter (Chelekom's `mix mishka.ui.export ... --cms`).

    MishkaCmsCore.Runtime.Handlers.UiKitHandler.install_chelekom!()
    MishkaCmsCore.Runtime.Handlers.UiKitHandler.install_chelekom!(site_id: tenant_id)
    MishkaCmsCore.Runtime.Handlers.UiKitHandler.uninstall_chelekom!()

# `add_custom_kit`

```elixir
@spec add_custom_kit(String.t(), keyword()) :: {:ok, map()} | {:error, String.t()}
```

Fetches a UI-kit bundle from an arbitrary URL, validates it, and caches it beside the official ones.

The one way to install a package this CMS does not ship. It is deliberately the same code path an
official kit takes once the bytes are in hand — the same `$schema` check, the same directory, the
same discovery on the next boot — because a third-party kit that behaved differently would be a
second class of kit to keep working.

## What is checked, and why each one

  * the response is **bounded** (`:max_bundle_bytes`, 32 MB) — an unbounded read of a URL a
    stranger controls is the whole attack, and the largest kit anyone ships is under 12 MB;
  * it parses as JSON and carries `"$schema": "mishka.ui_kit.bundle.v3"`, so a 200 that is really
    an HTML error page is refused rather than installed as an empty kit;
  * it has a `name`, which becomes its component prefix and its uninstall key;
  * that name **does not collide** with a kit already present. Two kits sharing a prefix means
    uninstalling either deletes the other's components — see `uninstall_kit!/2`, which purges by
    prefix.

Returns `{:ok, bundle}` or `{:error, reason}` with a sentence a wizard can show. Nothing is written
unless every check passes.

The source URL is recorded on the cached bundle so the admin can say where a kit came from, which
matters more for one of these than for a package we shipped.

# `available_kits`

```elixir
@spec available_kits(String.t()) :: [
  %{
    name: String.t(),
    version: String.t(),
    path: String.t(),
    components: non_neg_integer()
  }
]
```

Every UI kit bundle on disk, newest-looking first: `[%{name:, version:, path:, components:}]`.

The CMS ships one kit today and the generator already exports a second — `mix mishka.ui.export
priv/headless --cms --name chelekom_headless` — so the installer asks the DIRECTORY what is
available rather than naming Chelekom in code. Drop a bundle into `priv/cms_ui_kits/` and it is
offered; nothing else has to change.

A file that is not a readable v3 bundle is skipped rather than raising: an installer that refuses
to start because somebody left a note in the kits directory is worse than one that ignores it.

# `axis_bindings`

```elixir
@spec axis_bindings(map()) :: %{optional(String.t()) =&gt; String.t()}
```

Which attribute each helper axis actually describes, read from the call sites in the template.

A discriminator is keyed by the helper's own PARAMETER name — `color_variant(variant, color)` gives
axes `variant` and `color`. The attribute passed in is a different thing: alert's template calls
`color_variant(@variant, @kind)`, so the eleven colours belong to `kind`. Keyed by the axis, they
belonged to an attribute the component does not have, and the single most consequential choice on
an alert — is it primary, is it danger? — had no picker at all. Twenty-five components have an
orphan `color` axis, twenty-two `variant`, twenty-one `rounded`.

Two rules, in order:

  1. an axis whose name IS one of the passed attributes binds to it — the common case, where the
     author named the parameter after the attribute;
  2. when exactly one axis and one attribute are left over, they bind to each other — which is what
     recovers `color` → `kind`, **and only when the attribute's own default is one of the values
     that axis offers**. That last clause is not caution for its own sake: without it, `divider`
     and `hr` bound their `type` axis onto `position`, whose default is `middle` and which the axis
     has never heard of. The picker offered two options, neither of them the value the element
     actually had.

Anything still ambiguous is left keyed by its axis, because a wrong picker on the wrong property is
worse than a text box.

## A kit may simply say

All of the above is inference, and inference is what a second kit can break. A component carrying

    "axes": [{"name": "color", "attr": "kind"}]

is believed and none of it runs. The exporter that emits it lives upstream, in the kit's own
repository; this end is ready for it, so the day a bundle ships the key the guessing stops for
that component with no change here.

**The key is the opt-out, not the entries.** A component carrying an `axes` list — even an empty
one, which says "this component has none" — is never guessed at again. An entry naming an
attribute the component does not declare is dropped, and the axis simply goes unbound: falling
back to the reading above would re-enable, on a typo, the exact mechanism the declaration was
written to replace, and it could bind somewhere the kit never meant. A text box is the better
failure here, which is the same reason the leftover rule refuses an ambiguous pair.

# `bundle_files`

```elixir
@spec bundle_files(String.t()) :: [String.t()]
```

The path of every `.json` file in the kits directory, sorted, without opening any of them.

What a cache keyed by the bundles on disk reads: `available_kits/1` parses each bundle, and the
Chelekom one alone is 10.8 MB.

# `channels`

```elixir
@spec channels() :: [atom()]
```

The release channels the installer offers when the kits are not configured: `:stable`, the newest
release that is not a pre-release; `:prerelease`, the newest of any kind; and `:master`, the
files committed on Chelekom's `master` branch, which no release has vouched for yet.

# `component_features`

```elixir
@spec component_features(map()) :: %{optional(String.t()) =&gt; [String.t()]}
```

The available option values per axis for a single ALREADY-PARSED bundle component
(`%{"variant" => [...], "color" => [...], ...}`) — the same extraction `list_features/1` does, but
from an in-memory component map (no bundle reload). Used by the installer's `UiKitCatalog` to build
the per-component variant picker.

# `configured?`

```elixir
@spec configured?() :: boolean()
```

Whether the host names its kits in `config :mishka_cms_core, :official_ui_kits, […]`. Then that
list is fetched as it is, and no release is looked up — `[]` means nothing is downloaded and only
the bundles already in `kits_dir/0` are offered, which is what an air-gapped install wants.

# `default_bundle_path`

```elixir
@spec default_bundle_path() :: String.t()
```

The on-disk path of the Chelekom bundle in `kits_dir/0` (operator-overridable by placing a file there).

# `download_missing`

```elixir
@spec download_missing(keyword()) :: [map()]
```

Downloads every official kit that is not in `kits_dir/0` yet, and leaves each file already there
as it is — a developer's own export included.

The version is found the way the installer finds it, from `channel:` (default `:stable`): the
newest stable release, else the newest pre-release, else `master`. Nothing is looked up while every
file is on disk. Returns what could not be had, in `resolve_kits/1`'s failure shape.

# `ensure_bundle!`

```elixir
@spec ensure_bundle!(keyword()) :: map()
```

Resolves ONE official kit to a parsed `mishka.ui_kit.bundle.v3` map, local-first.

If a bundle already exists at the kit's path it is used as-is; otherwise it is downloaded and
cached there. Kept for the single-kit callers that predate `resolve_kits/1`; raises on failure.

# `ensure_bundles!`

```elixir
@spec ensure_bundles!(keyword()) :: [map()]
```

Every kit the installer should offer, as parsed bundles. Raises only when NONE can be resolved.

See `resolve_kits/1` for the same work with the failures reported rather than discarded.

# `install_chelekom!`

# `install_kit!`

Install any UI-kit bundle from `bundle_path`. Kit-agnostic — point it at
any `mishka.ui_kit.bundle.v3` JSON. `install_chelekom!/1` is a thin
wrapper that defaults the path to the downloaded Chelekom bundle.

Rows are **upserted** by `unique_name_per_site` (name + site_id): an
existing component/stylesheet/js_hook is updated in place (stable id,
preserved history); a new one is created. So re-importing is safe and
non-destructive — no need to wipe first.

## Options

  * `:site_id` — install under this tenant. Default `nil` (global).
  * `:cleanup` — when `true`, hard-delete existing `<kit_name>-*` rows
    before importing (full reset; drops rows no longer in the bundle).
    Default `false` — rely on upsert. Use `true` only for a clean slate.
  * `:selection` — narrow what gets installed. See "Selection".

## Selection

Default is `:all` — install everything in the bundle. Custom shape:

    %{
      components: :all | [String.t()],
      variants:   :all | [String.t()],
      colors:     :all | [String.t()],
      per_component: %{String.t() => component_filter}
    }

where `component_filter` is `%{variants: [...], colors: [...]}`.

Examples:

    # Only `button` and `badge`, all variants/colors
    install_kit!(path, selection: %{components: ["button", "badge"]})

    # All components, only `base` variant
    install_kit!(path, selection: %{variants: ["base"]})

Filtering applies to the component list, each kept component's
`helpers[]` (drop clauses the selection rules out), and `attrs[]`
`values:` constraints. Catch-all helpers (`_, _`) are always kept so
dropping color/variant clauses never crashes a render.

> #### Beware narrowing helpers {: .warning}
> A restrictive `:variants`/`:colors` selection drops the matching
> `color_variant`/etc. clauses. A component installed that way renders
> WITHOUT those colors/variants (the catch-all fallback returns the
> bare variant name). For a full component set, use `:all`.

# `install_kits!`

```elixir
@spec install_kits!([String.t()] | :all, keyword()) :: :ok
```

Installs every kit named, or all of them.

`kits` is a list of bundle NAMES as `available_kits/0` reports them. Each is installed through
`install_kit!/2`, which is already kit-agnostic — it prefixes its cleanup with the bundle's own
name, so two kits installed side by side never delete each other's components.

# `install_scope`

```elixir
@spec install_scope(map() | nil, :global | String.t() | nil) ::
  {:ok, String.t() | nil} | {:error, :forbidden}
```

Which site an install may write into, derived from the ACTOR rather than from what was asked for.

A master (`site_id == nil`) may install globally — shared by every site — or into one named site.
A tenant may install into their own site and nowhere else; asking for `nil` is read as "mine"
rather than refused, because a form that omits the field is not an attack.

## Why this is not left to the UI

The scope arrives on an event, and an event is whatever the client sent. A tenant who never sees
the control can still emit `phx-click` with any payload, and `install_kit!/2` writes `site_id`
straight through — a global install from a tenant would put their components on every site on the
server, and only a master could remove them.

## `:global` is a value, not `nil`

The scope this resolves TO is a `site_id`, and `nil` there means global — but `nil` coming IN
means "the request said nothing". Letting one atom carry both is how the first version of this
allowed exactly what it was written to stop: a tenant sent `scope: "global"`, the caller turned
that into `nil`, and `nil` matched the "unspecified, so give them their own site" clause. Allowed,
silently. `:global` has to be asked for by name.

    install_scope(%{site_id: nil}, :global)
    #=> {:ok, nil}

    install_scope(%{site_id: nil}, "acme")
    #=> {:ok, "acme"}

    install_scope(%{site_id: "acme"}, nil)
    #=> {:ok, "acme"}

    install_scope(%{site_id: "acme"}, :global)
    #=> {:error, :forbidden}

# `kits_dir`

```elixir
@spec kits_dir() :: String.t()
```

The directory the kit bundles are read from and written to: `priv/cms_ui_kits/`, or the
`config :mishka_cms_core, :ui_kits_dir, path` a host keeps them in instead.

# `list_components`

```elixir
@spec list_components(keyword()) :: {:ok, [String.t()]} | {:error, term()}
```

Return the bare component names (without the kit prefix) shipped by
the bundle. Useful for an installer UI that lets the user pick which
components to install.

    {:ok, names} = UiKitHandler.list_components(bundle_path: path)
    # => {:ok, ["accordion", "alert", "avatar", "badge", "button", ...]}

# `list_features`

```elixir
@spec list_features(keyword()) :: {:ok, map()} | {:error, term()}
```

Return the per-axis feature set for a single component. The
`:features` map shows every axis the user can filter, paired with
the values the kit ships for that axis.

Sources:
- Helper-level discriminators (extracted from the kit's `.eex`
  `<%= if %>` chain) — exact axis-value pairs the helpers support.
- Attribute `values:` constraints — values declared via
  `attr :variant, :string, values: [...]`.

    {:ok, info} =
      UiKitHandler.list_features(bundle_path: path, component: "button")

    # => {:ok, %{
    #      component: "chelekom-button",
    #      attrs: ["id", "variant", "color", "size", ...],
    #      features: %{
    #        "variant" => ["base", "default", "outline", "shadow", ...],
    #        "color"   => ["primary", "secondary", "danger", ...],
    #        "size"    => ["extra_small", "small", "medium", ...],
    #        "rounded" => ["none", "small", "medium", "large", "full"]
    #      }
    #    }}

# `official_kits`

```elixir
@spec official_kits(atom()) :: [map()]
```

The official kits from `channel`, each with the `:tag` an operator reads in the wizard and the
`:url` it is fetched from: the configured list as it is, or Chelekom's packages as the release
`channel` finds attaches them — or as `master` holds them, while no release attaches any. `[]`
when the releases could not be looked up.

# `packages`

```elixir
@spec packages() :: [map()]
```

The packages the installer fetches, in order: the host's `:official_ui_kits` when it set them,
otherwise Chelekom's two. Each carries `:key`, `:label` and `:file`; a configured one also its
`:tag` and `:url`, while Chelekom's version is found when they are fetched — see `official_kits/1`.

# `prefer_local?`

```elixir
@spec prefer_local?() :: boolean()
```

Whether a bundle already on disk beats the pinned download for this environment.

DEVELOPMENT prefers the local file, because a developer who put one there did so on purpose.
PRODUCTION prefers the pinned tag, because a file that happens to be in the image — a stale cache,
something baked in by a previous release — must not silently override the version the operator
chose. A file on disk that IS the pinned version is read rather than downloaded again, in both.

Either way the other source is still tried: a developer with no local copy still gets a working
install, and production still falls back to a bundle downloaded earlier when the network is gone,
so neither environment can dead-end.

Default `false` — production behaviour — so a host that never configures it gets the version it
asked for rather than whatever is lying around.

# `read_bundle`

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

The bundle at `path` as the installer reads it — screened, so what comes back is what an install
would actually write.

`MishkaCmsCore.Runtime.Handlers.UiKitUpgrade` compares a site's rows against it, and comparing
against the raw file would report a component this installer drops as one the site is missing.

# `resolve_kits`

```elixir
@spec resolve_kits(keyword()) :: %{
  bundles: [map()],
  failures: [map()],
  sources: [map()],
  release: %{channel: atom(), tag: String.t(), fallback?: boolean()} | nil
}
```

Every kit the installer should offer, WITH the ones that could not be fetched.

Returns `%{bundles: [...], sources: [...], failures: [%{key:, label:, tag:, url:, reason:}]}`.

`:sources` describes what resolved — `%{name:, version:, tag:, official?:}` per bundle — because
the wizard has to tell an operator which version it is about to install, and joining a bundle back
to the configured entry by filename would be a guess. The resolver already knows.

Failures are reported rather than raised because there is more than one official package now: a
headless bundle that 404s must not take the styled one — or a perfectly good third-party bundle
sitting on disk — down with it. The wizard names what it could not reach and installs the rest.

`progress: fun` is called as an official kit downloads — `fun.(key, {received, total})`, in
bytes, `total` being `nil` when the server does not say — about once a percent. The wizard draws
its bars from it. A kit read from disk reports nothing.

`channel:` is a release channel from `channels/0`; `:release` in the answer says which release it
found — `%{channel:, tag:, fallback?:}`, `nil` for configured kits or none. Without a channel a
developer's bundle on disk wins (`prefer_local?/0`) and no release is looked up; with one, that
channel is fetched. `local_only: true` looks nothing up and downloads nothing — the bundles in
`kits_dir/0` are the answer, which is what the Studio wants.

# `uninstall_chelekom!`

# `uninstall_kit!`

Remove every row tagged with this kit (components by `<kit_name>-*`
prefix, plus the bundle's JsHook + Stylesheet names). Kit-agnostic;
`uninstall_chelekom!/1` defaults the path to the Chelekom bundle.

---

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