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

Copy Markdown View Source

A page in the other languages it is published in: what <html lang dir>, the hreflang links and a language switcher read, and what "Add translation" writes.

Two ways to publish a page in several languages

An edition is a page row with a language and a language_group_id, the group's key being its master's own id — the model blog posts and documents already use (see MishkaCmsCore.LanguageEditions). The author chooses the URL:

  • one literal path per language — /about in English, /fa/about in Persian, two rows;
  • one pattern for all of them — /:lang/about, one row whose template branches on @query_params["lang"].

Both are read the same way. A literal edition is in the language its row names. A pattern edition is in the languages served_languages/2 gives it — every language its site has (MishkaCmsCore.Runtime.Languages) for a page marked as serving them all, its own otherwise — each at the URL the pattern makes when the lang capture is swapped, wherever in the path that capture sits, with every other capture kept as the visitor asked for it. Where both answer one language the literal row wins, as it does in the resolver.

In a template

MishkaCmsWeb.RuntimeLive assigns @page.language — the language THIS response is in, so the captured one on a pattern page — and @page_languages, which is alternates/3 of editions/2, each at its public address (MishkaCmsCore.Runtime.Languages.url/3), unless the extension that owns the record a page shows answers with that record's own translations (MishkaCmsCore.Runtime.SiteLayout):

<a :for={edition <- @page_languages} href={edition.path} hreflang={edition.language}>
  {String.upcase(edition.language)}
</a>

An href, not a <.link navigate>: <html lang dir> is drawn once per HTTP response, so a live navigation into another language would keep the first one's.

Summary

Functions

Every language a page answers in, each at its URL, sorted by language — [] when there is only one, because a page with no other language has no alternate to offer.

The page and the other editions of its group, each as its stored path, its language and its extra — where served_languages/2 reads whether it answers every language.

The site's pages a search engine may be sent to — active, open to every visitor, not archived, and not asking to be left out of an index, sorted by path — and the literal paths of its other active pages.

The language a response is in: the captured lang when the page's path captures one, else the page's own language, else the primary language of site_id (Languages.primary/1).

Whether a stored path's only capture is the language one — /:lang/blog yes, /:lang/blog/:slug and /guides/:lang/*rest no, and a literal path no. A glob is a capture like any other: *rest holds a path a translation spells its own way.

What "Add translation" offers as suggestions: untranslated/1 first, then the languages this site's other pages are already written in.

Whether a record a page shows may be listed in a sitemap, by the rule indexable/1 holds a page to: it names no permission, and the robots meta of its own seo_tags does not say noindex or none. parent is what the record belongs to — a document for one of its pages: a permission of either keeps it out, and its robots meta counts when the record's own seo_tags hold none.

The ids of the site's active pages that name permissions — a locked page, whatever it is stored at, a pattern such as /blog/:slug included. One read of those rows alone: none come back, and nothing else is read, on a site that locks no page.

What the robots meta in tags says, read the way the head reads it: :noindex when a directive is noindex or none, :index when there is a robots meta that says neither, and nil when there is no robots meta, so whatever the page defaults to stands. parent_tags are read instead when tags hold none — a page's own robots meta replaces its layout's in the head.

The languages a page answers in, by the rule the resolver serves it by — what its hreflang links and a sitemap offer, so that neither names a URL that 404s.

Creates page in language at path, as actor, through the page's own create action — so every rule a page is created under holds for a translation too.

Where a translation of page into language lives, before the author says otherwise.

The languages of the page's site it has no edition in yet, in the site's order — what "Add translation" offers. An inactive edition counts: its row already holds the language. So does an archived one: one edition per language is checked among unarchived rows only, so a second edition made while the first is archived would refuse the first one's restore.

The public addresses a page answers at on its site, by the rule the resolver serves it by (served_languages/2) and in the form MishkaCmsCore.Runtime.Languages.url/3 builds — what a sitemap lists and what no other page may answer.

Functions

alternates(editions, captured, site_id)

@spec alternates([map()], map(), String.t() | nil) :: [
  %{language: String.t(), path: String.t()}
]

Every language a page answers in, each at its URL, sorted by language — [] when there is only one, because a page with no other language has no alternate to offer.

editions is editions/2 of the page, captured what its pattern captured for this request.

A pattern edition only speaks for the language segment

Swapping lang in /:lang/blog — a page marked as serving every language — gives the Persian URL of that page, because every other segment is a literal that means the same thing in either language. An unmarked /:lang/about written in English is swapped into English alone (served_languages/2): the resolver refuses it /fa/about, which is its Persian edition's own row. Swapping it in /:lang/blog/:slug gives no Persian URL at all: the slug the visitor arrived with belongs to the edition they are LEAVING, and the Persian article has a slug of its own — so /fa/blog/<english-slug> is a URL that answers "that post is not published, or no longer exists". As an <a> it is a dead chip; as a <link rel="alternate"> it tells a crawler the Persian version lives somewhere it does not.

