# `MishkaCmsCore.Sidebar`
[🔗](https://github.com/mishka-group/mishka_cms_core/blob/v0.0.1/lib/mishka_cms_core/sidebar.ex#L5)

The admin sidebar as data — collected from plugins at runtime, filtered by what the viewer may
see, and ordered by what the admin arranged.

The nav used to be a hardcoded literal in `MishkaCmsCore.Layouts`: every extension's links were
spelled out in the core, and every admin who reached the console saw all of them, including
`/admin/dashboard/account/permissions`. This module replaces both halves. An extension declares
its own menu and who may see it; nothing in the core names the extension.

## The event

`"sidebar:menus"`, a `MishkaInstaller.Event.Hook` — the sibling of `"dashboard:widgets"`. The
payload is `%{groups: [Group.t()], items: [Item.t()]}` and every plugin appends to either list:

    defmodule MishkaBlogWeb.SidebarMenu do
      use MishkaInstaller.Event.Hook, event: "sidebar:menus", initial: %{priority: 40}

      use Gettext, backend: MishkaCmsCore.Gettext

      @items [
        MishkaCmsCore.Sidebar.Item.new!(%{
          key: "blog",
          label: gettext_noop("Blog"),
          icon: "hero-book-open",
          path: "/admin/dashboard/blog",
          module: MishkaBlogWeb.Live.Admin.Dashboard.Home.Index,
          group: "plugins",
          order: 10,
          permissions: [
            "admin:dashboard:blog:post",
            "admin:dashboard:blog:comment"
          ],
          credit_name: "Mishka Blog",
          credit_url: "https://mishka.tools/cms/extensions/blog"
        })
      ]

      @impl true
      def call(%{items: items} = data), do: {:reply, %{data | items: items ++ @items}}
    end

A `label` is marked with `gettext_noop/1` and translated when the sidebar is drawn, in the language
its admin reads the dashboard in: a descriptor is built at compile time, when nobody's language is
known yet.

Groups and items are two flat lists rather than a nested tree so that a group and its contents can
come from different extensions — Blog and Documents each contribute one item to the **Plugins**
group the core declares, without either knowing about the other.

Unlike widgets, the payload is seeded with `core_menu/0` instead of an empty list. A widget board
with no plugins registered is merely empty; a nav with no plugins registered is a console you
cannot navigate — so Home, Runtime, Account and Settings are always present, whether or not
mishka_installer has finished starting. `Enum.uniq_by/2` keeps the FIRST declaration of a key, so
a plugin can add sections but never silently redefine a core one.

## Permissions

`permissions` on an item is a list where holding ANY entry grants; `menu/1` compares it against
the viewer and drops the link when nothing matches. Nothing here reads a URL: the question is
"may this user see this link", and the answer needs the user and the item, nothing else.

An entry names the area its data asks for, as a plain string:

  * a screen of one area — Sites, Users, Permissions, Activity, an extension's section — lists
    that area's name: `["admin:dashboard:runtime:site"]`;
  * a content list every dashboard admin may read — Pages, Layouts, Components, Error Pages,
    Stylesheets, JS Hooks, Media, Translations — lists its area and `{:under, "admin:dashboard"}`;
  * the builder lists every name that opens a builder — the page, layout, component and error
    page areas and `admin:dashboard:code` — and the builder asks for the type it opens on;
  * Home, Notifications, Settings and the Plugins section list `{:under, "admin:dashboard"}`.

An API admin holds nothing under `admin:dashboard`, so it sees no entry and opens no page.

**This hides links; it does not guard pages.** A hidden link is still reachable by typing its URL.
Guarding the page is the page's own job — its LiveView declares who may open it — because that is
a server-side check on a known module rather than a string the client controls.

## Arrangement

Each group and item exposes two settings, registered by `MishkaCmsCore.Sidebar.MenuSettings`:

  * `sidebar.<key>.order` — an integer; overrides the declared `order`
  * `sidebar.<key>.visible` — a boolean; `false` hides the entry from everyone

Both are `scope: :both`, so a global default can be overridden per site: one CMS can put Blog
above Runtime for one tenant without touching another. Values are read through
`MishkaCmsCore.Settings.get/3` (RAM cache), and a non-integer order or non-boolean visibility is
ignored in favour of the declaration rather than crashing the chrome.

## Open pages

`changed/1` tells this node's open admin LiveViews that the entries' settings changed, and
`MishkaCmsCore.Layouts.Nav` redraws their sidebar. `MishkaCmsCore.Runtime.States.SettingsState`
calls it after it holds a `sidebar.*` value or an extension's `<app>.active` switch
(`MishkaCmsCore.Install.Extensions.active?/2`), so a view that reads the menu again reads the new
value. A site's change reaches the viewers whose menu reads that site, and the global one reaches
every viewer.

Event: `runtime:state:sidebar:changed` — `%{key: site_id}`, `nil` for the global value.

Like the widget board, nothing here is cached: the hook is called at each mount so enabling or
disabling a plugin shows up immediately. Everything else is compile-time data and ETS reads, and
the viewer's permissions are loaded once per mount rather than once per item.

## What is in this file

The whole concern, in one place: `Group` and `Item`, the two descriptors a plugin declares. They
are small, they are useless apart, and reading the contract next to the code that applies it is
the point.

The order is load-bearing: both come FIRST because `@core_groups`/`@core_items` call their
`new!/1` at compile time — a descriptor is built once when this module is compiled, not on every
admin mount. The settings registrar stays in its own file (`MishkaCmsCore.Sidebar.MenuSettings`)
because it is a supervised plugin, like its widget twin.

# `changed`

```elixir
@spec changed(String.t() | nil) :: :ok
```

Tells this node's open admin LiveViews that the sidebar settings of `site_id` changed; `nil` for
the global ones, which every viewer reads.

    Sidebar.changed(site.id)

# `collect`

```elixir
@spec collect() :: %{
  groups: [MishkaCmsCore.Sidebar.Group.t()],
  items: [MishkaCmsCore.Sidebar.Item.t()]
}
```

Every declared group and item — core plus every `"sidebar:menus"` plugin — with anything that is
not a valid descriptor discarded and the first declaration of a key winning.

Unfiltered: this is the registry, not what a given admin sees. Use `menu/1` to render.

# `core_menu`

```elixir
@spec core_menu() :: %{
  groups: [MishkaCmsCore.Sidebar.Group.t()],
  items: [MishkaCmsCore.Sidebar.Item.t()]
}
```

The core's own menu — Home, the Runtime and Account sections, Plugins (the section extensions hang
their entry point in), and Settings. This is the payload `collect/0` seeds the event with, so the
console is navigable even with no plugin registered.

