MishkaDocument.Kit (Mishka Document v0.0.1)

Copy Markdown View Source

Where this extension's UI kit bundle is, and how to install it.

What is in the bundle, and why it is a bundle

priv/cms_ui_kits/mishka_document.json carries eleven components of two kinds. Each is complete on its own: its markup, its declarations, and its own code, including the Ash reads it runs. Nothing about them lives in Elixir.

The six blocks, which are live components

doc-workspace-list, doc-section, doc-sidebar, doc-toc, doc-switcher and doc-section-cards. Each is a Phoenix.LiveComponent that runs its own reads, so an author can put any of them on any page and each holds what it read. The first five resolve the document from the URL; doc-section-cards from the page it is drawn in.

The five pieces, which are function components

doc-workspace-card, doc-pager, doc-notice, doc-callout and doc-example. These hold nothing and read nothing: they draw what they are handed. The notice is drawn by five of the six blocks — it is every skeleton but the list's own, every empty line and every failed read in this kit, bar the switcher's, which draws a small skeleton and error of its own to fit the bar it sits in — and the card is what doc-workspace-list draws a document with unless the author names a card of their own in its card attribute. The callout and the example are placed by an author inside a page's text, with the content editor — [[component doc-callout tone="tip"]]…[[/component]] — and drawn by doc-section's rich_content, which names them for the site's stylesheet.

The cards

[[component doc-section-cards /]] in a page's text, or in one of its notes, draws the pages filed under that page as cards, two a row from the sm breakpoint and one under it: each its title, the badges a plugin puts on it, its summary — the description its own SEO gives it, else the start of its text — and a link to its address. doc-section hands the marker the page it is drawn in (placed_in) and the doc_base, lang_param and version_param it links with, after the scrub and never inside <pre> or <code>. parent names another page of the same version — by its address, or by a slug only one page has, which is how a group with no page of its own is named. The pages are the version's approved ones the visitor may read, read through public_list_at_workspace_version as doc-sidebar reads them, under the parent the contents draw each one under and in their order; a group with no page of its own gives up its pages in its place. doc-notice draws its skeleton, its error and its empty line (empty_text).

Its badges are asked on document:page:extras, once a card, with the page as doc-section tells it and card: true, and only the header's :badges are drawn. A plugin answering an ask with card: true answers from what it already holds and fetches nothing; show_badges=false asks nothing. Every element says what it is in its data-role: doc-cards, doc-cards-heading, doc-cards-items, doc-cards-item, doc-cards-link, doc-cards-title, doc-cards-badges, doc-cards-badge (data-tone), doc-cards-description, doc-cards-empty, doc-cards-loading and doc-cards-error.

The design they are drawn in

Every block is drawn in the starter theme's tokens — text-fg dark:text-fg-dark, border-edge dark:border-edge-dark, bg-surface dark:bg-surface-dark and the rest — and in the three typefaces it names, so the site's dark mode turns the whole documentation. A colour the theme has no token for is written as a value with its dark: twin beside it. A site that was installed with no starter content has no such theme, and draws these blocks in the browser's colours until it has one.

  • doc-workspace-list draws a document as a card — its tile, how many pages and which version, its name, its summary, the languages it is published in and the way in — or, as one line of a list, as a row. show_search draws a search box over the list that narrows it to the documents whose name or summary carries the words, read from search_param of the page's own query string (q) and written back there, so a search has an address of its own; split_featured draws the documents flagged Featured as cards and every other one as a row under others_heading, the featured ones first. A document only some readers may open carries locked_text, and one that asks search engines to leave it out unlisted_text. Its tile is the first letter of its name — without a leading Mishka — on one of six tints picked from the document (doc-workspace-card).
  • doc-switcher draws the version being read in a menu of the document's versions, each leading to the same page in it, and a line saying so, with the way to the latest, when it is an older one. It is drawn by the bar under the site's header (MishkaDocument.Plugins.PageFrame), at the end of the trail the bar draws itself, and has no trail and no languages of its own: the header's language switcher lists the languages the page is in. The bar hands it the visitor as current_user and the page's rows as page_source, so it reads each version's pages as the visitor: a version whose page this reader may not open leads to the document's front page.
  • doc-section draws a document's overview — its tile, its version and how many pages, its name and summary, the first start_count pages as numbered cards with the start of their text, and every page under the heading it is filed under — and a page with its trail, its title, its text and the pages either side. The first paragraph of a page's text is its lead. A callout (doc-callout) has six tones, each with its own mark.
  • doc-toc is the list of what is on the page; its doc-toc-spy hook marks the entry the reader has scrolled to.

