What a database page puts in <head> for search engines and link previews, the tags an editor
can offer by name, and the variables a stored SEO value may use.
MishkaCmsWeb.RuntimeLive resolves a page's head in handle_params/3: it hands the plugins on
event/0 the page's values, reads their answer with accept/2, and renders what head/2
returns.
What the page sets, and what a plugin fills
A plugin on event/0 answers with a page's defaults — what it reads off the record the page
serves. Whatever the page itself sets wins, and whatever it leaves empty is the plugin's:
- the title is a stored
titletag, the page's over the layout's, else the plugin'stitle, else the page's own; - the description is a stored
descriptionmeta, the page's over the layout's, else the page's own, else a plugin's stored one, else the plugin'sdescription; - the image is a stored
og:image, else the page's share card, else the plugin's; - the JSON-LD is the page's stored
schemawhen it holds an object, else the plugin's, else oneWebPage. The plugin's and theWebPageare followed by the site'sWebSite(@id<base>/#website) and theOrganizationthat publishes it (@id<base>/#organization, with thesite.logosetting as itslogo), both named by thesite.titlesetting, so an object that names either by its@idfinds it on the page.{{page.image}}in it is the image the head resolved: a storedog:image, else the drawn share card, else the plugin's.
og:title and twitter:title say the title, og:description and twitter:description the
description, and og:image and twitter:image the image, unless a layer stores one of them.
What a page shows
A page that serves one record per address — a post at /:lang/blog/:slug — says which kind in
its extra, "record" => "blog:post" (record_of/1), and a plugin on event/0 answers for the
kinds it owns, whatever the page's path is. The kinds an editor can choose are what the installed
extensions answer on records_event/0 (record_types/1).
The record's own permissions come back in the answer: MishkaCmsWeb.RuntimeLive opens the
address only for whoever holds one of them, after the page's own, and the head keeps it out of an
index. A plugin answers for each kind it owns even when it finds no record, so an answer the
plugins left untouched on a page that shows one — none of them answered: one raised, or the event
cannot run and hands back what it was given — means the record's lock could not be read, and the
page opens only for whoever holds *.
Share cards
A public page whose head stores no og:image shows a share card, 1200×630, where a link to it is
shared (MishkaCmsCore.Runtime.ShareCard): its title, description, the site's name and logo
(site_name/2, the site.logo setting), and the plugin's image as its picture. A plugin adds what
its record has to show through card — eyebrow, people, byline, meta, picture — and
card: false keeps a page to the plugin's own image. Asked for only when resolve/2 is given
card: true; until it is drawn the head shows the plugin's image.
The page's <link rel="icon"> is the site.favicon setting, when it is set (favicon/1): a
/… path stays a path, so the browser asks the host it is on. A site whose site.app_icon or
site.favicon is a picture of its own also gets <link rel="apple-touch-icon" sizes="180x180">
and <link rel="manifest" href="/site.webmanifest"> (MishkaCmsCore.Runtime.SiteIcon), both on
the host too. A site that set its site.theme_color gets <meta name="theme-color"> with it
(MishkaCmsCore.Runtime.SiteIcon.theme_color/1), icons or not.
Layers
head/2 stacks the tags. A later layer replaces any earlier tag with the same identity — the
tag's name and every attribute except the one holding its value (content for a meta and a
title, href for a link):
- the defaults, built from the resolved title, description, image, address and language, each
only when it has a value — the entries of
catalog/0markeddefault?; - the
tagsa plugin onevent/0returned; - the layout's
seo_tags; - the page's
seo_tags.
What the page set itself is not undone by a plugin's tag: under a stored title, the plugin's
og:title and twitter:title are left out; under the page's own description, its description,
og:description and twitter:description; under a stored og:image, its twitter:image — so
each follows the page, unless the layout or the page stores that tag too.
meta and link tags render; a title tag is the title. An attribute name must be plain
(^[a-z][a-z0-9_:.-]*$), an on… handler is dropped, and so is a value that is not a string, a
number or a flag.
Variables
A string in the page's description, in a stored tag (layers 2–4) or anywhere inside the schema
may name a variable as {{key}} or {{ key }}. variables/0 lists the core ones; a plugin adds
its own through vars, which win on the same key. The page.* ones hold the defaults, before
the page's own title and description, so {{page.title}} in a stored title is the plugin's or
the page's title. A description or a tag holding a placeholder that does not resolve is left out,
a schema value that does not resolve is removed, and {{ never reaches a visitor.
Absolute addresses
Every address the defaults emit is on the site's own address, with the scheme and port the
endpoint answers on — the one the sitemap and robots.txt use (base/1,
MishkaCmsCore.Helpers.site_url/2): http://localhost:4000/en/about in development,
https://example.com/en/about in production. An http:// or https:// address stays as it is; a
protocol-relative one, a javascript: one and anything else is left out rather than emitted
(absolute/2). A stored canonical, alternate, og:url, og:image or twitter:image holding
a /… path is written on the same base.
No address ends in a slash (MishkaCmsCore.Helpers.address_path/1): the home page is the site's
address alone, https://example.com, as the sitemap lists it, in the canonical address, og:url,
the hreflang alternates and the JSON-LD alike.
Pages kept out of an index
The default robots is noindex, nofollow for a page that names a permission or that is a
sign-in page (noindex?/2), and for a record that names one; a stored robots tag replaces it as
any other default.
For an editor
catalog/0, variables/0 and schema_types/0 describe what an editor offers. stored_tags/1,
stored_tag/3, stored_value/1, entry_of/1, tag_value/2, put_tag/2 and drop_tag/2 read
and write stored tags; schema_object/3 and schema_values/1 write and read one JSON-LD object;
resolve/2 is the head a page would render, which a preview draws.
Summary
Functions
An http://… or https://… address as it is, a /… path on base, nil for anything else —
a protocol-relative address, a javascript: one, a relative path.
The map a plugin on event/0 returned, read against the one it was given. Each key keeps the
plugin's value when it has the right type and the given one otherwise; page, site_id,
params, record, redraw and source are always the given ones. Anything but a map is the
given map.
The rel="alternate" links of a page in several languages: one per edition, at its absolute
address (absolute/2), and one hreflang="x-default" at the edition in the site's primary
language (MishkaCmsCore.Runtime.Languages.primary/1), when there is one. [] for fewer than two
editions, which have no alternate to offer.
A site's own address with no path, on the scheme and port the endpoint answers on — the base the
sitemap and robots.txt use (MishkaCmsCore.Helpers.site_url/2); nil for no host.
Every tag the head knows by name, in the order the head emits the defaults.
tags without any the catalog entry id names.
The catalog entry a stored tag is, nil for one the catalog does not name.
The event a page's SEO is resolved through, called once per handle_params/3 with the map
MishkaCmsWeb.RuntimeLive documents.
The site's favicon as a page or a redirect names it: its site.favicon setting, read as
site_picture/2 reads one.
The map event/0 is called with for page, before any plugin has answered.
The head of a page, from the map accept/2 returned.
A language code as Open Graph writes a locale, ll_RR: its language and the two-letter region
the code names, else the one CLDR's likely subtags give the language; the language alone when
CLDR gives none, or only a numeric region. nil for what is no language code.
Whether a page is kept out of an index unless a stored robots tag says otherwise: it names a
permission, or its extra marks it as a sign-in page — "visitors_only" or an "auth_role", as
MishkaCmsCore.Install.StarterContent writes them. Pass head/2 the answer as :restricted?.
The number of the page of a list an address asks for, ?page=2; nil for the first and for an
address that asks for none.
tags with tag written in place of the first one with its identity — its name and attributes
but its value — or after them when there is none. A repeatable catalog entry (article:tag) is
always written after them.
The kind of record a page shows, from its extra: "blog:post", nil for none.
The kinds of record the installed extensions offer for site_id, each as records_event/0
describes, sorted by group; an entry without an id, a group and a label is left out.
The event an extension names the kinds of record its pages show on. It is called with
%{site_id: id, records: []}, and each plugin returns the map with its own appended to records
The JSON-LD object of type, one of schema_types/0, with an editor's values written over
object: each field keyed as schema_types/0 names it, a blank value removing it, and every other
key of object kept. A FAQPage reads "questions", [%{"question" => …, "answer" => …}], and
leaves out a pair with no question. nil for a type schema_types/0 does not name.
The JSON-LD types an editor offers beside the automatic WebPage, each with the few fields
search engines read, in the current locale.
An editor's values for a stored JSON-LD object: {type, values} when its @type is one of
schema_types/0, keyed as schema_object/2 reads them; nil for any other object.
The site's name in language: the site.title setting, translated when the site's Translations
hold that title in language (MishkaCmsCore.Runtime.States.TranslationsState), nil when the
setting is empty.
The picture the setting key names for the site site_id — its own value else the global one —
read off the address URI.new/1 parses, never off its text: a path on the site (no scheme, host
or user info, starting with one /) as it is, or an http/https address with a host and no
user info. nil for no picture and for anything else — //host/…, a \ a browser would read
as /, javascript:, data:.
A stored tag for tag with attrs holding value: a meta's in content, a link's in its href.
Stored tags in one shape, %{tag:, attrs:, content:} with string attribute names, whichever keys
they were stored with. Anything that is not a map is left out; a tag the head will not render is
kept, so writing the list back loses nothing.
The value a stored tag holds: a meta's content, a link's href.
The value of the first of tags (stored_tags/1) the catalog entry id names, nil for none.
The variables every page fills, each %{key:, label:, help:} for an editor, in the current
locale. A stored value names one as {{page.title}}.
Types
Functions
An http://… or https://… address as it is, a /… path on base, nil for anything else —
a protocol-relative address, a javascript: one, a relative path.
An address on the site itself, a path or one that starts with base, is written as
MishkaCmsCore.Helpers.address_path/1 writes a path: no slash at its end, so the home page is
base alone.
absolute("/en/about/", "https://example.com") #=> "https://example.com/en/about"
absolute("/", "https://example.com") #=> "https://example.com"
absolute("https://example.com/", "https://example.com") #=> "https://example.com"
absolute("//cdn.example.com/a.png", "https://example.com") #=> nil
The map a plugin on event/0 returned, read against the one it was given. Each key keeps the
plugin's value when it has the right type and the given one otherwise; page, site_id,
params, record, redraw and source are always the given ones. Anything but a map is the
given map.
The page's own description and schema win: the plugin's fill them only when the page's are
empty.
title,type,language— a non-empty stringdescription,image,url— a string orniltags,schema,var_docs— a listvars— a mappermissions— a list; what in it is not a string is dropped
The rel="alternate" links of a page in several languages: one per edition, at its absolute
address (absolute/2), and one hreflang="x-default" at the edition in the site's primary
language (MishkaCmsCore.Runtime.Languages.primary/1), when there is one. [] for fewer than two
editions, which have no alternate to offer.
editions is [%{language: language, path: path}], each path a public address —
MishkaCmsCore.Runtime.Languages.url/3's. The head's own are :languages of head/2; a plugin
answering for a record puts the record's in its tags from here.
Seo.alternate_links([%{language: "en", path: "/about"}, %{language: "fa", path: "/fa/about"}],
"https://acme.test", site.id)
#=> [%{tag: "link", attrs: %{"rel" => "alternate", "hreflang" => "en", "href" => "https://acme.test/about"}},
# %{tag: "link", attrs: %{"rel" => "alternate", "hreflang" => "fa", "href" => "https://acme.test/fa/about"}},
# %{tag: "link", attrs: %{"rel" => "alternate", "hreflang" => "x-default", "href" => "https://acme.test/about"}}]
A site's own address with no path, on the scheme and port the endpoint answers on — the base the
sitemap and robots.txt use (MishkaCmsCore.Helpers.site_url/2); nil for no host.
base("blog.example.com") #=> "https://blog.example.com" in production
base("localhost") #=> "http://localhost:4000" in development
@spec catalog() :: [map()]
Every tag the head knows by name, in the order the head emits the defaults.
Each entry:
:id— the identity's value:"og:title","canonical":tagand:attrs— the identity, as a stored tag writes it:"meta",%{"property" => "og:title"}:value— the attribute that holds the value:"content"for a meta,"href"for a link:needs— other attributes the author fills:["hreflang"]for a language version:label,:help— for an editor, in the current locale:default?— whether the head emits it when nothing stored names it:values— the allowed values of an enumerated tag, else[]:multiple?— whether several:valuescombine, comma separated (robots):default— the value an editor fills in when the tag is added, which may name a variable:repeatable?— whether a page holds several, one value each (article:tag)
tags without any the catalog entry id names.
The catalog entry a stored tag is, nil for one the catalog does not name.
@spec event() :: String.t()
The event a page's SEO is resolved through, called once per handle_params/3 with the map
MishkaCmsWeb.RuntimeLive documents.
The site's favicon as a page or a redirect names it: its site.favicon setting, read as
site_picture/2 reads one.
favicon(site_id) #=> "/uploads/<site>/media/icon.png"
The map event/0 is called with for page, before any plugin has answered.
page is the :page section of a compiled page's __page_information__/0, or any map holding
its title, language and seo (description, schema). Options: :site_id, :url (the
canonical address), :params (@query_params, %{} by default) and :record, the kind of
record the page shows (record_of/1). An editor reads a dynamic page's var_docs by calling
event/0 with this map and no :params.
:redraw is true when a forced look at the page's share card asks for the map
(MishkaCmsCore.Runtime.ShareCard.specs/3): a plugin that draws a card of its own draws it again
over its old file. false by default.
:source is what the page's plugin on MishkaCmsCore.Runtime.PageSource.event/0 read for this
request, as the visitor (@page_source): a plugin that finds the record there takes it from there
rather than reading it again. nil by default, as for an editor's preview and a share card, where
a plugin reads for itself.
The head of a page, from the map accept/2 returned.
seo holds title, description, image, type, url, language, tags, schema, vars,
permissions and site_id. Options:
:base—base/1of the site's host:languages— the page's editions,[%{language:, path:}],[]when it has one:restricted?— whether the page is kept out of an index,noindex?/2; a record that namespermissionsis kept out too:layers— the layout's and the page's storedseo_tags, in that order:page_description?— whetherdescriptionis the page's own rather than a plugin's:page_schema?— whetherschemais the page's own rather than a plugin's;falseby default, which adds the site'sWebSiteandOrganizationafter it:page_number— the page of a list this address shows (page_number/1); past the first, the title says so,Blog – Page 2, so no two pages of a list share one:card— whether a page with no storedog:imageshows its share card (ShareCard.image/2),falseby default: a preview asks for none;:specreturns what the card would show incardwithout asking for it
A stored title replaces title, a stored description meta description, and a stored
og:image image, else the share card does once it is drawn; with :page_description?, a
plugin's description meta does not replace the page's own. The description has its variables
filled; empty, or naming one that does not resolve, a stored description meta stands in.
Returns the resolved title, description, image and url, the share card's spec (with
:card), the tags to render as
{name, attributes}, the schema as JSON strings safe inside <script>, and the var_docs and
permissions a plugin answered.
A language code as Open Graph writes a locale, ll_RR: its language and the two-letter region
the code names, else the one CLDR's likely subtags give the language; the language alone when
CLDR gives none, or only a numeric region. nil for what is no language code.
locale("pt-br") #=> "pt_BR"
locale("en") #=> "en_US"
locale("fa") #=> "fa_IR"
locale("es-419") #=> "es"
Whether a page is kept out of an index unless a stored robots tag says otherwise: it names a
permission, or its extra marks it as a sign-in page — "visitors_only" or an "auth_role", as
MishkaCmsCore.Install.StarterContent writes them. Pass head/2 the answer as :restricted?.
noindex?([], %{"auth_role" => "sign_in"}) #=> true
noindex?(["members:read"], nil) #=> true
noindex?([], %{}) #=> false
@spec page_number(term()) :: pos_integer() | nil
The number of the page of a list an address asks for, ?page=2; nil for the first and for an
address that asks for none.
tags with tag written in place of the first one with its identity — its name and attributes
but its value — or after them when there is none. A repeatable catalog entry (article:tag) is
always written after them.
The kind of record a page shows, from its extra: "blog:post", nil for none.
record_of(%{"record" => "blog:post"}) #=> "blog:post"
The kinds of record the installed extensions offer for site_id, each as records_event/0
describes, sorted by group; an entry without an id, a group and a label is left out.
@spec records_event() :: String.t()
The event an extension names the kinds of record its pages show on. It is called with
%{site_id: id, records: []}, and each plugin returns the map with its own appended to records:
:id— the kind,"blog:post", which a page stores and its plugin onevent/0answers for:group,:label— what an editor lists it under and as,"Blog"and"Post":captures— the captures its address must have,["slug"],[]for none:samples— a few records to preview a page with,[%{label: "A post", params: %{"slug" => "a-post"}}]
The head page renders at one address: given/2, then the plugins on event/0, accept/2 and
head/2. MishkaCmsWeb.RuntimeLive calls it for every address it serves; an editor's preview
calls it with no :params, and reads the plugin's variables from var_docs.
Options: :site_id, :url, :params, :redraw and :source as given/2 takes them; :base,
:languages, :restricted? and :layers as head/2 takes them; and :stand_ins — true for an
editor's preview, where a page that serves records has none to read: each variable a plugin
describes in var_docs but did not fill reads as {key}, {post.title}, so the preview shows
where it goes.
The JSON-LD object of type, one of schema_types/0, with an editor's values written over
object: each field keyed as schema_types/0 names it, a blank value removing it, and every other
key of object kept. A FAQPage reads "questions", [%{"question" => …, "answer" => …}], and
leaves out a pair with no question. nil for a type schema_types/0 does not name.
schema_object("Article", %{"headline" => "{{page.title}}", "author.name" => "Ada"})
#=> %{"@context" => "https://schema.org", "@type" => "Article",
# "headline" => "{{page.title}}", "author" => %{"@type" => "Person", "name" => "Ada"}}
@spec schema_types() :: [map()]
The JSON-LD types an editor offers beside the automatic WebPage, each with the few fields
search engines read, in the current locale.
Each entry:
:type— the@type,"Article":label— for an editor:fields—[%{key:, label:, default:}];defaultis the value an editor fills in, which may name a variable. A key with a dot,"author.name", is a value of a nested object:nested— the@typeof each nested object, by its key:%{"author" => "Person"}:faq?— whether the object holds questions and answers instead of fields (FAQPage):stars?— whether search engines may show its rating as stars: a review snippet is offered for a product, a recipe, a course, a book, an application, an event and a local business, never for an article or a blog post
An editor's values for a stored JSON-LD object: {type, values} when its @type is one of
schema_types/0, keyed as schema_object/2 reads them; nil for any other object.
The site's name in language: the site.title setting, translated when the site's Translations
hold that title in language (MishkaCmsCore.Runtime.States.TranslationsState), nil when the
setting is empty.
site_name(site_id, "fa") #=> "میشکا"
The picture the setting key names for the site site_id — its own value else the global one —
read off the address URI.new/1 parses, never off its text: a path on the site (no scheme, host
or user info, starting with one /) as it is, or an http/https address with a host and no
user info. nil for no picture and for anything else — //host/…, a \ a browser would read
as /, javascript:, data:.
site_picture("site.app_icon", site_id) #=> "/uploads/<site>/media/app-icon.png"
A stored tag for tag with attrs holding value: a meta's in content, a link's in its href.
stored_tag("meta", %{"property" => "og:image"}, "/a.png")
#=> %{tag: "meta", attrs: %{"property" => "og:image"}, content: "/a.png"}
Stored tags in one shape, %{tag:, attrs:, content:} with string attribute names, whichever keys
they were stored with. Anything that is not a map is left out; a tag the head will not render is
kept, so writing the list back loses nothing.
The value a stored tag holds: a meta's content, a link's href.
The value of the first of tags (stored_tags/1) the catalog entry id names, nil for none.
The variables every page fills, each %{key:, label:, help:} for an editor, in the current
locale. A stored value names one as {{page.title}}.
A dynamic page's own variables come from its plugin on event/0: it returns their values in
vars and describes them in var_docs, [%{key: "post.title", label: "Post title"}].