9.8 KiB
Writing a Story
A Story is a long-form, hand-crafted piece with an immersive layout — distinct from an Entry, which is a quick dated post from the road (see CONCEPTS.md). Stories get a Ken Burns hero, scroll-driven sections, galleries and pull quotes.
The admin editor gives you a plain markdown textarea for the body. There is no block picker — the layout vocabulary is a set of shortcodes you type by hand. This guide is that vocabulary; the story edit form also carries a condensed copy of it on its Blocks tab, so you don't need this file open while writing.
Admin lives at /admin (the plugin slug is admin2, but the route is /admin). A story's edit URL looks like /admin/pages/edit/trips/<trip>/stories/<slug>.
Stories live at:
user/pages/01.trips/<trip>/04.stories/<slug>/story.md
04.stories/stories.md is a routable: false container — its children are aggregated onto the trip page. There is no standalone /stories view; don't create one.
1. Create the page
- Admin → Pages → add a page under the trip's Stories folder
- Set page template to story — this loads
user/themes/intotheeast/blueprints/story.yaml. You get the story-specific Content / Blocks / Location / Publishing tabs plus the inherited Options / Advanced / Security tabs - Fill in Title and Start Date (both required)
2. Upload the images first
Upload every image the story needs via the Images field at the bottom of the Content tab, before writing the body.
There is no separate Media tab — the uploader is a field on the Content tab, labelled Images. It arrives via inheritance:
story.yamldeclares'@extends': {type: default, context: blueprints://pages}, which merges in Grav's default page form.story.yamlandtrip.yamldid not originally extend it, so neither form could upload anything, which is why the demo story images had to be placed on the filesystem. Both now inherit, matchingentry.yaml.home.yamlis still standalone, deliberately — the home page has no per-page media.
Every shortcode refers to images by bare filename — the story-blocks plugin prefixes the page URL at render time (story-blocks.php:22), so you write:
image="photo-1.jpg" ✅
image="/images/photo-1.jpg" ❌ don't path it
The demo stories use a hero.jpg / photo-1.jpg / photo-2.jpg naming convention. Worth copying — it keeps the shortcodes readable.
3. Frontmatter fields
All of these come from the form tabs, so you rarely type them by hand. Listed here because the body shortcodes are not the whole story — the hero in particular is frontmatter, not a tag.
| Field | Tab | Notes |
|---|---|---|
title |
Content | Required |
date |
Content | Required. Start date |
end_date |
Content | Optional — leave blank for a single-day story |
hero_image |
Content | The hero. Filename only, from the Images field. Missing → grey placeholder, story still renders |
hero_alt |
Content | Falls back to the title if empty |
location_name, location_country |
Location | Shown in the hero meta line and the opener |
lat, lng |
Location | Decimal degrees — places the story marker on the trip map |
transport_mode |
Location | walking / bicycle / bus / train / car |
force_connect |
Location | Always draw a connector line from the previous marker |
published |
Options | Grav's standard toggle, from the inherited form |
featured |
Publishing | Show as a homepage highlight when not travelling |
The Ken Burns pan on the hero is automatic — no parameter for it.
If you hand-write frontmatter, use date: '2026-09-03'. Admin2 saves its own serialization (29-07-2026 19:51); both parse fine.
4. The body: six shortcodes
Defined in user/plugins/story-blocks/shortcodes/. Plain prose between them renders as a normal reading column — you don't need a shortcode to write paragraphs.
Wrapping tags
scrolly-section — text panels scroll over a pinned, slowly panning image. The centrepiece block.
[scrolly-section image="hero.jpg" alt="Description of the image" caption="Optional caption"]
The first panel. Scrolls into view over the image.
---
The second panel. A markdown `---` starts a new panel.
[/scrolly-section]
Panels are split on the <hr> that --- produces (story.html.twig:234). caption is optional. Under prefers-reduced-motion all panels render active with no pinning.
pull-quote — large extracted quote, optionally over a background image.
[pull-quote image="photo-1.jpg" alt="Description of the image"]
The quote itself. Markdown works in here.
[/pull-quote]
Drop image/alt entirely for the plain no-image variant.
Self-closing tags
Note the /] — these take no content.
chapter-break — full-width section transition over a background image.
[chapter-break image="photo-1.jpg" title="After Dark" number="II" alt="Description" /]
number is optional; the demo stories use roman numerals.
snap-gallery — swipeable multi-image carousel with dots.
[snap-gallery images="hero.jpg,photo-1.jpg" captions="First caption,Second caption" alts="First alt,Second alt" /]
⚠️ See the comma gotcha below.
full-bleed — single image edge-to-edge, as a visual pause.
[full-bleed image="photo-2.jpg" alt="Description" caption="Optional" credit="Optional" /]
image-caption — photo at a chosen width with caption beneath.
[image-caption image="photo-2.jpg" alt="Description" caption="Optional" credit="Optional" width="column" /]
width accepts column (default), full, bleed. Anything else falls back to column.
full-bleed and image-caption are implemented but not yet used by any story — the demos only exercise the other four.
Gotchas
snap-gallery splits on commas — captions cannot contain them
images, captions and alts are each split on , and zipped by index. A comma inside a caption shifts every caption after it.
This is already live in the demo content. 04.stories/04.florence-without-a-map/story.md has:
captions="The Arno at noon — greener than expected, the bridges older than you remember,Via dei Servi: …"
Two images, but three comma-separated pieces — so slide 1 gets "The Arno at noon — greener than expected", slide 2 gets " the bridges older than you remember", and the Via dei Servi text is silently dropped.
Use em dashes or semicolons in gallery captions. There is no escaping mechanism.
Self-closing tags need the space before /]
[chapter-break … /] — not [chapter-break …/] or [chapter-break …].
A typo'd shortcode fails silently
An unrecognised tag name or a malformed parameter list renders as literal text or vanishes — no error, no warning. Preview the page after every block; there is no in-editor validation.
--- outside a scrolly-section is just a horizontal rule
The panel-splitting behaviour only applies inside [scrolly-section].
Worked example
The best reference to copy from is user/pages/01.trips/italy-2026-demo/04.stories/01.sorano-rock-and-time/story.md — it combines scrolly-section, chapter-break and pull-quote in one story.
---
title: 'Sorano: Rock and Time'
date: '2026-09-03'
location_name: Sorano
location_country: Italy
lat: 42.683
lng: 11.715
hero_image: hero.jpg
hero_alt: Medieval town of Sorano clinging to pale tufa cliffs at dusk
published: true
---
Opening prose. Renders as a normal reading column.
[scrolly-section image="hero.jpg" alt="Sorano seen from the approach road" caption="Sorano — tufa cliff town"]
First panel over the pinned image.
---
Second panel.
[/scrolly-section]
More prose between blocks.
[chapter-break image="photo-1.jpg" title="After Dark" number="II" alt="Narrow medieval alley at dusk" /]
[pull-quote image="photo-1.jpg" alt="Stone alley lit by a single lantern"]
A town built on rock, carved from rock, returning slowly to rock.
[/pull-quote]
Publish
- Set Published on the Publishing tab
- Optionally set Featured highlight to surface it on the homepage between trips
- Push the content:
make content-push
That commits and pushes user/ to Gitea, which triggers the production pull. See deploy-cycle.md.
The Blocks tab
The story edit form has a Blocks tab holding a paste-ready example of each shortcode plus the comma warning. It's built from type: spacer fields in story.yaml, whose text admin2 renders as HTML (confirmed against admin2 2.0.12 — see its CHANGELOG entry for issue #91).
If you edit those fields, note two things: the text values are YAML double-quoted scalars, so HTML attribute quotes must be escaped (\") — and <h4> is flattened by admin2's CSS reset, which is why the headings use <strong> in a styled <p> instead. Long <pre> content needs white-space:pre-wrap, or it overflows underneath the Page Info sidebar.
Why it's a raw textarea
The admin2 story editor is a plain markdown field by design-so-far, not by limitation. Background and the options considered: docs/research/story-editing.md. Note that its conclusion that admin2 cannot host a custom editor is out of date — admin-next ships a plugin field-component surface (admin-next/fields/{type}.js + onApiBlueprintResolved) that the installed api 1.0.9 / admin2 2.0.12 support. Nobody has built it yet.