MishkaCmsCore.Runtime.Handlers.UiKitHandler (Mishka CMS Core v0.0.1)

Copy Markdown View Source

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!()

Summary

Functions

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

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

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

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

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.

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.

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.

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

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.

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

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

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.

Installs every kit named, or all of them.

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

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.

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.

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.

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.

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.

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

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

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

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.

Functions

add_custom_kit(url, opts \\ [])

@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(dir \\ kits_dir())

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

@spec axis_bindings(map()) :: %{optional(String.t()) => 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(dir \\ kits_dir())

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

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

@spec component_features(map()) :: %{optional(String.t()) => [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?()

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

@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(opts \\ [])

@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!(opts \\ [])

@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!(opts \\ [])

@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!(opts \\ [])

install_kit!(bundle_path, opts \\ [])

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

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!(kits \\ :all, opts \\ [])

@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(arg1, requested)

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

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

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

@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: [...].

    =

    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(channel \\ :stable)

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

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

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

@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(opts \\ [])

@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!(opts \\ [])

uninstall_kit!(bundle_path, opts \\ [])

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.