Page source: read a page's rows once

v0.0.1

In Mishka CMS, a page that shows a record has three readers: the plugin on runtime:page:seo that writes the head, the plugin on runtime:page:frame that fills the layout's frame, and the page's own blocks. Without a source, each of them asks the database for the same rows. A plugin on the runtime:page:source event reads the rows once for the address, as the visitor, and the CMS hands what it read to all three. This page lists the event's payload, where the answer goes, the rules a source keeps, the held key and the attribute a block declares to receive the rows. For the plugin that answers for the layout, see Page frame: languages and the section bar. For every other event, see Plugin events.

When the event fires

The host app serves database pages through MishkaCmsWeb.RuntimeLive. Its handle_params/3 calls MishkaCmsCore.Runtime.PageSource.assign_source/1 for every address, after the page, its language, @query_params and the visitor are known, and before the frame and the head. The call fires the event with the map PageSource.given/2 builds and assigns the answer as @page_source. The CMS then resolves the frame and the head and hands them the same rows.

The payload

KeyHoldsIn your answer
page, section, record, extra, params, language, site_idThe same values the frame's plugins receive. See Page frame.Read only
current_userThe signed-in visitor, or nil for a guest. Read the rows as this visitor.Read only
heldWhat this LiveView's previous address read, for the same visitor, or nil. See Reuse the last read with held.Read only
sourcenil. Put your rows here.Read back

What a plugin answers

  • Put the rows you read under source. The core never looks inside it, so its shape is yours.
  • The CMS keeps only source of the answer. An answer that is not a map, or that has no source key, gives nil.
  • Answer only for the pages you own, matched as a frame plugin matches them: by record, by section or by page.path. Return every other page untouched with {:reply, given} before you read anything.
  • {:reply, :halt, given} keeps later plugins from answering for a page you own.
  • A plugin that raises, or an event whose plugins cannot run, leaves the page with no source. Every reader then reads for itself, as it does without a source.

Where the source goes

ReaderHow it receives the rows
Plugins on runtime:page:seoAs source in their payload, unchanged. The CMS ignores a changed source in their answer. The editor's preview and a share card hand no source, so a plugin reads for itself there.
Plugins on runtime:page:frameAs source in their payload, unchanged. A frame plugin that finds its record here does not read it again.
The page's blocksAs @page_source. The page's template passes it on to each block as the attribute page_source.

The template passes the rows with page_source={assigns[:page_source]}:

heex
<.component
  component_name="shop-detail"
  site="acme"
  current_user={assigns[:current_user]}
  page_source={assigns[:page_source]}
/>

Write assigns[:page_source] and not @page_source. The admin's preview assigns no rows, and a template that reads a missing assign with @ raises.

The rules a source keeps

RuleWhat it means
Read as the visitorRead through the public actions and the policies that the blocks read with: the same locks, the same lock inherited from what a record is filed under, and the same page lock. A source is never broader than what the visitor can see, so a reader handed it shows nothing that a read of its own would not. A read as the system belongs only where nothing of it reaches the visitor, such as the lock that makes a record's page answer 404.
Keyed to one visitorA source says whom it was read for. MishkaCmsCore.Runtime.PageSource.visitor_id/1 gives the signed-in visitor's id, or nil for a guest. Keep it in your source. A block compares it with the id of its own current_user before it takes a row, and a block handed a source read for somebody else reads for itself.
Alive for one addressThe CMS reads the source again in every handle_params/3. It lives in the LiveView's assigns and nowhere else. A task that a block starts receives what it needs as an argument, not the LiveView.
A block still stands aloneOn the editor's canvas, in a preview, on a page whose template does not pass the rows, and for any read the block was not given, a block reads as it always did. A block handed a source takes only the rows read for exactly what it would read itself.

Reuse the last read with held

assign_source/1 remembers whom each source was read for. On the next handle_params/3 of the same LiveView, held is the previous @page_source when it was read for the same visitor. In every other case it is nil, such as on the first address of the LiveView or when the last address was read for a different visitor.

  • The core checks the visitor and nothing else. Before you reuse held, compare the site, the language and the record it was read for. Mishka Blog compares the site, the visitor, the language, the kind of record and the slug.
  • A live patch that stays on the same record, such as the next page of a list or another version, takes from held what the new address did not change and reads only the rest.