The version in a page's text

{{doc.version}} — or {{ doc.version }} — anywhere in a page's text or in one of its notes is drawn as the number of the version the reader is reading: 0.0.2 on the page of 0.0.2, 0.0.1 on the same page of 0.0.1. It is filled on the way out, after the scrub and HTML-escaped, in the text, in its code blocks and in the attributes of a marker, so an install line, a link to a release and a component's href each follow the version picked:

<pre><code class="language-elixir">{:my_lib, "~> {{doc.version}}"}</code></pre>
<a href="https://example.com/my_lib/blob/v{{doc.version}}/README.md">README</a>

A page stores the placeholder as it was written, so a version cloned from another (MishkaDocument.Resources.Changes.CloneVersion) draws its own number with nothing changed. \{{doc.version}} is drawn as {{doc.version}}, for a page that shows the placeholder itself. doc-toc lists a heading with its version filled the same way, a doc-section-cards card's summary is the start of its page's text with it filled, and so is the text of an example's preview (MishkaDocumentWeb.Live.ExamplePreview). In a highlighted code block it is filled where the highlighter left it in one token — inside a string or a comment.

The example

[[component doc-example title="…"]] around the markers of the components an example is made of, then [[/component]]. Its inner_block is drawn on the page itself, in the example's doc-example-stage and its doc-example-body, so the components' own hooks, events, popups and breakpoints work there as anywhere on the page, in the page's own colours.

Its toolbar — doc-example-toolbar, named by toolbar_label — turns the example left to right or right to left, and, for an example with a source (every one doc-section draws), its doc-example-open button, saying preview_text, opens its preview: a <dialog> (doc-example-preview, opened as a modal) the hook builds the first time it opens, inside a doc-example-preview-holder drawn with phx-update="ignore" and an id carrying a digest of the words it is drawn with, so a LiveView patch never closes it and other words build a preview of their own. The holder is drawn empty: it carries the preview's words and, in data-src, the page its frame shows, and nothing else of the preview is drawn with the example, so an example costs a page, and a LiveView patch, its own markup and the few elements around it. The frame (doc-example-frame, named by title or frame_label) shows /_preview/doc-example/<source>, the page that draws that example alone and live with the site's stylesheet and scripts (MishkaDocumentWeb.Live.ExamplePreview), only while the preview is open; it is sandbox="allow-scripts allow-same-origin allow-forms". The preview's own toolbar (doc-example-preview-toolbar) switches its colours, its width — a desktop's 1280 pixels, a tablet's 768 or a phone's 375, scaled down to fit a narrower preview — and the direction, and its doc-example-close button, saying close_text, Escape and a press on the backdrop close it.

theme (light, dark; dark by default) and device (desktop, tablet, mobile; desktop) are what the preview is first drawn with, and dir (ltr, rtl; ltr) is what the example is first drawn with on the page and in the preview, carried on its root as data-frame-theme, data-frame-device and data-frame-dir, and dir on the stage. Each choice is a doc-example-option button with a data-action of theme, device or dir, a data-value and aria-pressed. Every element, the preview's included, says what it is in its data-role, and that is what the kit's stylesheet draws it by: no element of the example but its doc-example-body carries a class, and a site's stylesheet restyles any of them by its data-role. Drawn without an id the example has no toolbar and no preview.

