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

The picture a link to a page shows where it is shared — X, Telegram, WhatsApp, LinkedIn,
Slack, Discord — drawn from what the page is: 1200×630 JPEG, the size every one of them shows
large.

`MishkaCmsCore.Runtime.Seo` asks for one (`image/2`) for a public page whose head has no image of
its own to show: nothing stored in its `og:image`. A plugin on `MishkaCmsCore.Runtime.Seo.event/0`
fills what the card shows of its record through `card`, and its `image` is the card's picture.

## What a card shows

`card` is a map; every key but `title` may be left out:

  * `title` — the page's title, up to three lines, smaller as it grows, then cut with `…`
  * `description` — all of it, in as many lines as fit under the title: set smaller, down to 21
    pixels, before the title steps down a size; one that fits at no size is cut at the end of a
    sentence, else at a word, with `…` (`shorten/1`)
  * `eyebrow` — a short label above the title: a post's category, "Team", "Author"
  * `site`, `host` — the site's name, in the page's language, and its address
  * `logo` — the `site.logo` setting, on a white tile; the site's first letter without one
  * `people` — `[%{name: name, image: path}]`: round photos, the names beside them; up to four
  * `byline`, `meta` — a line under the names: a team, a date, a reading time
  * `picture` — a picture behind the card, darkened under the text

A picture is a file of the site's own (`site_file/2`): a `/uploads/<site_id>/media/…` path of
its media (`MishkaCmsCore.Runtime.Media.storage_paths/2`), or a `/…` path under the host's static
folder. An `http(s)://` address is never fetched. A photo is read only when it is a JPEG, PNG,
WebP or GIF (`picture_type/1`); the logo may be an SVG too. One it cannot read is left out, and
the card is drawn without it.

Text written right to left — Persian, Arabic, Hebrew — puts everything on the right. The fonts
are in `priv/share_card`: Plus Jakarta Sans for Latin text and Vazirmatn for Arabic script, both
under the SIL Open Font License 1.1 (`priv/share_card/OFL.txt`).

## When it is drawn

Never in a request. `image/2` answers from ETS (`MishkaCmsCore.Runtime.States.AssetsState`), and
the first time a card is asked for it is queued: this module is the Oban worker, on
`:share_cards` (`MishkaCmsCore.Runtime.ShareCardQueue`, as many at once as the Share-card workers
setting says), one job per card. Until it is drawn, the page shows its own image, or none.

A card is named by what it shows (`key/1`): a page whose title changes asks for a new card, and the
old one stays where it is. It is stored at `/uploads/<site_id>/share/<key>.jpg`, served as the
site's other files are.

## Ready before the first visit

`refresh/3` looks at a page's or a record's card in the background — the plugins on the
after-save events call it — and queues its drawing when it is not drawn yet, so it is there for
the first visitor. A save that leaves what the card shows alone finds it drawn and draws nothing.
`refresh(site_id, target, force: true)` draws it again over the old file: the Regenerate button.

## Every card of a site

`redraw_all/2` looks at every card of a site in the background, so none waits for its first
visitor: after what every card of the site shows changes — its address, its name or that name's
translation, its logo — and from the Redraw buttons of the settings page and of an extension. It
queues one planner job (`MishkaCmsCore.Runtime.Workers.ShareCardPlanWorker`), which asks the
producers for the cards they own on the site through the `targets_event/0` mishka_installer
event (`targets/2`) and queues a look at each, in batches. The core answers with its pages
(`MishkaCmsCore.Runtime.PageShareCardTargets`); an extension or a site answers with its own.

## Old cards

A card drawn for data that has changed since stays on disk until the next look at every card of
the site: each look marks the files its pages show (`keep/2`), and once all have run the planner's
`MishkaCmsCore.Runtime.Workers.ShareCardSweepWorker` deletes the files none of them marked. A page
that asks again for a card swept away has it drawn again.

Drawn by libvips in a separate process (`MishkaCmsCore.Isolated`), so a file that crashes it ends
that process, never the server. That process runs at a lower priority (`nice` 10) and libvips in
it on one thread, so a long redraw leaves the processor to the pages being served.

# `card`

```elixir
@type card() :: %{optional(atom() | String.t()) =&gt; term()}
```

What a card shows; see the module doc.

# `target`

```elixir
@type target() :: {:page, String.t()} | {:record, String.t(), map()}
```

What a refresh is for: one page, by its id, or the pages that show records of a kind
(`MishkaCmsCore.Runtime.Seo.record_of/1`), with the captures that name one record —
`{:record, "blog:post", %{"slug" => "hello", "lang" => "en"}}`.

# `cards`

```elixir
@spec cards(String.t(), target()) :: [
  %{title: String.t(), url: String.t(), drawn_at: integer() | nil}
]
```

