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

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.

# `alternates`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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?`

```elixir
@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`

```elixir
@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?`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

---

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