An example taller than collapse_height pixels (320; 240, 400, 480 or 640, and 0 cuts none) is cut at that height under a fade until the doc-example-expand button in the doc-example-foot under it shows all of it, saying expand_text and then collapse_text. The root carries the height as data-collapse-height; the hook measures the example's body and writes data-collapsible onto the root — again whenever the body changes size — and the button writes data-expanded. The cut clips only the example's height, so a popup still reaches past its sides.

An example holding a component that keeps state is drawn on the page like any other, and its preview draws it live like any other.

They draw themselves in plain HTML and Tailwind and call no other kit. A site that installed this extension and skipped Chelekom renders them; the components a documentation page is made of cannot depend on a kit the installer only offers.

That is the same shape Chelekom ships in, and it is the point. A component that arrives as data can be installed one at a time or in batches, can be edited in the admin like any other row, and carries no dependency on a module a third-party importer would not have. An extension that kept its components in compiled code would be asking the CMS to treat it specially.

Who they read as

Every read is the public action's, with the visitor the page hands down as current_user as its actor — nil for a guest — so a document or a page of one locked to permissions the visitor does not hold is left out by the same rules the record's own page keeps (MishkaDocument.Resources.Preparations.OpenToActor). The document a page sits in and its versions are read as the system: a page whose own lock opens for the reader is drawn inside a document whose lock does not.

What the six are

doc-workspace-list lists the documents a site publishes. doc-section draws one page of one of them — its body, its notes, the trail above it and the pages either side. doc-sidebar draws that document's whole contents as a tree, doc-toc draws what is on the page being read, and doc-switcher offers the document's versions, each leading to the same page in it. doc-section-cards draws the pages filed under one page as cards.

A version may mark one page at its top as its front page: every block reads that page at the document's own address, in place of the landing, links to it there, and files the pages under it directly under that address (MishkaDocument.Address). A page marked as a group is only a heading over the pages under it — in the contents and on the landing — with no page or address of its own: nothing links to it, Previous and Next step over it, and its pages are addressed as if it were not there. A group with a page of its own (group_page) is read at its own slug like any page — its text, its notes, its trail — and its heading in the contents, its title on the landing and its place in a trail link there; Previous and Next stop on it. The pages under it keep the address that leaves its slug out.

Under a page's title doc-section says when it last changed and the version being read, Updated Oct 14, 2026 · v1.2.0 (doc-section-updated, the date in a <time datetime>, the version in doc-section-updated-version): when the page last changed — the newest of the page, its notes and the archiving of one (MishkaDocument.Resources.Calculations.LastModified) — and its document's at the document's own address, the date its head gives as its dateModified (MishkaDocument.Plugins.PageSeo). updated_text is what it says before the version, and show_updated=false draws none.

A page's body and each of its notes are scrubbed with HtmlSanitizeEx.html5/1, the scrub a page's text is stored through, and drawn with rich_content/1, so a component the author placed in them with the content editor is drawn there as that component (MishkaCmsCore.Runtime.ContentComponents), and a code block keeps the classes it was highlighted with. The scrub is also where a link to another page of the site is made to navigate with LiveView, below.

After the scrub, doc-section draws each code block in a window: a head holding the block's language, read off its language-x class, and a <button type="button"> that copies its code, over the block as it was stored. Every element of it says what it is in its data-role — doc-code, doc-code-head, doc-code-lang, doc-code-copy, doc-code-status — for a site's stylesheet to restyle, and what the button says is the block's copy_text, copied_text and copy_label. The window is drawn, never stored: a page's text keeps only its code.