Each card `target`'s pages show now: `%{title:, url:, drawn_at:}`, `drawn_at` the Unix time its
file was written, `nil` until it is drawn. A page kept out of an index, or storing an `og:image`
of its own, shows none.

# `dir`

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

The folder the share cards of the site `site_id` are written to.

# `image`

```elixir
@spec image(String.t() | nil, card()) :: map() | nil
```

The card `card` names, for the site `site_id`: `%{url:, width:, height:, type:}` once it is
drawn, `nil` until then — the first ask queues it.

`nil` for a card with no title, and for a site with no id.

# `keep`

```elixir
@spec keep(String.t(), String.t() | map() | nil) :: :ok
```

Marks the card file `url` names as shown now — `/uploads/<site_id>/share/<name>.jpg`, or an
address whose path is that — so the sweep that follows a look at every card of the site
(`MishkaCmsCore.Runtime.Workers.ShareCardSweepWorker`) leaves it. A producer whose own job finds
its card drawn calls it; `look/3` calls it for the core's cards and a plugin's. A map with a
`url`, as `image/2` answers, is read for it. A file that is not there, a path elsewhere and
anything else are left alone.

# `key`

```elixir
@spec key(card()) :: String.t()
```

The name of the card that shows `card`: the same for the same card, another when anything on it
changes.

# `layout`

```elixir
@spec layout(card()) :: {:ok, map()} | {:error, term()}
```

Where `card`'s text goes, measured as `draw/1` sets it, in a separate process like the drawing and
without drawing it: `{:ok, %{title_size:, description_size:, description:}}` — the sizes in pixels
and the description as it is shown, which is the whole of it unless no size fits it. Never called
in a request.

# `look`

```elixir
@spec look(String.t(), target(), keyword()) :: :ok
```

Looks at the cards `target`'s pages show now, as `ShareCardRefreshWorker` does in the background:
a card not drawn yet is queued (`image/2`), and with `force: true` every card is drawn again over
its old file (`redraw/2`). Each card file the pages show — the core's, and one a plugin on
`MishkaCmsCore.Runtime.Seo.event/0` drew and put in the head's image — is kept (`keep/2`).

# `picture_type`

```elixir
@spec picture_type(term()) :: String.t() | nil
```

The type of the picture file at `path`, read off its first bytes, never its name:
`"image/jpeg"`, `"image/png"`, `"image/webp"`, `"image/gif"`, `"image/svg+xml"` or
`"image/x-icon"`. `nil` for any other file, and for one that cannot be read.

# `producers`

```elixir
@spec producers(String.t() | nil) :: [String.t()]
```

The producers that answer `targets_event/0` on the site `site_id` — or on any site, for `nil` —
sorted: `["mishka_blog", "mishka_cms_core"]`. Asks for their names alone; nothing is read.

# `put_targets`

```elixir
@spec put_targets(map(), String.t(), (-&gt; Enumerable.t())) :: map()
```

Puts `producer`'s targets in an answer to `targets_event/0`; `fun` is called for them only when
the answer asks every producer or this one.

# `redraw`

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

Queues `card` to be drawn again, over the file drawn before; `:ok` for a card with no title.

# `redraw_all`

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

Looks at every share card of the site `site_id` in the background — of every active site for
`nil` — so a card whose data changed is drawn before anybody asks for it: one planner job a site
on `:share_cards`, which collects the cards through `targets_event/0` and queues a look at each.
A site that already has a planner waiting with the same options keeps that one.

Options:

  * `:force` — `true` draws every card again over its old file; by default only a card whose data
    changed is drawn
  * `:producer` — the one producer to ask, `"mishka_blog"`; every one by default
  * `:schedule_in` — seconds to wait first, so the saves of a burst are one look; none by default

`{:ok, site_ids}`: the sites a planner is queued for.

# `refresh`

```elixir
@spec refresh(String.t(), target(), keyword()) :: :ok | {:error, term()}
```

Queues a look, in the background, at the card `target` shows now, and its drawing when it is not
drawn yet. Saves within two seconds of each other are one look.

`force: true` looks now and draws the card again even when it is drawn, over the old file.

# `shorten`

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

`text` one step shorter, for a text that has no room: without its last sentence while it has more
than one, else without its last word, the rest ending in `…`. `nil` once one word is left.

    iex> MishkaCmsCore.Runtime.ShareCard.shorten("First one. Second one. Third one.")
    "First one. Second one."
    iex> MishkaCmsCore.Runtime.ShareCard.shorten("A single long sentence, with commas.")
    "A single long sentence, with…"

# `site_file`

```elixir
@spec site_file(term(), String.t()) :: Path.t() | nil
```

