MishkaCmsCore.Runtime.ShareCard (Mishka CMS Core v0.0.1)

Copy Markdown View Source

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.

Summary

Types

What a card shows; see the module doc.

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"}}.

Functions

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.

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

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.

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.

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

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.

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).

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.

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.

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.

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

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.

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.

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.

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.

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

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.

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}.

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

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.

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

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

Where the card key of the site site_id is served.

Types

card()

@type card() :: %{optional(atom() | String.t()) => term()}

What a card shows; see the module doc.

target()

@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"}}.

Functions

cards(site_id, target)

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

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

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

image(site_id, card)

@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(site_id, url)

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

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

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

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

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

@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(data, producer, fun)

@spec put_targets(map(), String.t(), (-> 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(site_id, card)

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

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

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

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

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

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

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

specs(site_id, target, opts \\ [])

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

@spec squares(Path.t(), [pos_integer()]) ::
  {:ok, %{required(pos_integer()) => 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(conn)

@spec static_headers(Plug.Conn.t()) :: %{optional(String.t()) => 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()

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

@spec targets(String.t(), keyword()) ::
  {:ok, %{required(String.t()) => 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()

@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 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(site_id, key)

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

Where the card key of the site site_id is served.