A block of more lines than code_lines (16; 8, 12, 20, 24 or 32, and 0 cuts none) is cut at that many under a fade: its window carries data-collapsible and data-collapse-lines, its <pre> an id, and a doc-code-foot under the code holds the doc-code-expand button that shows all of it, saying expand_text — %{count} is its number of lines — and then collapse_text. The copy button copies the whole code either way.

Moving between the pages of a document

A link to another page of the document being read is a <.link patch>, once the page's LiveView has joined: the LiveView stays, its handle_params/3 runs for the new address, and what the blocks draw changes in place — the contents, the page's pager and breadcrumb, the links of the version switch, the text of the page in the text's own links. These are the links of doc-sidebar, of the pager (doc-section-prev, doc-section-next) and the breadcrumb (doc-section-crumb) of doc-section, and the links in a page's text and notes that lead to another page of the same document in the version being read, written the way the contents write them. Every other link navigates: the document's front page and its landing, which are served by another page of the site than the pages under them; a page of another document, another version (?v=) or another language; and a link of a block pinned to a document by its workspace, which may sit on any page. Until the LiveView has joined every link navigates, because a patch pressed before then makes the LiveView client load the address that is open again, and a navigation loads the one pressed; the HTTP response draws data-phx-link="redirect" and the join draws data-phx-link="patch".

On a patch each block keeps what it holds of the document and reads only what the new page needs. The blocks tell a patch from another read by the address: it differs from the one they read for in the page alone, and the document, version and reader are the same.

  • doc-sidebar reads nothing. It finds the page among the pages it holds, moves aria-current and the open branch to it, and keeps the filter the reader typed. Its entries are keyed, so only the entries that change are sent.
  • doc-section reads the page and its notes, and asks document:page:extras for the page. A page the document it holds does not have — one published since — reads the document again.
  • doc-toc reads the page's text, for its headings, and its notes, and keeps the entries drawn until the new ones arrive.
  • doc-switcher reads nothing: it makes the links of the version menu and the banner again for the new page, from the pages of every version it read for the document.

The doc-sidebar-scroll and doc-page-scroll hooks (below) keep the contents in place and bring the new page's top into view. doc-sidebar's link_click is a JS command run when one of its links is pressed — JS.exec("phx-remove", to: "#drawer") closes the drawer the contents sit in, which a patch does not remount.

A link in a page's text or in one of its notes that leads to another page of the site navigates with LiveView. doc-section writes it with the attributes <.link navigate> writes, data-phx-link="redirect" and data-phx-link-state="push", so a click changes the page in place instead of loading a new one; one that leads to another page of the same document, in the version being read, is written with the attributes <.link patch> writes, data-phx-link="patch", once the LiveView has joined. It is done in the pass that scrubs the text on its way out, on what the scrub kept, so nothing takes the attributes off again, and the text is read once; a text with no link to change comes out as HtmlSanitizeEx.html5/1 writes it. The text is stored as it was written, so every page already written draws them.

A link is made live when its href is a path from the site's root — /cms, /chelekom/docs/button?v=2#props — or an address in full on the site's own host, which is written as the path it is. The path is read the way the router reads it, with its dot segments and percent-encoding resolved. It is left as it is when it is:

  • a fragment of the page being read, /docs/button#props on /docs/button, which the browser scrolls to; a fragment alone (#props) is never a page;
  • //elsewhere, a relative path, a query alone, or an address with a space, a backslash or a control character in it;
  • a file, a last segment with an extension (.pdf, .xml, .gz; a number after a dot, as in /docs/1.2.0, is none), or a route that is not a page: /admin, /api, /assets, /fonts, /images, /uploads, /webhooks, /sitemap…, /feed, /feeds, /_preview, /oban, /gql and /install;
  • a link with a target other than _self, a download, rel="external", or a data-phx-link of its own.

Every other attribute of the link is kept, in its place.

What plugins add to a page

Each time doc-section reads a page of a document it fires the document:page:extras mishka_installer event through MishkaInstaller.Event.Hook.call/3, with:

%{
  site_id: "…",
  document: %{slug: "chelekom", path: "/chelekom/docs", language: "en"},
  version: "0.0.9",
  page: %{
    slug: "breadcrumb",
    title: "Breadcrumb",
    path: "breadcrumb",
    parents: [%{slug: "navigations", title: "Navigations", group: true}]
  },
  header: [],
  tabs: []
}

page is nil on a document's landing. Its path is its address under the document and parents the pages and groups it is filed under, nearest last. A plugin appends to header and tabs and replies with the map:

use MishkaInstaller.Event.Hook, event: "document:page:extras"

def call(%{document: %{slug: "handbook"}, header: header} = state),
  do: {:reply, %{state | header: header ++ [%{kind: :text, label: "Edition", value: "2"}]}}

def call(state), do: {:reply, state}

header holds facts, drawn under the title in the order given:

  • %{kind: :badges, label: nil, items: [%{text: "v1.0", tone: :primary}]} — a tone is neutral (the default), primary, secondary, success, info, warning, danger, or any other word a site's stylesheet keys on as data-tone.
  • %{kind: :command, label: "Install", value: "mix …"} — drawn left to right, with the button a code window has.
  • %{kind: :link, label: "Source", href: "https://…", text: "View source", rel: "nofollow"} — http, https or a path on the site; an http or https address opens in a new tab. rel is optional: the link types of the <a>, space-separated words of lower-case letters and -, each drawn once and in place of the default; a word of any other shape is left out. With none, an http or https address is drawn rel="noopener noreferrer" and a path on the site with no rel.
  • %{kind: :text, label: "Package", value: "…"}.
  • %{kind: :pending, text: "…"} — a skeleton for the facts still being fetched, text for a screen reader.

tabs holds tabs, drawn after the page's own text, docs_tab_text. A tab is %{id: "props", label: "Props", blocks: [block]}, or the one block it is — %{id: "props", label: "Props", kind: :table, …}. While its data is being fetched it is %{id: "props", label: "Props", state: :not_ready, text: "…", failed_text: "…", pending: %{topic: "…"}}, drawn as a skeleton, and one whose data could not be had is %{id: "props", label: "Props", state: :failed, text: "…"}. An id is lower-case letters, digits, - and _, and never documentation; the first tab with an id is kept. A block is:

  • %{kind: :table, title: nil, description: nil, columns: [%{key: :name, label: "Name", code: true}], rows: [%{name: "color"}], empty_text: "…"} — a cell is a word (in code under a code: true column), a list of words, or a badge, %{text: "Yes", tone: :success}; nothing is a dash, and a table with no rows says empty_text.
  • %{kind: :list, title: nil, description: nil, items: [fact]} — facts, one a row.

Each tab is a button, and carries in data-href the address of the page with its id under tab_param — ?tab=props — and the page's other URL keys; the text is shown when the URL names no tab a plugin added. Every panel is drawn, the ones not shown hidden. Pressing a tab sends doc-section its own open_tab event, which changes the tab shown from what the block already holds: no navigation, no handle_params/3 of the page's LiveView, and nothing read or asked again. The doc-tabs hook, mounted on the tab bar, writes the pressed tab's address over the address bar's as the same history entry, so a reload or a shared link opens that tab. A URL that asks for another tab moves the tab shown to it, and the block drawn again with the URL it had keeps the tab pressed. Every element says what it is in its data-role: doc-extras, doc-extra-badges, doc-extra-badge, doc-extra-command, doc-extra-link, doc-extra-text, doc-extra-label, doc-extra-value, doc-tabs, doc-tab (aria-current on the one shown), doc-tab-panel, doc-extra-table, doc-extra-list, doc-extra-row, doc-extra-item, doc-extra-blank, doc-extra-pending, doc-tab-pending and doc-tab-failed.