A block must declare page_source

The runtime hands page_source only to a component that declares the attribute. It asks the component's own list of declared attributes each time it draws the component, and only when the template names page_source.

  • A component that does not declare it, such as one an admin keeps on an older kit, gets no rows. The runtime draws it as if the template had not named the attribute. Without the check, the rows would fall into the component's global attributes, and the component would draw them onto its root element.
  • When a kit adds the attribute, the component gets the rows as soon as it is compiled.
  • Declare it with the type any and a default of nil:
json
{"name": "page_source", "type": "any", "opts": {"default": null}}

A block that ships with an extension reads a source by its shape, because its code calls nothing of the extension. It checks that rows_of names the extension, then that site_id is its own site and visitor is its own reader. Anything else it reads for itself.

Example: a shop product page

A shop extension marks its product pages with the record shop:product. This plugin reads the product once per address. MyShop.Catalog.product/3 stands for your own read, made as the visitor.

elixir
defmodule MyShop.Plugins.PageSource do
  use MishkaInstaller.Event.Hook, event: "runtime:page:source", initial: %{priority: 50}

  alias MishkaCmsCore.Runtime.PageSource

  @impl true
  def call(%{record: "shop:product", params: %{"slug" => slug}} = given) do
    visitor = PageSource.visitor_id(given.current_user)

    source =
      case given.held do
        %{rows_of: "my_shop", visitor: ^visitor, slug: ^slug} = held ->
          held

        _read_for_another_address ->
          %{
            rows_of: "my_shop",
            site_id: given.site_id,
            visitor: visitor,
            slug: slug,
            product: MyShop.Catalog.product(given.site_id, slug, given.current_user)
          }
      end

    {:reply, :halt, %{given | source: source}}
  end

  def call(given), do: {:reply, given}
end

The page's template passes the rows to a product block, shop-detail, that declares page_source and current_user. The block takes the source with a helper named source_of, which has the argument assigns. It falls back to its own read when the helper returns nil.

elixir
site = __MODULE__.__component_information__([:private, :site_id])

visitor =
  case assigns[:current_user] do
    %{id: id} -> id
    _guest -> nil
  end

case assigns[:page_source] do
  %{rows_of: "my_shop", site_id: ^site, visitor: ^visitor} = source -> source
  _not_for_this_block -> nil
end

The results, address by address:

  • A visitor with the id u1 opens /shop/mug. held is nil, so the plugin reads the product.
  • The same LiveView patches to the same record. held is the first source, and the plugin returns it without a read.
  • The LiveView patches to /shop/cup. held was read for another slug, so the plugin reads again.
  • The next address is read for another visitor, u2. held is nil, and the new source says visitor: "u2".
  • The block, drawn for a guest, takes a source whose visitor is nil. It reads for itself when its reader is a different visitor, when the source is for another site, and when the page passes none.

What the blog and the document extension put under source

Each extension answers for its own kinds of record and keeps its rows in a struct. The shape is the extension's own, and its blocks rely on it.

ExtensionAnswers forStruct and keysBlocks that declare page_source
Mishka Blogblog:post, blog:category, blog:tag, blog:author and blog:team pages, and the front page, which is the page of the Posts placeMishkaBlog.PageSource: rows_of ("mishka_blog"), site_id, visitor, language, kind, slug, record, featured, total and listedblog-post-list, blog-featured-posts, blog-post-search, blog-post-detail, blog-post-comments, blog-related-posts, blog-author-profile and blog-team-profile
Mishka Documentdocs:document and docs:page pagesMishkaDocument.PageSource: rows_of ("mishka_document"), site_id, visitor, language, tried, found_by, workspace, versions, version, pages, pages_by_version, body and notesdoc-section, doc-sidebar, doc-toc and doc-switcher

The struct of the document extension also holds the other editions of the document, which its frame plugin reads. The frame plugins of both extensions take their record from this source. See the Mishka Blog plugin events documentation and the Mishka Document plugin events documentation.