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

Copy Markdown View Source

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 title tag, the page's over the layout's, else the plugin's title, else the page's own;
  • the description is a stored description meta, the page's over the layout's, else the page's own, else a plugin's stored one, else the plugin's description;
  • 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 schema when it holds an object, else the plugin's, else one WebPage. The plugin's and the WebPage are followed by the site's WebSite (@id <base>/#website) and the Organization that publishes it (@id <base>/#organization, with the site.logo setting as its logo), both named by the site.title setting, so an object that names either by its @id finds it on the page. {{page.image}} in it is the image the head resolved: a stored og: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):

  1. the defaults, built from the resolved title, description, image, address and language, each only when it has a value — the entries of catalog/0 marked default?;
  2. the tags a plugin on event/0 returned;
  3. the layout's seo_tags;
  4. 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 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.

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

head()

@type head() :: %{
  title: String.t() | nil,
  description: String.t() | nil,
  image: String.t() | nil,
  url: String.t() | nil,
  card: map() | nil,
  tags: [tag()],
  schema: [String.t()],
  var_docs: [map()],
  permissions: [String.t()]
}

tag()

@type tag() :: {String.t(), [{String.t(), String.t() | number() | boolean()}]}

Functions

absolute(url, base)

@spec absolute(term(), String.t() | nil) :: String.t() | nil

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

accept(returned, given)

@spec accept(term(), map()) :: map()

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 string
  • description, image, url — a string or nil
  • tags, schema, var_docs — a list
  • vars — a map
  • permissions — a list; what in it is not a string is dropped

alternate_links(editions, base, site_id)

@spec alternate_links([map()], String.t() | nil, String.t() | nil) :: [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.

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

base(host)

@spec base(String.t() | nil) :: String.t() | nil

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

catalog()

@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"
  • :tag and :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 :values combine, 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)

drop_tag(tags, id)

@spec drop_tag([map()], String.t()) :: [map()]

tags without any the catalog entry id names.

entry_of(tag)

@spec entry_of(map()) :: map() | nil

The catalog entry a stored tag is, nil for one the catalog does not name.

event()

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

favicon(site_id)

@spec favicon(String.t() | nil) :: String.t() | nil

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"

given(page, opts)

@spec given(map(), keyword()) :: map()

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.

head(seo, opts)

@spec head(map(), keyword()) :: head()

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/1 of 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 names permissions is kept out too
  • :layers — the layout's and the page's stored seo_tags, in that order
  • :page_description? — whether description is the page's own rather than a plugin's
  • :page_schema? — whether schema is the page's own rather than a plugin's; false by default, which adds the site's WebSite and Organization after 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 stored og:image shows its share card (ShareCard.image/2), false by default: a preview asks for none; :spec returns what the card would show in card without 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.

locale(language)

@spec locale(String.t() | nil) :: String.t() | nil

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"

noindex?(permissions, extra)

@spec noindex?(term(), term()) :: boolean()

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

page_number(arg1)

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

put_tag(tags, tag)

@spec put_tag([map()], map()) :: [map()]

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.

record_of(extra)

@spec record_of(term()) :: String.t() | nil

The kind of record a page shows, from its extra: "blog:post", nil for none.

record_of(%{"record" => "blog:post"}) #=> "blog:post"

record_types(site_id)

@spec record_types(String.t() | nil) :: [map()]

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.

records_event()

@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 on event/0 answers 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"}}]

resolve(page, opts)

@spec resolve(map(), keyword()) :: head()

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.

schema_object(type, values, object \\ %{})

@spec schema_object(String.t(), map(), map()) :: map() | nil

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

schema_types()

@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:}]; default is 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 @type of 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

schema_values(object)

@spec schema_values(term()) :: {String.t(), map()} | nil

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.

site_name(site_id, language)

@spec site_name(String.t() | nil, String.t() | nil) :: String.t() | nil

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") #=> "میشکا"

site_picture(key, site_id)

@spec site_picture(String.t(), String.t() | nil) :: String.t() | nil

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"

stored_tag(tag, attrs, value)

@spec stored_tag(String.t(), map(), String.t()) :: map()

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

@spec stored_tags(term()) :: [%{tag: term(), attrs: map(), content: term()}]

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.

stored_value(tag)

@spec stored_value(map()) :: String.t() | nil

The value a stored tag holds: a meta's content, a link's href.

tag_value(tags, id)

@spec tag_value([map()], String.t()) :: String.t() | nil

The value of the first of tags (stored_tags/1) the catalog entry id names, nil for none.

variables()

@spec variables() :: [%{key: String.t(), label: String.t(), help: String.t()}]

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