A pending topic — on a tab, or on the answer as pending: %{topic: "…"} — is a Phoenix.PubSub topic on MishkaCmsCore.PubSub the plugin announces the end of its fetch on. Once connected, doc-section starts one task (MishkaGervaz.Messages.start_async/4) that subscribes to it, asks the event once more, and otherwise waits for the next message on it, up to pending_wait milliseconds; then it asks again and draws the answer, and waits again while it still names a topic. Nothing polls. A wait that runs out turns each tab still being fetched into its failed_text, and takes the header's skeleton away.

Every word is text the template escapes, and anything of another shape is left out. With no plugin, or an event that cannot run, the page is drawn as it was.

The hooks

doc-code-copy answers the buttons on each code window — the copy and the one that shows all of a cut block — and the one beside each command a plugin adds to the page. doc-section mounts it on its own root, outside the page's text, under the name the site's JavaScript registers it by (MishkaCmsCore.Runtime.Compilers.JavaScriptCompiler.browser_name/2), and one listener there answers every button inside, whatever render drew it. A press copies the window's code with the Clipboard API, or a textarea and execCommand("copy") where that API is missing, marks the button data-copied="true" for 1.2 seconds and puts copied_text in the window's doc-code-status live region. It writes no class and no style attribute, so a site's CSP has nothing to refuse. Installed with the components, per site.

The doc-example hook answers an example's toolbar. The component of that name mounts it on its own root, under the name MishkaCmsCore.Runtime.Compilers.JavaScriptCompiler.browser_name/2 gives it, and one listener there answers every button of the example and of its preview by its data-action and data-value: no button carries a command of its own. A press writes the choice onto the root's data-frame-*, the buttons' aria-pressed and, for a direction, the stage's dir, through the hook's JS commands, which a LiveView patch keeps, and onto the frame's <html>. Opening the preview the first time builds it into its holder — the words the holder carries, the buttons pressed as the root's data-frame-* say — and every time hands the frame its src and calls showModal/0; closing it takes the src away and sends the frame to about:blank. Once the frame's page has loaded, the hook gives its <html> the theme as data-theme and the direction as dir, and again after every choice. The frame is drawn at its device's width and as tall as the example in it or the preview, whichever is taller, through its width and height attributes — measured when its page loads, its fonts arrive, the preview's size changes and after every choice — and scaled down to the preview's width through CSSOM properties set from script, never a style attribute, so a site's CSP has nothing to refuse. A link pressed in the frame that leads to another page closes the preview and leads the page itself there. The hook writes no class and no style attribute, and no markup but the preview. The example's height is read by a ResizeObserver once the browser has laid the page out, so mounting the hook reads no layout.

The doc-sidebar-scroll hook keeps a document's contents where the reader left them. doc-sidebar mounts it on its own root, under the name browser_name/2 gives it, with the document's id in data-document. The contents scroll in the root's nearest ancestor whose overflow-y scrolls, or the root itself; a live navigation draws that box again at its top, and the hook puts it back at the position saved for that block and document in sessionStorage, then scrolls the entry marked aria-current="page" into it only when it lies outside. The position is saved at most once a frame from a passive scroll listener, and not while the block draws no entries. With no scrolling box the hook does nothing. It writes scrollTop alone: no class, no style attribute, no markup. A patch to another page of the document keeps the box and its position and moves aria-current to another entry, which the hook scrolls into the box in the same way.

The doc-page-scroll hook brings the top of a page into view when a patch draws another one. doc-section mounts it on the article it draws a page in, with the page's id as data-page. When LiveView reports a patch in its phx:navigate event and the page drawn is another since the last one, the window scrolls to the top, or to the element the address's fragment names; a back or a forward navigation is left to LiveView, which puts the window where that entry left it. It writes the window's scroll position alone.

The doc-tabs hook keeps the address bar in step with the tab pressed. doc-section mounts it on its tab bar, under the name browser_name/2 gives it; one listener there reads the pressed tab's data-href and writes it with history.replaceState/3, keeping the history state LiveView put there. It writes no class, no style attribute and no markup.

