A Grav 2.x plugin providing theme-agnostic Twig functions:
preview_panel()— a source preview panel for any page with prose links towikipedia.org/wiki/…. Clicking a link loads the article's lead section into the panel without leaving the page; wikilinks inside the loaded text chain further.vis_timeline(events, options)— a vis.js timeline driven by page frontmatter, whose item clicks populate the same panel.
The theme decides where the markup goes; the plugin owns what it does.
0.3.0 — both functions work. The .pp-timeline--wide full-bleed CSS is a per-theme
integration step, not part of this plugin (see "Full-viewport timeline" below). See
CLAUDE.md for the full implementation plan and CHANGELOG.md for what has actually
landed.
Not on GPM. Clone into a Grav install's user/plugins/:
git clone <this-repo> user/plugins/preview-panel
Note for the artificiallystupid.com deployment specifically: Git-Sync's folders: list does
not include plugins, so this never reaches the VPS automatically the way page and theme
edits do. Deploying is a manual clone/pull on that install.
Call the function from a template wherever the panel should sit:
{% if page.header.preview_panel %}{{ preview_panel() }}{% endif %}That single call also enqueues the plugin's CSS and JS — no separate asset wiring. Pages that never call it pay nothing.
preview_panel() accepts an optional options hash:
| Key | Meaning |
|---|---|
class |
Extra classes appended to pp-panel, passed through verbatim. |
id |
Overrides the configured panel_id for this one call. |
A bare string is taken as class, so {{ preview_panel('my-class') }} and
{{ preview_panel({class: 'my-class'}) }} are equivalent.
Calling it twice on one page renders one panel. The second call returns an empty string
rather than a duplicate — the panel carries a DOM id and the script binds to the first
.pp-panel it finds, so a second one would be an element the theme placed that never fills.
This is also what lets a future vis_timeline() auto-emit a panel without having to know
whether the template already emitted one.
- The theme's asset block must be
deferred. This plugin registers its stylesheet from inside a Twig function, i.e. while the page body is rendering — after a non-deferred{{ assets.css() }}in<head>has already run. A theme that renders assets without Twig'sdeferredmodifier will silently drop the panel CSS. Quark 2 is fine (partials/base.html.twig:{% block assets deferred %}). JS goes to thebottomgroup, which is rendered at the end of the document and needs no deferral. - Placement is the theme's job. The plugin sets no width, float, grid column or position. Wrap the call however the layout needs.
The plugin never declares its public custom properties — it only reads them with a
fallback to a private default. So a theme can set them at equal specificity and win,
regardless of stylesheet order (which matters: Grav's Assets manager renders a theme's
custom.css before a plugin's stylesheet).
| Property | Default | Used for |
|---|---|---|
--pp-bg |
transparent |
Panel background |
--pp-fg |
inherit |
Panel text |
--pp-border |
rgba(0,0,0,.15) / lighter in dark mode |
Panel border |
--pp-muted |
rgba(0,0,0,.55) / lighter in dark mode |
Placeholder + "no preview" text |
--pp-accent |
currentColor |
Underline on panel-loading links |
--pp-radius |
6px |
Panel corner radius |
/* in the theme's own stylesheet */
.pp-panel {
--pp-bg: var(--my-card-bg);
--pp-border: var(--my-border);
}Without any mapping the panel still renders correctly, including a dark-mode variant driven
by prefers-color-scheme and by :root[data-theme="light"|"dark"].
These names are stable across versions:
<aside id="preview" class="pp-panel" …>
<div class="pp-panel__extract">…</div> <!-- JS writes here -->
</aside>Both levels are load-bearing — the JS writes into .pp-panel__extract and does nothing if
it is missing. Links the plugin claims are tagged a.pp-source-link.
{{ vis_timeline(page.header.events, {class: 'pp-timeline--wide'}) }}events is page.header.events — a YAML list in the page's own frontmatter, not a fetched
file (user/ blocks direct .json/.yaml access, so a client-side fetch() of page data
never works here). options accepts the same class/id shape as preview_panel()
(bare string = class). id overrides the canvas element's id, useful only if a page calls
vis_timeline() more than once.
Auto-emits a panel if the page hasn't already called preview_panel() — a page calling
only vis_timeline() still gets a working panel, in either call order. Calling both is
normal and produces exactly one panel (see "Calling it twice" above).
events:
- id: prise-de-la-bastille # required, stable, unique on the page
start: 1789-07-14 # required — YYYY, YYYY-MM, or YYYY-MM-DD, quoted or not
end: 1789-07-15 # optional — presence makes it a range, not a point
content: Prise de la Bastille # required, short — rendered inside the item box
detail: > # optional prose, shown in the panel + hover tooltip
L'attaque de la forteresse-prison, symbole de l'arbitraire royal.
group: militaire # optional swimlane id — plugin never hardcodes a vocabulary,
# whatever `group` values a page's events carry become that
# page's lanes; omit on every event for a single-lane timeline
citation: 'Michelet, 1847' # optional short source citation
className: milestone # optional, per-item CSS hook for color
wikipedia: https://fr.wikipedia.org/wiki/Prise_de_la_Bastille # shorthand for {url: ...}A missing id/start/content throws with the offending event's id (or index) in the
message rather than silently dropping the event. wikipedia.title is optional — derived
from the URL when omitted. Unrecognized/absent source keys render the event's
content/detail/citation with no extract underneath, never an error.
<div class="pp-timeline {{ options.class }}">
<div class="pp-timeline__canvas" id="…"></div>
<script type="application/json" id="…-data">…</script>
<aside class="pp-panel" id="preview">…</aside> <!-- only when auto-emitted -->
</div>.pp-timeline sets no width, float, or grid column — the plugin publishes stable class
names and a caller-supplied modifier class (options.class); the host theme decides
whether and how to escape its own content column. This site's own breakout rule
(.pp-timeline--wide) lives in themes/quark2/css/custom.css, not here — see that file's
comment for the two nested max-width constraints it clears.
Admin2 → Plugins → Preview Panel, or user/config/plugins/preview-panel.yaml:
| Setting | Default | Meaning |
|---|---|---|
enabled |
true |
Plugin on/off. |
panel_id |
preview |
DOM id the panel renders with and the timeline targets. |
autoload_first_link |
true |
Fill the panel from the first previewable link on load, so prose pages don't open empty. |
sources.wikipedia.enabled |
true |
Fetch article leads from the MediaWiki API. |
sources.wikipedia.lang |
en |
Language subdomain used to build the API host. |
Other source keys (e.g. courtlistener:) are accepted and render as plain outbound links
until an adapter for them exists — authoring them now needs no later migration.
- A page with no panel is left completely alone. The assets are not enqueued at all, and even if a theme loads the script anyway it bails before tagging anything. Wikipedia links on such a page navigate normally.
- Non-article namespaces (
File:,Category:,Special:, …) are never claimed; those links stay outbound. - Leads are cached for the page's lifetime, keyed by requested title, so revisiting a link is instant and costs no second request.
sanitizeLeadHtml() is a paragraph filter, not a sanitizer. It keeps top-level <p>
children and strips sup.reference/<style>, but anything else inside a kept paragraph
survives into innerHTML. The trust assumption is "whatever Wikipedia serves, we run" —
an inline <img onerror=…> in a vandalized article would execute. That is a conscious
trade-off for en.wikipedia.org. If a future adapter targets a less-curated source, that
assumption stops holding and a real allow-list sanitizer becomes mandatory.
MIT.