# `credit`

```elixir
@spec credit(module() | nil) :: %{name: String.t(), url: String.t()} | nil
```

The name and address of the extension that owns the admin page `module` mounts, for the "Powered
by" line under it: `%{name: name, url: url}` from the `credit_name` and `credit_url` one of that
extension's entries declares. `nil` for a core page, a page no entry declares, and an extension
that declares both of them on no entry.

# `event`

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

The mishka_installer event extensions register their menus on.

# `home`

```elixir
@spec home() :: MishkaCmsCore.Sidebar.Item.t()
```

The console's Home entry — where someone refused a page is sent back to.

Read rather than hardcoded so the landing page and its gate cannot drift from the declaration
everything else uses.

# `landing_path`

```elixir
@spec landing_path(map() | nil) :: String.t()
```

Where `user` should be sent when a page will not have them — Home if they may see the console, the
site root otherwise.

Bouncing someone to a page they would also be refused is how a redirect loop starts, so the same
Home declaration that gates the console decides whether the console is a valid destination. Read
through `home/0` rather than hardcoded, so the landing and its gate cannot drift.

Used by `MishkaCmsCore.Sidebar.PageGuard` for a refusal and by the sign-out page for its "stay
signed in" way out — both are the same question.

# `menu`

```elixir
@spec menu(map() | nil) :: %{main: list(), footer: list()}
```

