Components inside content: a marker an author writes into stored HTML — a post's body — drawn as the runtime component it names. WordPress calls these shortcodes; Joomla, content plugins.
[[component chelekom-alert title="Heads up"]]Text, <b>with</b> HTML[[/component]]
[[component chelekom-badge size="small" /]]A template draws such content with rich_content/1, imported into every runtime template — see
MishkaCmsCore.Runtime.LiveViewHelpers.rich_content/1:
<.rich_content
id={"#{@id}-body"}
html={@body}
site={site_name()}
current_user={@current_user}
/>The marker
[[component NAME ATTRS]]INNER[[/component]]—INNERis HTML, and becomes the component'sinner_blockslot when the component declares one.[[component NAME ATTRS /]]— no inner content.NAMEis letters, digits,-and_. It names a component of the site being drawn, or a global one when the site has none of that name.- Each of
ATTRSiskey="value",key='value',key=valueor a barekey, which istrue. A quote may arrive as an entity —",",',','— and a value is entity-decoded. A value cannot hold both kinds of quote: the sanitizer content is stored through decodes every quote entity back into the quote itself. - Whitespace is spaces, tabs, line breaks and the non-breaking space, written or as an entity.
A marker is found only in text: never inside a tag, and never inside <pre> or <code>, so a
page can show the syntax. A marker that does not parse, an opening marker nothing closes and a
closing marker nothing opened are left in the text as they are.
Inside a component
INNER may hold markers of its own, drawn inside the component — a timeline holding its sections,
a card holding its title:
[[component chelekom-timeline]]
[[component chelekom-timeline-section title="Started" /]]
[[component chelekom-timeline-section title="Shipped" /]]
[[/component]]and, for a component that declares named slots, a slot marker for each entry it takes:
[[component chelekom-video]][[slot source src="/intro.mp4" type="video/mp4" /]][[/component]]
[[slot item title="Question"]]The answer, as <b>HTML</b>[[/slot]]A slot marker is read with the same rules as a component's, belongs to the component it sits directly inside, and is passed as that slot when the component declares it — its attributes cast against the slot's own. A slot marker anywhere else is text. Markers are drawn eight levels deep; the content of a deeper one is drawn as it is written.
What a component hands its content — render_slot(@inner_block, value), or a slot entry's
render_slot(entry, value) — reaches each marker inside that content, at any depth, as
runtime:render:before_content_component's slot_arg meta. A marker outside the component never
sees it, and a component inside that hands nothing (render_slot(@inner_block)) passes on the one
it was given.
A paragraph holding one marker and nothing else — whitespace and non-breaking spaces aside — is
drawn as the component alone, without the <p> an editor wraps a line in: a block inside a <p>
is HTML a browser takes apart. A marker sharing its paragraph with other text keeps it.
What is drawn
Only a component the request's site may render: active, and the site's own or a global one — the
rule MishkaCmsCore.Runtime.LiveViewHelpers.component/1 holds every template to. The name is
first checked, as a string, against the active components SiteState knows the site and the
global scope have: a name an author typed that names none of them makes no module name, no atom
and no remembered failure. A name that resolves to nothing, an inactive component, another site's,
one that draws content itself (it could draw the content that holds it, forever), one missing an
attribute it requires, and one whose list or map value cannot be read (see Lists and maps) are
drawn as nothing. The same render in a preview (MishkaCmsCore.Runtime.StaticHelpers) draws a
notice saying why in their place. On a page, one that passes every check and then raises while it
draws is drawn as a small box, and the content around it draws — see
MishkaCmsCore.Runtime.ComponentBoundary.
A component is handed what <.component> in a template would hand it: the attributes the marker
writes, and for one it leaves out the default the component declares, or nothing at all when it
declares none, as Phoenix leaves it. A clause matching %{navigate: _} is taken only when the
marker writes navigate, and a template reads an attribute with no default as assigns[:label],
never @label.
Only the attributes the component declares are read from the marker, each cast to its declared
type: string and any as written, boolean from true/false or a bare key, integer and
float from a number, list and map from JSON (see Lists and maps), and anything that declares
values only as one of them — which is the only way an atom is taken. id, site, component_name, current_user, rest and any phx- name are never an
author's. A stateful component, and one declaring id, gets the id "<id>-<n>", n counting
the content's markers from 0 — "<id>-<n>-<m>" for the mth inside it — so it stays the same
component across renders.
A component declaring a global attribute (attr :rest, :global, include: ~w(disabled circle))
takes, through it, the HTML attributes that attribute names and a few every element has: each
name in its include list, aria-*, data-*, and accesskey, autocapitalize,
autocorrect, autofocus, dir, draggable, enterkeyhint, hidden, inert, inputmode,
lang, role, spellcheck, tabindex, title and translate. A bare key or "true" is
true, "false" leaves it out, and anything else is the text written. Never an event handler
(on…), style, a phx- or data-phx- name, data-method, data-to, data-csrf, method,
csrf_token or a name the component declares, and a link or source (href, src, action,
formaction, poster, cite, ping) only when it is a path or an http, https, mailto or
tel address:
[[component my-video controls muted]][[slot source src="/intro.mp4" /]][[/component]]
[[component my-button circle disabled aria-label="Scan"]][[/component]]A slot the component declares and the content does not fill is passed empty, and one it requires draws the component as nothing — as a required attribute does. A slot's attribute the marker leaves out is passed as its declared default, and an entry with nothing inside it draws nothing where the component renders its content.
The HTML around the markers is drawn as it is given: pass only HTML that was sanitized.
Lists and maps
An attribute declared list takes a JSON array, and one declared map a JSON object, written in
single quotes around JSON's own double ones:
[[component chelekom-headless-tree nodes='[{"value":"src","label":"src","children":[{"value":"app","label":"app.ex"}]}]' /]]
[[component chelekom-headless-sparkline values='[3,5,2,8,6]' /]]
[[component chelekom-headless-segmented-control options='["Day","Week","Month"]' value="Week" /]]
[[component chelekom-headless-countdown seconds="93784" show_labels labels='{"days":"d","hours":"h"}' /]]An any attribute that declares no values takes an array or an object the same way, and every
other value as it is written: option='{"series":[]}' is a map, option="{draft" the text.
The value reaches the component as data: strings, numbers, true, false and null as Elixir's
own, an array as a list, an object as a map. Nothing in it is run, and no string in it becomes a
module or an atom. An object's keys are atoms when it is a record — an object inside an array,
such as a tree's node read as node.label, and the object of an attribute whose declared default
is a map with atom keys. Any other object keeps its keys as written: a lookup such as a
countdown's labels, read by the strings its units hold. A key becomes an atom only when the VM
already has that atom — as it has every key the component's code reads — and a key starting with
__ is always a string.
A value is at most 32 KB as written, and 8 levels deep, each array and object a level: a tree's
node is two. A ' inside the JSON is written \u0027, and &, < and >, as in any value, as
entities. A list or map value that is not JSON of its kind, that is larger or deeper, or that
is a bare key draws the component as nothing, and a preview says which attribute and why. An
any value is then handed as it is written.
Cost
Content holding no [[component is drawn as it is, and nothing is parsed. A marker asks only what
the runtime keeps in memory: SiteState for the site and its component names, then the loaded
code table for the compiled module. The first render of a component nothing has built yet builds
it, once, as component/1 does.
Styles
A site's stylesheet is built from the templates it reaches
(MishkaCmsCore.Runtime.Compilers.CssClosure), and the components content names are in no
template. Whoever stores content answers runtime:compile:content_components with the names
its content uses — see MishkaCmsCore.Runtime.Compilers.TailwindCompiler.content_components/1 —
and a template that draws content reaches exactly those. names/1 reads them out of content, to
store beside it when it is written.
The icons content names are in no template either: a marker's icon="hero-inbox" reaches the
component as an expression, and a <span class="hero-inbox"> in the HTML is never scanned. The
same event answers them as icons — see
MishkaCmsCore.Runtime.Compilers.TailwindCompiler.content_icons/1 — and icons/1 reads them out
of content.
Nor are the classes content writes: a <div class="bg-[#282828]"> in the HTML, a marker's
class, its *_class attributes, and the attributes its component takes a class string in. The
same event answers them as classes — see
MishkaCmsCore.Runtime.Compilers.TailwindCompiler.content_classes/1 — and site_classes/2 reads
them out of content.
The editor
The admin's content editor holds content as segments/1 splits it: the HTML it edits, and each
component standing alone as a block it shows drawn. The block writes its marker back as a
paragraph of its own, so content saved from the editor stores what it was opened with. Its
palette is palette/2.
Events
runtime:render:before_content_component— before each embedded component is drawn, with%{component_name: name, site: site, attrs: attrs, inner: inner_html | nil}and%{site_id: site_id, index: n, marker_attrs: written, slot_arg: arg, slot_args: args}as meta.attrsis atom-keyed and already cast;marker_attrsare the attributes as the marker writes them, string-keyed and uncast.slot_argis the value the nearest component around the marker hands its content (nilwhen none does), andslot_argsevery such value, nearest first. A plugin answers the map with otherattrsorinnerto change what is drawn — an attribute taken out ofattrsis not handed at all, for a component's own clause to fill, as a form field'snamefrom itsfield— orsuppress: trueto draw nothing.runtime:component:crashed— a marker's component raised while it was drawn, and a box was drawn in its place; seeMishkaCmsCore.Runtime.ComponentBoundary.runtime:content:editor_palette— before the content editor's palette is shown, with%{site_id: site_id, components: entries}(embeddable/2's entries) and%{current_user: user}as meta. A plugin answers the map with othercomponentsto narrow or extend what an author may place; the editor places only what the palette lists.
Summary
Types
An attribute a marker can set, as embeddable/2 lists it: group is the heading the page
builder's Inspector files it under, untranslated, and media whether it holds a picture or a
video.
A piece of content: HTML drawn as it is, or a marker.
A component an author may embed, as embeddable/2 lists it.
A piece of content as the editor holds it: HTML, or a component standing alone. marker is the
marker as written, source the bytes it was cut from, the paragraph around it included.
Which clause of each of a component's helpers draws it: one list of clauses per helper, in order,
each the attributes and the values it matches — %{} matching any — or :unknown when it matches
on something no attribute holds; and the attributes whose every use in the template is through
those helpers.
Functions
The attributes of given a component declaring declared takes, cast to their declared types.
The palette heading a component is listed under — "text", "media", "layout", "buttons",
"data", "disclosure", "feedback", "navigation", "overlays", "forms", a kit's own
type, or "other" — from its kit type and the words of its name.
A palette heading's name, in the reader's language.
Whether token could be a class: at most 512 bytes — an arbitrary value such as
mask-linear-[to_bottom,transparent_0,black_min(40px,var(--edge)),…] runs past a hundred —
printable ASCII holding a letter, its brackets and parentheses balanced, and none of ", \,
{, }, <, ; or a backtick in it.
The class tokens html names, each once — what a resource stores beside its content when it is
written, for the site's stylesheet. The HTML's own class lists come first, then the markers'
Draws assigns.html with its markers drawn as components — the body of rich_content/1.
Whether a component template draws content itself — calls rich_content. Such a component is
never drawn inside content, which could hold a marker for it.
The components an author may embed on the site site_id, with the attributes a marker can set —
what the editor's picker offers. nil is the global components alone.
The palette entry for a component row — a stored component, or anything with its fields — as
embeddable/2 lists it.
The icon classes html names, each once, in the order they first appear — a marker's attribute
([[slot item icon="hero-inbox"]]) and the HTML's own classes (<span class="hero-inbox">) alike
— what a resource stores beside its content when it is written, for the site's stylesheet.
Whether the attribute name of the palette entry draws nothing with the other attributes at
values — the marker's, the rest at their defaults: the values of the other attributes that pick
a clause of every helper it goes to that matches all of its own values alike, or nil when it
draws something, or when that cannot be told.
The marker for component name with attrs and inner content — what the editor's Insert writes.
The component names the markers in html name, each once, in the order they first appear — those
inside other markers too — what a resource stores beside its content when it is written, for the
site's stylesheet.
The attributes each component names names takes a class string in — one declaring examples
and no values, a string or an any — as classes/2 takes them. Read off the active
components of the site site_id and the global ones, a site's own hiding the global one of the
same name, as the system: the stylesheet is the same whoever wrote the content.
The components the content editor's palette offers on the site site_id, read as actor —
embeddable/2, as a plugin answering runtime:content:editor_palette leaves it.
Splits html into the HTML drawn as it is and the markers in it, in order.
One component marker read apart into a tree/0 — what the content editor's settings edit.
:error when marker is not exactly one component marker.
Splits html into the HTML the content editor edits and the components standing alone in it,
which it holds as blocks.
The class tokens html names on the site site_id — classes/2, with the attributes the
components it names take a class string in (open_attrs/2).
The slot marker for the slot name with attrs and inner content, written as marker/3 writes
a component's.
categories' names in the order the palette shows them: text first, the ones above in their
order, a kit's own after them by name, and Other last.
The classes a preview's notice is drawn with — a marker that draws nothing, or a component that could not be drawn — for whatever builds the stylesheet a preview is drawn with.
Types
@type attr_entry() :: %{ name: String.t(), type: String.t(), values: [term()], default: term(), doc: String.t() | nil, required: boolean(), group: String.t(), media: boolean() }
An attribute a marker can set, as embeddable/2 lists it: group is the heading the page
builder's Inspector files it under, untranslated, and media whether it holds a picture or a
video.
A piece of content: HTML drawn as it is, or a marker.
@type entry() :: %{ name: String.t(), description: String.t() | nil, type: String.t() | nil, stateful: boolean(), global: boolean(), inner_block: boolean(), category: String.t(), examples: [String.t()], furnishing: MishkaCmsCore.Builder.Furnishing.t() | nil, switches: switches(), attrs: [attr_entry()], slots: [ %{ name: String.t(), doc: String.t() | nil, required: boolean(), inner: boolean(), attrs: [attr_entry()] } ] }
A component an author may embed, as embeddable/2 lists it.
@type marker() :: %{ name: String.t(), attrs: %{required(String.t()) => String.t() | true}, inner: String.t() | nil, index: non_neg_integer() }
One marker, as parse/1 found it.
@type segment() :: {:html, String.t()} | {:component, %{ name: String.t(), attrs: %{required(String.t()) => String.t() | true}, inner: String.t() | nil, marker: String.t(), source: String.t() }}
A piece of content as the editor holds it: HTML, or a component standing alone. marker is the
marker as written, source the bytes it was cut from, the paragraph around it included.
@type switches() :: %{ helpers: [[%{required(String.t()) => [String.t()]} | :unknown]], through: [String.t()] }
Which clause of each of a component's helpers draws it: one list of clauses per helper, in order,
each the attributes and the values it matches — %{} matching any — or :unknown when it matches
on something no attribute holds; and the attributes whose every use in the template is through
those helpers.
@type tree() :: %{ name: String.t(), attrs: %{required(String.t()) => String.t() | true}, parts: [ text: String.t(), component: tree(), slot: %{name: String.t(), attrs: map(), parts: list()} ] }
One component marker read apart, as read/1 gives it and write/1 takes it: its name, its
attributes, and what it holds, in order — text, the components inside it, and its slot entries.
Functions
@spec cast([map()], %{required(String.t()) => String.t() | true}) :: %{ required(atom()) => term() }
The attributes of given a component declaring declared takes, cast to their declared types.
declared is the component's attribute declarations, as __component_information__/1 or the
row holds them; given is a marker's attrs. What is not declared, cannot be cast, or is one of
the names an author never sets is left out. The HTML attributes a global attribute takes are
gathered under its name, as the module documentation says.
cast([%{name: "count", type: "integer"}, %{name: "open", type: "boolean"}],
%{"count" => "3", "open" => true, "phx-click" => "boom"})
#=> %{count: 3, open: true}
cast([%{name: "rest", type: "global", opts: %{"include" => ["circle"]}}],
%{"circle" => true, "aria-label" => "Scan", "onclick" => "boom"})
#=> %{rest: %{:circle => true, "aria-label" => "Scan"}}
cast([%{name: "nodes", type: "list"}, %{name: "labels", type: "map"}],
%{"nodes" => ~s([{"label":"src"}]), "labels" => ~s({"days":"d"})})
#=> %{nodes: [%{label: "src"}], labels: %{"days" => "d"}}
The palette heading a component is listed under — "text", "media", "layout", "buttons",
"data", "disclosure", "feedback", "navigation", "overlays", "forms", a kit's own
type, or "other" — from its kit type and the words of its name.
category("chelekom-h2", "general") #=> "text"
category("chelekom-video", "media") #=> "media"
category("acme-hero", "marketing") #=> "marketing"
A palette heading's name, in the reader's language.
Whether token could be a class: at most 512 bytes — an arbitrary value such as
mask-linear-[to_bottom,transparent_0,black_min(40px,var(--edge)),…] runs past a hundred —
printable ASCII holding a letter, its brackets and parentheses balanced, and none of ", \,
{, }, <, ; or a backtick in it.
class?("bg-[#282828]")
#=> true
class?("{brace}")
#=> false
@spec classes(String.t() | nil, %{required(String.t()) => [String.t()]}, %{ required(String.t()) => map() }) :: [String.t()]
The class tokens html names, each once — what a resource stores beside its content when it is
written, for the site's stylesheet. The HTML's own class lists come first, then the markers':
- every
class="…"of an HTML tag, entities decoded; - a component's or a slot's
classattribute, and every attribute named*_class; - a component's attribute
opennames for it —%{"chelekom-badge" => ["size", "variant"]}, whatopen_attrs/2reads off the components a site reaches: an attribute that takes one of its examples or a class string of the author's own; - any other attribute of a component or a slot whose value is nothing but utilities — every
token holding a
-,:or[, and none an icon:height="h-16",relative="md:relative". Of a componentdeclaredholds the declaration of —%{"my-badge" => %{attrs: [...], slots: [...]}}, whatsite_classes/2reads off the components a site reaches — and of its slots, only an attribute that declaration takes the value in (cast/2): a value outside an attribute'svaluesnever reaches the component.
A token is kept when it could be a class (class?/1). At most 1000 are kept.
classes(~s(<p class="text-sm bg-[#282828]">x</p>[[component chelekom-badge size="py-2 px-4" class="ml-1" /]]),
%{"chelekom-badge" => ["size"]})
#=> ["text-sm", "bg-[#282828]", "py-2", "px-4", "ml-1"]
@spec draw(map(), :live | :static) :: Phoenix.LiveView.Rendered.t()
Draws assigns.html with its markers drawn as components — the body of rich_content/1.
mode is :live where LiveView runs the result, and :static where it is rendered to a string:
a stateful component is then drawn cold, and a marker that draws nothing says why.
Whether a component template draws content itself — calls rich_content. Such a component is
never drawn inside content, which could hold a marker for it.
The components an author may embed on the site site_id, with the attributes a marker can set —
what the editor's picker offers. nil is the global components alone.
Active components of the site and global ones, every one of them, read as actor through
:for_tenant_with_globals; a site's own component hides the global one of the same name, as it
does when drawn. Components that draw content themselves are left out, and so are those requiring
an attribute the editor has no field for — a list or a map, which a marker is written with by
hand (see Lists and maps), or a struct — and every such attribute, and every one cast/2 would
drop.
The palette entry for a component row — a stored component, or anything with its fields — as
embeddable/2 lists it.
The icon classes html names, each once, in the order they first appear — a marker's attribute
([[slot item icon="hero-inbox"]]) and the HTML's own classes (<span class="hero-inbox">) alike
— what a resource stores beside its content when it is written, for the site's stylesheet.
A class is found by the prefixes MishkaCmsCore.Runtime.PageBuilder.Icons registers
(Icons.classes_in/1), and kept only when a registered set ships it (Icons.known?/1): a
hero-section in the text, or a prefix nothing registers, is left out.
icons(~s([[slot item icon="hero-cloud" /]]<span class="hero-inbox size-4"></span>hero-nope))
#=> ["hero-cloud", "hero-inbox"]
Whether the attribute name of the palette entry draws nothing with the other attributes at
values — the marker's, the rest at their defaults: the values of the other attributes that pick
a clause of every helper it goes to that matches all of its own values alike, or nil when it
draws something, or when that cannot be told.
inert(blockquote, "color", %{"variant" => "base"})
#=> %{"variant" => "base"}
The marker for component name with attrs and inner content — what the editor's Insert writes.
A blank value is left out, so the component's own default applies; a blank inner writes the
self-closing form. Attributes are written in name order, &, < and > in a value as entities.
A value holding both kinds of quote has its double quotes written as single ones, since stored
content cannot carry an escaped quote. nil when name, or a key, could not be read back as
one, or is a key no author sets.
marker("chelekom-alert", %{"title" => "Heads up", "size" => ""}, "Read <b>this</b>")
#=> ~s([[component chelekom-alert title="Heads up"]]Read <b>this</b>[[/component]])
The component names the markers in html name, each once, in the order they first appear — those
inside other markers too — what a resource stores beside its content when it is written, for the
site's stylesheet.
names(~s([[component chelekom-alert /]]<p>x</p>[[component chelekom-card]][[component chelekom-alert /]][[/component]]))
#=> ["chelekom-alert", "chelekom-card"]
The attributes each component names names takes a class string in — one declaring examples
and no values, a string or an any — as classes/2 takes them. Read off the active
components of the site site_id and the global ones, a site's own hiding the global one of the
same name, as the system: the stylesheet is the same whoever wrote the content.
open_attrs(["chelekom-badge"], site_id)
#=> %{"chelekom-badge" => ["size", "variant", "rounded"]}
The components the content editor's palette offers on the site site_id, read as actor —
embeddable/2, as a plugin answering runtime:content:editor_palette leaves it.
Splits html into the HTML drawn as it is and the markers in it, in order.
parse(~s(<p>Hi</p>[[component chelekom-badge size="small" /]]))
#=> [
#=> {:html, "<p>Hi</p>"},
#=> {:component, %{name: "chelekom-badge", attrs: %{"size" => "small"}, inner: nil, index: 0}}
#=> ]Content with no [[component in it is one {:html, html}, found with a single binary match. Only
the outermost markers are pieces: a marker inside another is part of that one's inner.
One component marker read apart into a tree/0 — what the content editor's settings edit.
:error when marker is not exactly one component marker.
read(~s([[component chelekom-card]][[component chelekom-card-title title="Hi" /]]Text[[/component]]))
#=> {:ok, %{name: "chelekom-card", attrs: %{}, parts: [
#=> {:component, %{name: "chelekom-card-title", attrs: %{"title" => "Hi"}, parts: []}},
#=> {:text, "Text"}
#=> ]}}
Splits html into the HTML the content editor edits and the components standing alone in it,
which it holds as blocks.
A marker stands alone when its paragraph holds nothing else — the marker parse/1 draws without
the <p> — or when it sits inside no element at all. A marker sharing its paragraph with text
stays in the HTML around it. Each component's source is what it was cut from, so the pieces'
HTML and the components' source, joined in order, are html again.
segments(~s(<p>Hi</p><p>[[component chelekom-badge size="small" /]]</p>))
#=> [
#=> {:html, "<p>Hi</p>"},
#=> {:component, %{name: "chelekom-badge", attrs: %{"size" => "small"}, inner: nil,
#=> marker: ~s([[component chelekom-badge size="small" /]]),
#=> source: ~s(<p>[[component chelekom-badge size="small" /]]</p>)}}
#=> ]
The class tokens html names on the site site_id — classes/2, with the attributes the
components it names take a class string in (open_attrs/2).
The slot marker for the slot name with attrs and inner content, written as marker/3 writes
a component's.
slot_marker("source", %{"src" => "/a.mp4", "type" => "video/mp4"}, nil)
#=> ~s([[slot source src="/a.mp4" type="video/mp4" /]])
categories' names in the order the palette shows them: text first, the ones above in their
order, a kit's own after them by name, and Other last.
The marker for tree, with every component and slot inside it — read/1 the other way. nil
when a name or a key in it could not be read back (marker/3).