The file on disk a picture's address names, when it is a file of the site `site_id`'s own: a
`/uploads/<site_id>/media/…` path of its media (`MishkaCmsCore.Runtime.Media.storage_paths/2`),
or a `/…` path under the host's static folder outside `uploads`. `nil` for a file that is not
there, a path of another site or climbing out, an `http(s)://` address — never fetched — and
anything else.

    site_file("/uploads/<site_id>/media/brand/logo.svg", site_id) #=> "<static>/uploads/<site_id>/media/brand/logo.svg"

# `site_settings`

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

The settings a core card shows on every page of a site: its name and its logo.

# `specs`

```elixir
@spec specs(String.t(), target(), keyword()) :: [map()]
```

What each page that shows `target` puts on its share card, as `Seo.head/2` builds it for a visit
to it: the same card, with the same name. The pages are read as the system, as the sitemap reads
them — a job has no visitor.

`redraw: true` tells the plugins on `MishkaCmsCore.Runtime.Seo.event/0` the card is to be drawn
again (`redraw` in `MishkaCmsCore.Runtime.Seo.given/2`): one that draws a card of its own draws it
over its old file.

# `squares`

```elixir
@spec squares(Path.t(), [pos_integer()]) ::
  {:ok, %{required(pos_integer()) =&gt; binary()}} | {:error, term()}
```

The picture at `path` drawn in a square of each of `sizes`, as PNGs: fitted whole and centered
on transparency, never stretched or cut. Drawn in a separate process, as a card is:
`{:ok, %{size => png}}`, else `{:error, reason}`.

`path` is a file on disk `picture_type/1` names a PNG, JPEG, WebP, GIF or SVG — a site's
home-screen icon (`MishkaCmsCore.Runtime.SiteIcon`).

    squares("/srv/uploads/<site_id>/media/icon.svg", [180, 512])
    #=> {:ok, %{180 => <<137, 80, 78, 71, …>>, 512 => <<137, 80, 78, 71, …>>}}

# `static_headers`

```elixir
@spec static_headers(Plug.Conn.t()) :: %{optional(String.t()) =&gt; String.t()}
```

The headers `Plug.Static` adds to a file it serves from the uploads folder:

  * a card is named by what it shows (`key/1`), and a site's icon
    (`/uploads/<site_id>/icons/<hash>-<size>.png`, `MishkaCmsCore.Runtime.SiteIcon`) by the
    picture it is drawn from, so any cache may keep either a year;
  * a file a visitor uploaded to a page (`/uploads/<site_id>/forms/<file>`) is saved rather than
    shown, never sniffed into another type, and sandboxed — see
    `MishkaCmsCore.Runtime.PageUpload.static_headers/0`;
  * a media library file (`/uploads/<site_id>/media/…`) is never sniffed into another type, and an
    `.svg` among them is sandboxed, so a script in it does not run when its address is opened;
  * any other file gets none.

    plug Plug.Static, at: "/", only: ~w(uploads), headers: {ShareCard, :static_headers, []}

# `swept_event`

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

The event the names of the card files a sweep deleted are announced on:
`"runtime:share_card:swept"`, with `%{site_id:, removed: ["<name>.jpg", …]}`. A producer that remembers
its cards as drawn forgets those names here; the core forgets its own.

# `targets`

```elixir
@spec targets(String.t(), keyword()) ::
  {:ok, %{required(String.t()) =&gt; list()}} | {:error, :cannot_run}
```

Every share card of the site `site_id`, by producer, as the plugins on `targets_event/0` answer:
`{:ok, %{"mishka_cms_core" => [{:page, id}, …], …}}`.

Options: `:force`, handed to the producers, and `:producer`, to ask that one alone.
`{:error, :cannot_run}` while the event cannot run: one of its plugins is started but not loaded.

# `targets_event`

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

The event every share card of a site is collected through: `"runtime:share_card:targets"`.

It is called with `%{site_id: id, force: boolean, producer: name | nil, targets: %{}}`, and each
plugin answers with its own put in `targets` under its name (`put_targets/3`) — the name of its
OTP application, `%{"mishka_blog" => [target, …]}`. A target is either

  * a `t:target/0`, `{:page, id}` or `{:record, kind, captures}`, which `refresh/3` looks at, or
  * an `Oban.Job` changeset of the producer's own worker on `:share_cards`, for a card the core
    does not draw: built with `force` it draws the card again over the old file, else only when
    it is not drawn yet.

An answer names targets only — ids and captures, read in pages or streamed — and draws nothing.
With `producer` set, only that producer answers; with `names_only: true` each puts its name and
no targets (`producers/1`).

# `url`

```elixir
@spec url(String.t(), String.t()) :: String.t()
```

Where the card `key` of the site `site_id` is served.

---

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