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:

PropertyTypeDescription
urlstringServing URL (e.g., /content-media/blog/post/hero.jpg)
filenamestringFilename without directory (e.g., hero.jpg)
media_typestringType classification: image, video, audio, document, archive, other
mime_typestringMIME content type (e.g., image/jpeg)
size_bytesintFile size in bytes
widthint/noneImage width in pixels (images only)
heightint/noneImage height in pixels (images only)
metadataobject/nonePlugin-contributed metadata (see Plugin Metadata below)

Media Type Filters

Three filters are available for selecting media by type:

FilterSelects
filter_imagesjpg, jpeg, png, gif, webp, svg, bmp, ico, avif
filter_videosmp4, webm, mov, avi, mkv, m4v
filter_documentspdf, doc, docx, txt, rtf
filter_pdfspdf only (narrower than filter_documents; matches MIME application/pdf)
{% 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:

ParameterDescriptionExample
wResize width in pixels?w=800
hResize height in pixels?h=600
fitFit mode: contain (default), cover, crop?fit=cover
fmtOutput format: jpeg, png, gif, webp?fmt=webp
qQuality 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:

ArgumentRequiredDescription
pathYesMedia URL (absolute path starting with /)
altYesAlt text for the image
sizesNoThe 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:

ArgumentRequiredDescription
widthYesTarget width in pixels
heightNoTarget height in pixels
fitNoFit 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.

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:

ArgumentTypeDefaultDescription
pathstring(required)Video file path. Absolute for shared media, relative for page-local
posterstringauto-detectPoster frame image path
controlsbooltrueShow playback controls
autoplayboolfalseAuto-play on load
loopboolfalseLoop playback
mutedboolfalseMute audio
widthstring-CSS width value
heightstring-CSS height value
page_urlstring-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 filePoster file (checked in order)
intro.mp4intro.mp4.poster.jpg
intro.mp4intro.mp4.poster.png
intro.mp4intro.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:

ArgumentTypeDefaultDescription
urlstring(required)Video URL (YouTube, Vimeo, or direct embed URL)
widthstring"560"Iframe width in pixels
heightstring"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.com to delay tracking cookies until playback
  • Vimeo URLs include ?dnt=1 (Do Not Track)
  • Unknown URLs are used as-is for direct embed links

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.