What `user` may see, ready to render: `%{main: [...], footer: [...]}`, each a list of
`Group.t()` (with `items` filled in) and top-level `Item.t()` interleaved in display order.

Items the user may not see are dropped, then groups left with no items are dropped with them — a
tenant admin holding only `admin:dashboard:blog:post` gets Home, the Runtime content lists,
Plugins → Blog and Settings, and nothing else. Ordering and per-entry visibility come from
Settings, resolved against the viewer's own site.

# `opens?`

```elixir
@spec opens?(map() | nil, String.t()) :: boolean()
```

Whether `user` may open the admin page at `path`, as its sidebar entry declares: `true` for a path
no entry declares, since a link to it is not the sidebar's to judge.

# `order_key`

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

The settings key holding `key`'s position override.

# `permissions`

```elixir
@spec permissions(String.t()) :: [
  MishkaCmsCore.Accounts.Checks.AccessPermissions.requirement()
]
```

The permissions declared for one of the CORE's pages. See `permissions/2`.

# `permissions`

```elixir
@spec permissions([MishkaCmsCore.Sidebar.Item.t()], String.t()) :: [
  MishkaCmsCore.Accounts.Checks.AccessPermissions.requirement()
]
```

The permissions declared for `key`, raising when nothing declares it.

This is the single place a page's gate is written down. A page reads its own:

    on_mount {MishkaCmsCore.LiveUserAuth,
              {:require_permission, MishkaBlogWeb.SidebarMenu.permissions("blog.posts")}}

It raises rather than returning `[]` on a miss, and it takes a STATIC list rather than calling
`collect/0`: both are about the same failure. A page reads this at COMPILE time, when no plugin
has registered on the hook yet — so `collect/0` would answer `[]`, and a silent `[]` would mean
a page that gates nothing. A typo has to break the build, not the gate.

# `permissions_for_module`

```elixir
@spec permissions_for_module(module() | nil) :: [
  MishkaCmsCore.Accounts.Checks.AccessPermissions.requirement()
]
```

The permissions declared for the LiveView `module`, or `[]` when nothing declares it.

`[]` means "no declaration", and `MishkaCmsCore.Sidebar.PageGuard` treats that as REFUSE, not as
"no requirement" — an admin page nobody declared is a page nobody vouched for. Forgetting a
declaration therefore locks a page rather than opening it, and the per-extension tests fail the
build long before anyone meets that in a browser.

Looked up by module rather than by path on purpose: a path is a string the client spells (and can
spell several ways), a module is what the router already resolved.

# `settings_definitions`

```elixir
@spec settings_definitions() :: [MishkaCmsCore.Settings.definition()]
```

The `order`/`visible` setting definitions for every declared entry — what
`MishkaCmsCore.Sidebar.MenuSettings` registers with the Settings page.

Each definition inherits the entry's own `permissions`, so an admin is never offered a switch for
a menu they cannot see, and items are grouped under their section's heading. An entry every
dashboard admin sees (`{:under, "admin:dashboard"}`) is arranged by the holders of
`admin:dashboard:setting` alone, even when it also names an area.

# `subscribe`

```elixir
@spec subscribe(map() | nil) :: :ok
```

Subscribes the calling process to the changes `changed/1` announces for the menu `user` is shown:
the global ones and those of the site the menu reads. It receives `{:sidebar_changed, site_id}`.

# `visible_key`

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

The settings key holding `key`'s on/off switch.

# `visible_to?`

```elixir
@spec visible_to?(map(), map() | nil) :: boolean()
```

`visible_to?/3` against the site the USER is on — their own, or the master site for a master.

This is what `menu/1` asks, so a caller checking one entry gets the same answer the menu would
rather than the global one.

# `visible_to?`

```elixir
@spec visible_to?(map(), map() | nil, String.t() | nil) :: boolean()
```

Whether `user` on `site_id` sees `entry` — the per-entry setting, the site's switch over the
extension that owns it, and the permissions it declares.

Public because it is the whole of "may this be drawn", and a caller asking about one entry should
not have to rebuild the menu to find out.

---

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