Live for Craft CMS

Templating

One include

{% include 'live/_feed.twig' with { feed: entry.commentary } %}

That renders the feed on the server for the first paint and wires up the client that keeps it current afterwards. commentary is your Live field's handle.

There is only one card

This is worth understanding before you change anything. When an update is published, Live renders it through live/_update.twig and stores the HTML in the update's snapshot. That HTML is what travels to the browser.

So an update that arrives three seconds after page load is byte-identical to one that was server-rendered — because it was server-rendered, by the same partial. There is no JavaScript template to keep in step with your Twig, so there is nothing to drift.

The consequence: a change to _update.twig applies to new updates straight away, and to updates already published when you rebuild their snapshots:

php craft live/snapshots/rebuild --all

Taking over the markup

Copy either partial into your own templates directory:

templates/live/_feed.twig      # the container, the state, the pinned update, the empty state
templates/live/_update.twig    # one update

Craft looks in your templates directory before the plugin's, so a file you provide wins and the one you didn't copy keeps working. Nothing to register, nothing to turn off.

To keep the update template somewhere else entirely, set Update template (updateTemplate) in the settings. Twig and the snapshot writer both use it, so the two can't disagree.

What the runtime needs from your markup

Rewrite as much as you like, but keep:

  • the <live-feed> element with its data-live-config attribute
  • a container marked data-live-updates holding the rendered updates
  • data-seq and data-rev on each update — they are how the client knows what it already has
  • optionally, an element marked data-live-count (kept up to date with the update count) and a button marked data-live-new (the "3 new updates" pill — see below)

A minimal container of your own:

{% set feed = entry.commentary %}

<live-feed {{ craft.live.attributes(entry) }}>
    <div data-live-updates>
        {% for update in feed.updates.limit(50).all() %}
            {% include 'live/_update.twig' with { update: update } only %}
        {% endfor %}
    </div>
</live-feed>

{% do craft.app.view.registerAssetBundle('justinholtweb\\live\\web\\assets\\feed\\FeedAsset') %}

craft.live.attributes() prints the config attribute ready-escaped, and prints nothing at all for an element without a Live field.

The feed value

entry.<handle> returns the feed:

{% set feed = entry.commentary %}

{{ feed.state }}          {# upcoming | live | paused | ended #}
{{ feed.stateLabel }}     {# translated label for the state #}
{{ feed.isLive }}
{{ feed.isFollowable }}   {# upcoming, live or paused — anything still coming #}
{{ feed.isEnded }}
{{ feed.count }}
{{ feed.seq }}            {# the current sequence number #}
{{ feed.pinned }}         {# the pinned update, or null #}
{{ feed.startedAt }}
{{ feed.endedAt }}

{% for update in feed.updates.limit(50).all() %}
    {{ update.postedAt|date('H:i') }}
    {{ update.bodyHtml }}
    {{ update.type.name }}
{% endfor %}

{% for goal in feed.updates.type('goal').all() %}…{% endfor %}
{% for key in feed.highlights.all() %}…{% endfor %}

feed.updates is an element query, so the usual limit, offset and orderBy apply, and it only returns published updates. feed.highlights is the same query narrowed to key moments.

An update

update.seqIts sequence number in the post
update.postedAtWhen it was posted
update.titleThe headline, if its type has one or a title format
update.body / update.bodyHtmlThe purified HTML, and the same marked safe for output
update.typeThe update type — name, handle, color, icon
update.pinned, update.highlightPinned, key moment
update.authorThe user who posted it
update.meta.revIts revision, bumped on every edit
update.<fieldHandle>Any field from the type's field layout

craft.live

The same ground without knowing a field handle — useful in listings and layouts:

{% set feed = craft.live.feed(entry) %}            {# or craft.live.feed(entry, 'commentary') #}
{% set goals = craft.live.updates({ type: 'goal', limit: 10 }).all() %}
{% set onNow = craft.live.posts('live') %}          {# head rows for every live post on this site #}
{{ craft.live.seq(entry) }}
{{ craft.live.isLive(entry) }}
{% set types = craft.live.types() %}
{% set goal = craft.live.type('goal') %}

craft.live.updates() is a bare update query across every post — "every goal today", a network ticker. craft.live.posts() takes a state (null for all of them) and an optional site ID.

Caching the page

Cache it. Updates arrive over the top of the HTML rather than changing it, so nothing about a publish makes the page stale in a way a reader would notice. A publish doesn't touch the entry and, by default, doesn't clear its caches.

Where you want the server-rendered part current for a first paint, feed.seq is the cache key: it changes on every publish and on nothing else.

{% cache using key "feed-#{entry.id}-#{entry.commentary.seq}" %}
    {% include 'live/_feed.twig' with { feed: entry.commentary } %}
{% endcache %}

If you render a feed with no JavaScript at all and it must be right on every request, turn on invalidateOwnerCaches and accept the cost the plugin is otherwise built to avoid.

The live-feed element

<live-feed> is a custom element with no dependencies, registered by the FeedAsset bundle that _feed.twig loads. It:

  1. reads the config attribute — head URL, current seq, state, poll interval, order and, on Pro with SSE on, the stream URL
  2. polls head.json every pollInterval seconds, with a cache-busting parameter that only changes once per interval, so every reader in the same window asks a CDN for the same URL
  3. when seq has moved, fetches the updates it is missing and inserts their pre-rendered HTML; when a rev it holds has moved, replaces that update in place; when a seq appears on the removed list, takes it down
  4. stops polling when the post ends, pauses while the tab is hidden, and backs off (up to a minute) when the origin is failing

Readers part-way down a newest-first feed are not moved under their feet. New updates wait behind the data-live-new button until they click it, which scrolls them back to the top.

Events

The element dispatches two bubbling events you can listen for:

document.addEventListener('live:update', (e) => {
    // e.detail is the update payload — seq, rev, html…
});

document.addEventListener('live:state', (e) => {
    // e.detail is the new head — e.g. e.detail.state === 'ended'
});

Use them for a scoreline, a "LIVE" badge in the site header, or analytics.