So a pattern edition is swapped into the languages it answers in (served_languages/2) only when lang is its ONLY capture. A page holding another one cannot know what that capture becomes in a different language — only the component that owns the row can, and that is where the translations row on a post or a document already comes from. Such a page still names ITSELF: the URL being served, under the language it is being served in, which is derivable and which hreflang wants in the set. On its own that is one edition and the list stays empty; beside a literal translation it is the half that makes the pair.

editions(page, site_id)

@spec editions(map(), String.t() | nil) :: [
  %{language: String.t(), path: String.t(), extra: map() | nil}
]

The page and the other editions of its group, each as its stored path, its language and its extra — where served_languages/2 reads whether it answers every language.

page is the :page section of a compiled page's __page_information__/0, with the module's extra put beside it. Its group, on the page's own site, and only the active, unarchived editions: an inactive one is not served. They are read from MishkaCmsCore.Runtime.States.SiteState.page_editions/2, not the database — the index the boot fills and every write of a page keeps, on each node — so a page reads its group as it is now on every render, a translation added or removed since it was compiled included, without a query and without being compiled again. Nothing in it depends on the request — see alternates/3.

indexable(site_id)

@spec indexable(String.t()) ::
  {[MishkaCmsCore.Runtime.Page.t()], MapSet.t(String.t())}

The site's pages a search engine may be sent to — active, open to every visitor, not archived, and not asking to be left out of an index, sorted by path — and the literal paths of its other active pages.

A login-only page is left out because a crawler is answered with the 404. A page is asking to be left out when the robots meta its head renders says noindex or none: its own, when its seo_tags hold one, else its layout's — a page's robots meta replaces its layout's in the head (MishkaCmsWeb.RuntimeLive.page_head/1), and the tags are read the way the head reads them. A page that holds no robots meta at all says what the head defaults to (MishkaCmsCore.Runtime.Seo.noindex?/2): a sign-in page, one whose extra holds "visitors_only" or an "auth_role", is left out.

The paths, as public addresses (MishkaCmsCore.Runtime.Languages.url/3), are what no sitemap may name: the resolver answers a literal page before any pattern, so /fa/blog/archive is the login-only page stored there, not /:lang/blog/:slug in Persian.

One read of the site's active rows, as the system, with each page's tenant_layout loaded beside it. A page carries its id, path, language, language_group_id, extra, permissions, seo_tags, layout_id and updated_at and nothing else — its template stays in the database.

language(page, captured, site_id)

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

The language a response is in: the captured lang when the page's path captures one, else the page's own language, else the primary language of site_id (Languages.primary/1).

A page reached at /blog on a site that leaves its primary language unprefixed was matched as /en/blog, and captured lang as that language — see MishkaCmsCore.Runtime.States.WebRunTimeMetaDataState.resolve/2.

language_only?(path)

@spec language_only?(String.t()) :: boolean()

Whether a stored path's only capture is the language one — /:lang/blog yes, /:lang/blog/:slug and /guides/:lang/*rest no, and a literal path no. A glob is a capture like any other: *rest holds a path a translation spells its own way.

Such a path makes one URL per language in served_languages/2, with Languages.url/3 given only the language; any other pattern needs a capture only the component that owns its rows can fill.

language_suggestions(page)

@spec language_suggestions(MishkaCmsCore.Runtime.Page.t()) :: [String.t()]

What "Add translation" offers as suggestions: untranslated/1 first, then the languages this site's other pages are already written in.

A SUGGESTION, NOT A RULE. The author types the code, exactly as the page form's own Language box takes one, and a code the site does not list is theirs to write — a literal path like /de/about is matched literally and answers whatever language it is written in. What site.languages decides is narrower: only a lang CAPTURE is held to that list, so a /:lang/… page cannot answer a language the site has not been given. The dialog says so beside the path rather than refusing the word.

listed?(record, parent \\ %{})

@spec listed?(map(), map()) :: boolean()

Whether a record a page shows may be listed in a sitemap, by the rule indexable/1 holds a page to: it names no permission, and the robots meta of its own seo_tags does not say noindex or none. parent is what the record belongs to — a document for one of its pages: a permission of either keeps it out, and its robots meta counts when the record's own seo_tags hold none.

listed?(%{permissions: [], seo_tags: []})                                #=> true
listed?(%{permissions: ["members:read"], seo_tags: []})                  #=> false
listed?(%{seo_tags: []}, %{seo_tags: [%{tag: "meta", attrs: %{"name" => "robots"},
                                        content: "noindex"}]})           #=> false

locked_ids(site_id)

@spec locked_ids(String.t()) :: MapSet.t(String.t())

The ids of the site's active pages that name permissions — a locked page, whatever it is stored at, a pattern such as /blog/:slug included. One read of those rows alone: none come back, and nothing else is read, on a site that locks no page.