The stylesheet

doc-code-highlight colours the code blocks in a page's body. A page is stored with its code already highlighted — MishkaDocument.Resources.Changes.HighlightCode does it when the page is written — as <pre class="highlight"><code class="language-x"> with a <span> per token, and the sheet colours the token classes Makeup emits, in its Monokai style, and draws the window around each block, the button's copied state included. It draws the frame doc-example puts an example in — its head, its toolbars and their buttons, its foot — and the preview the hook builds, each part by its data-role, with Tailwind's @apply in the components layer, so a rule a site writes outside a layer wins over it. It cuts a long block and a tall example at each height their attributes offer, and draws the fade, the blur and the button over the cut, keyed on data-collapsible and data-expanded. Installed with the components, per site, as a :base stylesheet, so a site's own :custom rows still win over it.

Every one of them resolves a document the same way — slug to workspace, ?v= to a version, version to its approved pages — and always through the public reads, never by a raw id. That is what keeps a draft version out of a URL somebody typed.

What a block reads is what it draws. doc-switcher reads the approved pages of every version it offers in one read; doc-section reads a page's notes with their text only when it draws them, and the two aggregates behind last_modified_at only for a page that is not the front page, which is dated by its document; doc-toc reads a page's text only to list its headings and the page at all only to list its headings or its notes. MishkaDocument.Plugins.PageSeo reads the approved pages of the version it shows and no other's.

When a block reads in step, and when in the background

Five of the six read their FIRST answer in step with the page, on both of a visit's renders: the GET, so a crawler and a reader with no JavaScript are served the workspaces, the sidebar, the table of contents, the version switcher and the cards themselves, and the connected one, so they are drawn straight away rather than replaced by a skeleton while they are read again. Every later read runs in a task, and while it does the block draws a skeleton; when a read fails it draws a line and a button to ask again. cold?/1 still answers for the builder's canvas and the Preview, where MishkaCmsCore.Runtime.LiveViewHelpers.static_component/1 renders a component with no process at all, and reads inline there too.

Each task is started through MishkaGervaz.Messages.start_async/4, which hands it the site and the locale the page renders in (MishkaCmsCore.Runtime.LiveViewHelpers.carry_into_tasks/0), so a link doc-switcher builds there with site_path/2 is the one its template would build. doc-workspace-list starts its task with Phoenix.LiveView.stream_async/4, which has no such twin, and wraps what it runs in MishkaGervaz.Messages.in_caller_locale/1, which is what the twins do.

A list reached by a live navigation

doc-workspace-list reads its first answer in a task as well, for a reader who arrived by a live navigation: a click on a link of the site, or the browser's Back and Forward. The page tells it so with its live_visit attribute, and MishkaDocument.Plugins.LiveVisit is what gives the page the assign to hand it: a LiveView reads Phoenix.LiveView.get_connect_params/1 in its own mount/3 and a live component never, and the client sends "_live_referer" with a live navigation and not with the connection that follows a GET. The GET and that connection still draw the cards themselves, so a crawler is served them and a reader who loaded the address is not shown a skeleton; left false, live_visit leaves every read in step.

The list reads how many documents there are, and so how many pages, in step with the page on every visit — one count, which the pager, the empty line and the skeletons all hang on — and only the rows themselves wait for a task. While they do it draws one skeleton for every card the page will hold (doc-list-skeleton, in doc-list-skeletons inside doc-list-loading) and the pager, then streams the cards in behind them. Turning a page does the same, and so does asking again after a failure. A list with nothing in it says so at once.

A skeleton is drawn with the structure, padding and line heights of the card it stands for, so nothing moves when the cards arrive. The block knows its own card, with a description of two lines. A card an author named in card is a size the block cannot know, so skeleton names the component each skeleton is drawn with, in the way card does for each document: one of the site's own that draws the same card with its text left out, handed nothing. Its height cannot follow the text it replaces, so a card meant to be replaced without a shift gives its description a fixed number of lines (min-h-[5lh]) and the skeleton the same.

