Media in Templates
Page Media
Every page has a page.media list containing metadata for non-markdown files in the same directory as the page’s content file. This enables templates to build image galleries, file download lists, and other media-driven layouts without hardcoding filenames.
Media Asset Properties
Each entry in page.media has:
| Property | Type | Description |
|---|---|---|
url | string | Serving URL (e.g., /content-media/blog/post/hero.jpg) |
filename | string | Filename without directory (e.g., hero.jpg) |
media_type | string | Type classification: image, video, audio, document, archive, other |
mime_type | string | MIME content type (e.g., image/jpeg) |
size_bytes | int | File size in bytes |
width | int/none | Image width in pixels (images only) |
height | int/none | Image height in pixels (images only) |
metadata | object/none | Plugin-contributed metadata (see Plugin Metadata below) |
Media Type Filters
Three filters are available for selecting media by type:
| Filter | Selects |
|---|---|
filter_images | jpg, jpeg, png, gif, webp, svg, bmp, ico, avif |
filter_videos | mp4, webm, mov, avi, mkv, m4v |
filter_documents | pdf, doc, docx, txt, rtf |
filter_pdfs | pdf only (narrower than filter_documents; matches MIME application/pdf) |
Image Gallery
{% set images = page.media | filter_images %}
{% if images %}
<div class="gallery">
{% for img in images %}
<figure>
<img src="{{ img.url }}" alt="{{ img.filename }}"
{% if img.width %}width="{{ img.width }}" height="{{ img.height }}"{% endif %}
loading="lazy">
<figcaption>{{ img.filename }}</figcaption>
</figure>
{% endfor %}
</div>
{% endif %}
File Downloads
{% set docs = page.media | filter_documents %}
{% if docs %}
<h3>Downloads</h3>
<ul>
{% for doc in docs %}
<li><a href="{{ doc.url }}">{{ doc.filename }}</a> ({{ doc.mime_type }})</li>
{% endfor %}
</ul>
{% endif %}
PDF Thumbnail Previews
PDFs in page.media | filter_documents get a thumbnail_url field
populated automatically when a sibling thumbnail file exists alongside
the PDF in the content directory:
content/docs/q4/
default.md
report.pdf
report.pdf.thumb.webp # WebP preferred...
report.pdf.thumb.jpg # ...then JPEG...
report.pdf.thumb.png # ...then PNG
Authors can also override which image is used per-PDF in frontmatter:
--- title: Quarterly Report pdf_thumbnails: report.pdf: quarterly-cover.jpg appendix.pdf: appendix-preview.png ---
In templates, two helpers turn that into a download card:
{# Manual rendering — full control over markup #}
{% for doc in page.media | filter_documents %}
{% if doc.thumbnail_url %}
<a href="{{ doc.url }}" class="doc-link">
<img src="{{ doc.thumbnail_url | thumbnail(200, 280) }}" alt="">
<span>{{ doc.filename }}</span>
</a>
{% else %}
<a href="{{ doc.url }}" class="doc-link">{{ doc.filename }}</a>
{% endif %}
{% endfor %}
{# One-line rendering — uses the built-in BEM-styled card.
`filter_pdfs` narrows `filter_documents` to `application/pdf`
so non-PDF documents (e.g. `.docx`) don't end up in pdf_card(). #}
{% for doc in page.media | filter_pdfs %}
{{ pdf_card(doc, width=300) }}
{% endfor %}
The pdf_thumbnail filter appends ?thumb&w=&fmt= to a PDF URL
so the server (or accent build) returns a thumbnail variant that
composes with the rest of the image-processing pipeline. The page=
argument is accepted for forward-compatibility but currently has no
effect on the output URL:
<img src="{{ doc.url | pdf_thumbnail(400) }}" alt="">
<img src="{{ doc.url | pdf_thumbnail(600, fmt='webp') }}" alt="">
<img src="{{ doc.url | pdf_thumbnail(600, page=3) }}" alt="">
When a PDF has no thumbnail source, the ?thumb URL is served by
the metadata-card backend (Phase B): the server reads the
PDF’s title and author from its Info dictionary or XMP metadata
stream and returns a self-contained SVG card. This is the default
when the pdf-thumb-card Cargo feature is compiled in (it ships
with edition-standard). When the feature is off or
media.pdf.auto_thumbnails: false, the URL falls back to a
generic SVG document icon so the page never has a broken image.
# config.yaml media: pdf: auto_thumbnails: true # metadata-card backend (default true when feature on) fallback_icon: true # generic icon when no metadata can be extracted
The card SVG composes with neither ?w= nor ?fmt=: the metadata
card is a fixed-aspect 240x320 vector image. Use a Layer 1
convention thumbnail or a Layer 2 frontmatter override when you
need raster output or a custom resolution.
Plugin-supplied thumbnails
Plugins implementing the on_media_discover hook can attach a
thumbnail_url key to a MediaAsset’s metadata map; Accent
mirrors that value onto the typed thumbnail_url field
automatically (when neither Layer 1 nor Layer 2 has set it). This
lets a plugin extract a real raster preview from a PDF (or call out
to an external service) and have it appear in templates with no
extra glue:
// In the plugin's on_media_discover handler: HashMap::from([( "thumbnail_url".to_string(), serde_json::json!("/plugin-cache/report-page1.jpg"), )])
{# Templates use plugin thumbnails the same way they use convention
thumbnails -- via `doc.thumbnail_url`. The `?thumb` URL is only
emitted when no other Layer 1/2/4 source exists. #}
{% for doc in page.media | filter_pdfs %}
{{ pdf_card(doc, width=300) }}
{% endfor %}
In markdown content, the same card is available as a shortcode:
[pdf src="report.pdf" /] [pdf src="report.pdf" title="Q4 2025 Report" thumb_width="300" /]
All Media Listing
{% if page.media %}
<h3>Attached files</h3>
<ul>
{% for asset in page.media %}
<li>
<a href="{{ asset.url }}">{{ asset.filename }}</a>
<span class="type">{{ asset.media_type }}</span>
</li>
{% endfor %}
</ul>
{% endif %}
The default theme template includes a gallery section that automatically displays page images using filter_images.
Plugin Metadata
When WASM plugins register an on_media_discover hook, they can attach arbitrary metadata to media assets during content scanning. This metadata is available in templates via asset.metadata.
Accessing Plugin Metadata
{% set images = page.media | filter_images %}
{% for img in images %}
{# BlurHash placeholder (from blurhash plugin) #}
{% if img.metadata and img.metadata.blurhash %}
<div style="background: url(data:image/svg+xml,...)" data-blurhash="{{ img.metadata.blurhash }}">
<img src="{{ img.url }}" alt="{{ img.filename }}" loading="lazy">
</div>
{% endif %}
{# EXIF camera info (from exif-extractor plugin) #}
{% if img.metadata and img.metadata.exif %}
<figcaption>
Shot with {{ img.metadata.exif.camera }}
{% if img.metadata.exif.iso %} at ISO {{ img.metadata.exif.iso }}{% endif %}
</figcaption>
{% endif %}
{% endfor %}
The metadata field is only present when at least one plugin has contributed data for that asset. Always guard access with {% if img.metadata %} to handle assets without plugin data. See the Plugins guide for configuring media plugins.
Image Processing
When image processing is enabled in config.yaml, templates gain two helpers for generating responsive, optimized images: a responsive_image() function and a thumbnail() filter.
Processing Query Parameters
Media URLs accept query parameters to request on-demand processing:
| Parameter | Description | Example |
|---|---|---|
w | Resize width in pixels | ?w=800 |
h | Resize height in pixels | ?h=600 |
fit | Fit mode: contain (default), cover, crop | ?fit=cover |
fmt | Output format: jpeg, png, gif, webp | ?fmt=webp |
q | Quality 1-100 (JPEG/WebP) | ?q=80 |
Parameters can be combined: /media/hero.jpg?w=800&h=600&fit=cover&fmt=webp&q=80
SVG files are always returned unchanged regardless of parameters.
responsive_image(path, alt, sizes?)
Generates a complete <img> tag with srcset at each configured responsive breakpoint width. The breakpoints are configured in config.yaml under media.processing.responsive_widths (default: [320, 640, 960, 1280, 1920]).
Arguments:
| Argument | Required | Description |
|---|---|---|
path | Yes | Media URL (absolute path starting with /) |
alt | Yes | Alt text for the image |
sizes | No | The sizes attribute for responsive rendering (default: 100vw) |
Usage:
{# Basic responsive image #}
{{ responsive_image("/media/hero.jpg", "Hero image") }}
{# With custom sizes attribute #}
{{ responsive_image("/media/hero.jpg", "Hero image", "(max-width: 768px) 100vw, 50vw") }}
Output:
<img src="/media/hero.jpg?w=960" srcset="/media/hero.jpg?w=320 320w, /media/hero.jpg?w=640 640w, /media/hero.jpg?w=960 960w, /media/hero.jpg?w=1280 1280w, /media/hero.jpg?w=1920 1920w" sizes="100vw" alt="Hero image" loading="lazy">
The default src uses the middle breakpoint. All images include loading="lazy" for performance.
thumbnail(width, height?, fit?)
A filter that appends processing query parameters to a media URL. Use it with media() or page.media[].url to generate on-the-fly resized versions.
Arguments:
| Argument | Required | Description |
|---|---|---|
width | Yes | Target width in pixels |
height | No | Target height in pixels |
fit | No | Fit mode (contain, cover, crop). Defaults to cover when height is given |
Usage:
{# Width-only thumbnail #}
<img src="{{ media('/photos/hero.jpg') | thumbnail(400) }}" alt="Thumbnail">
{# Fixed dimensions with cover fit #}
<img src="{{ img.url | thumbnail(200, 200) }}" alt="Square thumb">
{# Explicit fit mode #}
<img src="{{ img.url | thumbnail(300, 200, 'contain') }}" alt="Fitted thumb">
Output:
/media/photos/hero.jpg?w=400
/content-media/blog/post/photo.jpg?w=200&h=200&fit=cover
/content-media/blog/post/photo.jpg?w=300&h=200&fit=contain
image_width(path) and image_height(path)
Filters that return the dimensions (in pixels) of an image file by reading its headers. Useful for setting explicit width and height attributes on <img> tags to prevent layout shift (CLS).
These filters read only file headers (no full decode), so they are fast even for large images. Returns 0 if the file is not found or dimensions cannot be determined.
Usage:
{# Set explicit dimensions to prevent layout shift #}
<img src="/media/hero.jpg"
width="{{ "/media/hero.jpg" | image_width }}"
height="{{ "/media/hero.jpg" | image_height }}"
alt="Hero image">
{# Use in calculations #}
{% set w = "/media/photo.jpg" | image_width %}
{% set h = "/media/photo.jpg" | image_height %}
{% if w > 1200 %}
<p>This is a high-resolution image ({{ w }}x{{ h }})</p>
{% endif %}
Output:
<img src="/media/hero.jpg" width="1920" height="1080" alt="Hero image">
These filters work with shared media files (absolute paths starting with /media/). They require the media directory to be configured.
Gallery with Thumbnails
Combine page.media, filter_images, and thumbnail() for an optimized gallery:
{% set images = page.media | filter_images %}
{% if images %}
<div class="gallery-grid">
{% for img in images %}
<figure>
<a href="{{ img.url }}">
<img src="{{ img.url | thumbnail(400) }}" alt="{{ img.filename }}"
loading="lazy">
</a>
<figcaption>{{ img.filename }}</figcaption>
</figure>
{% endfor %}
</div>
{% endif %}
This serves 400px-wide thumbnails in the grid, linking to the full-size original.
Processing Configuration
media: processing: enabled: true # Toggle processing on/off jpeg_quality: 85 # Default JPEG quality (1-100) webp_quality: 80 # Default WebP quality (1-100) max_dimension: 4096 # Maximum allowed width/height responsive_widths: [320, 640, 960, 1280, 1920] # Breakpoints for responsive_image() cache: directory: ./site/.media-cache # Where processed variants are stored max_size_mb: 500 # Max cache size (LRU eviction)
Video Functions
Two functions are available for embedding video content in templates: video() for local video files and video_embed() for external platforms.
video(path, …)
Generates an HTML5 <video> element with MIME type detection and optional poster frame.
Arguments:
| Argument | Type | Default | Description |
|---|---|---|---|
path | string | (required) | Video file path. Absolute for shared media, relative for page-local |
poster | string | auto-detect | Poster frame image path |
controls | bool | true | Show playback controls |
autoplay | bool | false | Auto-play on load |
loop | bool | false | Loop playback |
muted | bool | false | Mute audio |
width | string | - | CSS width value |
height | string | - | CSS height value |
page_url | string | - | Page URL for resolving relative paths |
Usage:
{# Shared media video with defaults (controls shown) #}
{{ video("/videos/intro.mp4") }}
{# Page-local video #}
{{ video("demo.mp4", page_url=page.url) }}
{# Background hero video #}
{{ video("/videos/hero.mp4", poster="/videos/hero.poster.jpg",
muted=true, autoplay=true, loop=true, controls=false) }}
{# Video with explicit dimensions #}
{{ video("/videos/tutorial.mp4", width="800", height="450") }}
Output:
<video controls playsinline poster="/media/videos/hero.poster.jpg"> <source src="/media/videos/intro.mp4" type="video/mp4"> Your browser does not support the video tag. </video>
All videos include playsinline for mobile compatibility.
Poster Frame Convention
If no explicit poster is provided, the video() function checks the filesystem for poster files following this naming convention:
| Video file | Poster file (checked in order) |
|---|---|
intro.mp4 | intro.mp4.poster.jpg |
intro.mp4 | intro.mp4.poster.png |
intro.mp4 | intro.mp4.poster.webp |
Auto-detection works for shared media videos (absolute paths). For page-local videos, specify the poster explicitly.
video_embed(url, …)
Generates a privacy-respecting <iframe> embed for YouTube and Vimeo videos.
Arguments:
| Argument | Type | Default | Description |
|---|---|---|---|
url | string | (required) | Video URL (YouTube, Vimeo, or direct embed URL) |
width | string | "560" | Iframe width in pixels |
height | string | "315" | Iframe height in pixels |
Usage:
{# YouTube (uses youtube-nocookie.com for privacy) #}
{{ video_embed("https://www.youtube.com/watch?v=dQw4w9WgXcQ") }}
{# YouTube short URL #}
{{ video_embed("https://youtu.be/dQw4w9WgXcQ") }}
{# Vimeo (adds ?dnt=1 for privacy) #}
{{ video_embed("https://vimeo.com/123456789") }}
{# Custom dimensions #}
{{ video_embed("https://vimeo.com/123456789", width="800", height="450") }}
Privacy features:
- YouTube URLs are transformed to
youtube-nocookie.comto delay tracking cookies until playback - Vimeo URLs include
?dnt=1(Do Not Track) - Unknown URLs are used as-is for direct embed links
Video Gallery with page.media
Combine filter_videos and video() for an automatic video gallery:
{% set videos = page.media | filter_videos %}
{% if videos %}
<div class="video-gallery">
{% for vid in videos %}
<figure>
{{ video(vid.filename, page_url=page.url) }}
<figcaption>{{ vid.filename }}</figcaption>
</figure>
{% endfor %}
</div>
{% endif %}
The default theme template includes this pattern automatically.