indexable/1 leaves out the literal addresses of such pages; this names the pages themselves, for a caller that matches an address to the page that serves it (MishkaCmsCore.Runtime.States.WebRunTimeMetaDataState.resolve/2).

robots(tags, parent_tags \\ [])

@spec robots(term(), term()) :: :index | :noindex | nil

What the robots meta in tags says, read the way the head reads it: :noindex when a directive is noindex or none, :index when there is a robots meta that says neither, and nil when there is no robots meta, so whatever the page defaults to stands. parent_tags are read instead when tags hold none — a page's own robots meta replaces its layout's in the head.

robots([%{tag: "meta", attrs: %{"name" => "robots"}, content: "noindex, follow"}]) #=> :noindex
robots([%{tag: "meta", attrs: %{"name" => "robots"}, content: "index"}])           #=> :index
robots([])                                                                         #=> nil

served_languages(page, site_id)

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

The languages a page answers in, by the rule the resolver serves it by — what its hreflang links and a sitemap offer, so that neither names a URL that 404s.

A page whose path captures lang answers every language of its site when it is marked as serving them all (MishkaCmsCore.Runtime.PageProvision.serves_all_languages?/1), and otherwise its own language alone, while the site still lists it — the capture admits nothing else (see MishkaCmsCore.Runtime.States.WebRunTimeMetaDataState). A page whose path captures none answers in its own language whatever the site lists: nothing in its URL is checked against them.

page is a page row, or any map holding its path, language and extra.

PageEditions.served_languages(%{path: "/:lang/about", language: "en", extra: %{}}, site.id)
#=> ["en"]

translate(page, language, path, actor)

@spec translate(MishkaCmsCore.Runtime.Page.t(), String.t(), String.t(), map()) ::
  {:ok, MishkaCmsCore.Runtime.Page.t()} | {:error, term()}

Creates page in language at path, as actor, through the page's own create action — so every rule a page is created under holds for a translation too.

It starts as a copy of the page — every field the create action accepts, read from the action rather than listed here — under its own name, path and language, in the page's language group, and INACTIVE: until it is translated it is the original under another language's name, and nothing should serve that.

The copy is the whole page because a part of one is a different page. Without its permissions the translation of a members-only page opens to everyone once activated; without its helpers a template calling them does not compile; without its events every phx-click it still sends is swallowed. Its code is written the way any page's is: by whoever may write the page, and refused while the deployment's :page_code switch is off.

ITS ELEMENTS ARE ITS OWN. A translation is a page of its own and is edited in the builder as one, so it gets fresh element classes and its own copy of the original's element CSS — see MishkaCmsCore.Runtime.PageBuilder.ElementScopes, which says what a kept class costs. Every attribute it is copied from rather than the template alone, because a class is a class wherever the page is carrying it: events, infos and seo_tags are plain maps and their text is walked too. A helper is the one that is not — it is an embedded resource, and ElementScopes.rewrite/2 says why a struct is left as written. Rewritten code stays trusted either way: Changes.SignCode signs these fields on the way in, so the translation's signatures are computed from the text it actually stores.

translation_path(map, language)

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

Where a translation of page into language lives, before the author says otherwise.

The page's own path with its language swapped: a lang capture takes the new language, and so does a literal segment that names the page's language. A path with neither is prefixed.

translation_path(%{path: "/about", language: "en"}, "fa")          #=> "/fa/about"
translation_path(%{path: "/docs/en/intro", language: "en"}, "fa")  #=> "/docs/fa/intro"
translation_path(%{path: "/:lang/post/:slug", language: "en"}, "fa") #=> "/fa/post/:slug"

untranslated(page)

@spec untranslated(MishkaCmsCore.Runtime.Page.t()) :: [String.t()]

The languages of the page's site it has no edition in yet, in the site's order — what "Add translation" offers. An inactive edition counts: its row already holds the language. So does an archived one: one edition per language is checked among unarchived rows only, so a second edition made while the first is archived would refuse the first one's restore.

urls(page, site_id)

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

The public addresses a page answers at on its site, by the rule the resolver serves it by (served_languages/2) and in the form MishkaCmsCore.Runtime.Languages.url/3 builds — what a sitemap lists and what no other page may answer.

A literal page answers at its one address, and a page whose only capture is the language at one per language it answers. Any other pattern is the template of many addresses that only the component owning its rows can fill, and answers []. mode builds them as the site would in that URL mode instead of its own.

# site.languages ["en", "fa"], site.language_urls "prefix_others"
PageEditions.urls(%{path: "/:lang/blog", language: "en", extra: %{"serves_all_languages" => true}}, site.id)
#=> ["/blog", "/fa/blog"]
PageEditions.urls(%{path: "/en/pricing", language: "en"}, site.id)
#=> ["/pricing"]

urls(page, site_id, mode)

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