The cards are Phoenix.LiveView.stream/4 rows in a <.async_result>: :loading is the skeletons, :failed is doc-notice's error with its way to ask again, and the stream is drawn inside it with phx-update="stream". A read that returns an error or dies draws the error and never leaves the skeletons up.

doc-workspace-list with show_details also says, for each document, the version a reader lands on — the one marked latest, else the newest published — how many of its pages the visitor may read there, a group with no page of its own left out, every language it is published in on this site, and when it last changed: the latest of the document, that version and those pages, a page by when it last changed as above. The version and the other editions' languages are aggregates of the read that lists the documents (MishkaDocument.Workspace's current_version_* and edition_languages), and the pages one more read for the whole page of documents, all as the visitor. doc-workspace-card draws them as doc-list-meta, holding doc-list-version, doc-list-pages and doc-list-updated, and, beside the way in, doc-list-language-codes; a card an author named in card is handed them as details, a map of version, pages, languages and updated_at, which it declares as an attribute of its own.

THE LANGUAGES ARE WORDS, NEVER LINKS. The header's language menu is the one place a reader changes language, so a card has no chip leading to another edition: languages lists the languages the document has an edition in that the site publishes, in the site's own order, and the card draws them as plain badges only when there are two or more, since one language named on every card of a site that publishes in one says nothing.

doc-workspace-list streams its rows, and says phx-update="stream" only where there is a socket to stream from — @myself is nil in a cold render, and a stream container is one the client never removes a child from, so the builder's canvas would keep the cards of the previous setting beside the new ones every time the Inspector changed one.

A page of doc-workspace-list past the last one is the site's 404 when the address names it (?page=999, under page_param), as a document the address names and nobody has is; one the pager's buttons ask for is the last one there is.

doc-section is the exception, and on purpose: it is what the page IS. A skeleton where the document's text should be is a worse page than one that takes as long as the text does, and a crawler served the skeleton is served nothing. That is also the trade the other five accept: a reader with no JavaScript is served the skeleton of a tree, a table of contents and a version list, while the document's own text is in the HTTP response either way.

doc-sidebar's filter box is the other half of the same idea in reverse: it narrows the tree the block already holds, so a keystroke is a render and not a read.

Installing

MishkaDocument.Kit.install!(site_id)

Per SITE rather than globally: these components read documents, and a document belongs to a tenant. The site is baked into the compiled module's own metadata — see site_id/0 in the bundle's helpers — so a component cannot be edited into reading another site's documents.

Re-running it upserts by name, which is what makes it safe in a seed.

Updating a site that already has them

Not with this. install!/1 writes the bundle's copy over every row an admin ever edited, which is what a seed wants and what an admin does not. The "Documentation components" card on the document dashboard is the other way: MishkaCmsCore.Runtime.Handlers.UiKitUpgrade compares each installed row with the version and the fingerprint the installer stamped on it, reports what is behind and what carries an edit, and writes only what the admin chooses.

What this module is not

It installs, and that is all. There is deliberately no query helper, no render helper and nothing a component's stored code could call: the components are self-contained, and a function here would quietly become part of their contract.

Summary

Functions

The bundle's absolute path inside this extension's priv.

Installs the bundle for one site, upserting by component name.

Functions

bundle_path()

@spec bundle_path() :: String.t()

The bundle's absolute path inside this extension's priv.

install!(site_id)

@spec install!(String.t()) :: {:ok, map()} | {:error, term()}

Installs the bundle for one site, upserting by component name.

Returns whatever MishkaCmsCore.Runtime.Handlers.UiKitHandler.install_kit!/2 returns — {:ok, …} with the rows, or {:error, reason} when the file is not a bundle this CMS reads.