Blogroll & Reader

The blogroll plugin lets you curate a list of blogs you follow and automatically fetch their RSS/Atom feeds to create:

  • /blogroll/ - A directory of blogs you follow, organized by category
  • /reader/ - A river-of-news style page showing latest posts from all feeds

This is perfect for sharing your reading list, building community connections, or creating a personal feed reader built into your site.

Quick Start #

Add this to your markata-go.toml:

[markata-go.blogroll]
enabled = true

[[markata-go.blogroll.feeds]]
url = "https://simonwillison.net/atom/everything/"
title = "Simon Willison"
category = "Technology"

Run markata-go build and you’ll have:

  • /blogroll/ - Lists Simon Willison’s blog
  • /reader/ - Shows his latest posts

Configuration #

Basic Settings #

[markata-go.blogroll]
enabled = true                    # Enable the blogroll plugin
cache_dir = "cache/blogroll"      # Where to cache fetched feeds
cache_duration = "1h"             # How long to cache (default: 1 hour)
timeout = 30                      # HTTP request timeout in seconds
concurrent_requests = 5           # Max parallel feed fetches
max_entries_per_feed = 50         # Global default entries per feed

Pagination Settings #

The reader page supports pagination to handle large numbers of entries:

[markata-go.blogroll]
enabled = true
items_per_page = 50               # Entries per page (default: 50)
orphan_threshold = 3              # Min entries for separate page (default: 3)
pagination_type = "manual"        # "manual", "htmx", or "js"

Pagination Types:

Type Description
manual Traditional page links with full page reloads
htmx Seamless AJAX-based navigation using HTMX
js Client-side JavaScript pagination

The paginated reader generates:

/reader/              # Page 1
/reader/page/2/       # Page 2
/reader/page/3/       # Page 3
/reader/page/2/partial/   # HTMX partial for page 2

Adding Feeds #

Add feeds using the [[markata-go.blogroll.feeds]] array:

[[markata-go.blogroll.feeds]]
url = "https://example.com/feed.xml"    # Required: RSS/Atom feed URL
title = "Example Blog"                   # Optional: display name (auto-fetched if not set)
description = "A great blog about stuff" # Optional: short description
category = "Technology"                  # Optional: groups feeds on blogroll page
tags = ["python", "web"]                 # Optional: additional labels
site_url = "https://example.com"         # Optional: main website URL
image_url = "https://example.com/logo.png" # Optional: logo/icon
active = true                            # Optional: set false to disable without removing
max_entries = 50                         # Optional: override global max_entries_per_feed

Per-Feed Entry Limits #

Override the global max_entries_per_feed for individual feeds:

[markata-go.blogroll]
enabled = true
max_entries_per_feed = 50         # Global default

[[markata-go.blogroll.feeds]]
url = "https://prolific-blogger.com/feed.xml"
title = "Prolific Blogger"
max_entries = 100                 # Override: this site posts frequently

[[markata-go.blogroll.feeds]]
url = "https://micro.blog/user.xml"
title = "Micro Blog"
max_entries = 200                 # Override: many small posts

[[markata-go.blogroll.feeds]]
url = "https://infrequent-poster.com/feed.xml"
title = "Infrequent Poster"
max_entries = 10                  # Override: rarely posts, save cache space

[[markata-go.blogroll.feeds]]
url = "https://normal-blog.com/feed.xml"
title = "Normal Blog"
# Uses global default: 50 entries

Feed Configuration Reference #

Field Required Default Description
url Yes - RSS or Atom feed URL
title No Auto-fetched Display name for the feed
description No Auto-fetched Short description
category No “Uncategorized” Groups feeds together
tags No [] Additional labels for filtering
site_url No Auto-fetched Main website URL
image_url No Auto-fetched Logo or icon URL
active No true Set to false to disable
max_entries No Global default Override max entries for this feed

Custom Templates #

Override the default templates:

[markata-go.blogroll.templates]
blogroll = "blogroll.html"    # Template for /blogroll/ page
reader = "reader.html"        # Template for /reader/ page

Example: Building a Reading List #

Here’s a complete example with multiple feeds organized by category:

[markata-go.blogroll]
enabled = true
cache_duration = "2h"
max_entries_per_feed = 25

# =============================================================================
# TECHNOLOGY
# =============================================================================

[[markata-go.blogroll.feeds]]
url = "https://simonwillison.net/atom/everything/"
title = "Simon Willison"
description = "Creator of Datasette, Django co-creator, AI/LLM enthusiast"
category = "Technology"
tags = ["python", "ai", "llm", "sqlite"]
site_url = "https://simonwillison.net"

[[markata-go.blogroll.feeds]]
url = "https://jvns.ca/atom.xml"
title = "Julia Evans"
description = "Making hard things easy to understand"
category = "Technology"
tags = ["linux", "networking", "zines"]
site_url = "https://jvns.ca"

[[markata-go.blogroll.feeds]]
url = "https://danluu.com/atom.xml"
title = "Dan Luu"
description = "Deep dives into computer systems"
category = "Technology"
tags = ["systems", "performance"]
site_url = "https://danluu.com"

[[markata-go.blogroll.feeds]]
url = "https://blog.codinghorror.com/rss/"
title = "Coding Horror"
description = "Jeff Atwood on programming and human factors"
category = "Technology"
tags = ["programming", "software"]
site_url = "https://blog.codinghorror.com"

# =============================================================================
# DESIGN
# =============================================================================

[[markata-go.blogroll.feeds]]
url = "https://alistapart.com/main/feed/"
title = "A List Apart"
description = "For people who make websites"
category = "Design"
tags = ["web", "ux", "accessibility"]
site_url = "https://alistapart.com"

[[markata-go.blogroll.feeds]]
url = "https://css-tricks.com/feed/"
title = "CSS-Tricks"
description = "Tips, tricks, and techniques on using CSS"
category = "Design"
tags = ["css", "frontend"]
site_url = "https://css-tricks.com"

# =============================================================================
# PERSONAL
# =============================================================================

[[markata-go.blogroll.feeds]]
url = "https://austinkleon.com/feed/"
title = "Austin Kleon"
description = "Writer and artist"
category = "Personal"
tags = ["creativity", "writing"]
site_url = "https://austinkleon.com"

Adding Simon Willison #

Simon Willison is a prolific blogger known for creating Datasette, co-creating Django, and writing extensively about AI/LLMs. His feed URL is:

https://simonwillison.net/atom/everything/

Add him to your blogroll:

[[markata-go.blogroll.feeds]]
url = "https://simonwillison.net/atom/everything/"
title = "Simon Willison"
description = "Creator of Datasette, Django co-creator, AI/LLM enthusiast"
category = "Technology"
tags = ["python", "ai", "llm", "sqlite", "datasette"]
site_url = "https://simonwillison.net"

Simon also has topic-specific feeds if you want to subscribe to specific content:

  • https://simonwillison.net/atom/entries/ - Blog entries only
  • https://simonwillison.net/atom/links/ - Links/bookmarks only

Finding Feed URLs #

Most blogs have RSS/Atom feeds. Here’s how to find them:

  1. Look for feed icons - Usually in the header, footer, or sidebar
  2. Check common paths:
    • /feed/
    • /rss/
    • /atom.xml
    • /feed.xml
    • /rss.xml
    • /index.xml
  3. View page source - Search for application/rss+xml or application/atom+xml
  4. Use browser extensions - Feed discovery extensions can help

Common Feed URL Patterns #

Platform Feed URL Pattern
WordPress /feed/ or /feed/rss/
Ghost /rss/
Substack /feed
Medium /feed
Jekyll /feed.xml
Hugo /index.xml
dev.to /feed
GitHub Releases /releases.atom

Generated Pages #

Blogroll Page (/blogroll/) #

The blogroll page lists all feeds grouped by category:

/blogroll/
  index.html

Default layout:

  • Header with title and feed count
  • Feeds grouped by category
  • Each feed shows: title, description, post count
  • Links to the original site

Reader Page (/reader/) #

The reader page shows the latest posts from all feeds in reverse chronological order with pagination:

/reader/
  index.html              # Page 1
  partial/
    index.html            # Page 1 partial (HTMX)
  page/
    2/
      index.html          # Page 2
      partial/
        index.html        # Page 2 partial (HTMX)
    3/
      index.html
      partial/
        index.html

Default layout:

  • Header with title
  • List of recent entries (newest first)
  • Each entry shows: title, source feed, date, description
  • Links to the original article
  • Pagination navigation (when more than one page)

Deterministic ordering:

  1. Most recent publish/update date first
  2. Feed URL (ascending)
  3. Entry ID (ascending)
  4. Title (ascending)

Custom Templates #

Create custom templates for full control over the appearance.

Blogroll Template #

Create templates/blogroll.html:

{% extends "base.html" %}

{% block content %}
<main class="blogroll">
  <h1>{{ title }}</h1>
  <p class="subtitle">{{ feed_count }} blogs I follow</p>

  {% for category in categories %}
  <section class="category" id="{{ category.Slug }}">
    <h2>{{ category.Name }}</h2>
    <div class="feed-grid">
      {% for feed in category.Feeds %}
      <article class="feed-card">
        {% if feed.ImageURL %}
        <img src="{{ feed.ImageURL }}" alt="{{ feed.Title }}" class="feed-icon">
        {% endif %}
        <h3>
          {% if feed.SiteURL %}
          <a href="{{ feed.SiteURL }}" target="_blank" rel="noopener">{{ feed.Title }}</a>
          {% else %}
          {{ feed.Title }}
          {% endif %}
        </h3>
        {% if feed.Description %}
        <p class="description">{{ feed.Description }}</p>
        {% endif %}
        <div class="meta">
          <span class="post-count">{{ feed.EntryCount }} posts</span>
          <a href="{{ feed.FeedURL }}" class="feed-link" title="RSS Feed">
            <svg><!-- RSS icon --></svg>
          </a>
        </div>
      </article>
      {% endfor %}
    </div>
  </section>
  {% endfor %}
</main>
{% endblock %}

Reader Template #

Create templates/reader.html:

{% extends "base.html" %}

{% block content %}
<main class="reader">
  <h1>{{ title }}</h1>
  <p class="subtitle">Latest posts from blogs I follow</p>

  <ul class="entry-list">
    {% for entry in entries %}
    <li class="entry">
      <article>
        <h2>
          <a href="{{ entry.URL }}" target="_blank" rel="noopener">
            {{ entry.Title }}
          </a>
        </h2>
        <div class="meta">
          <span class="source">{{ entry.FeedTitle }}</span>
          {% if entry.Published %}
          <time datetime="{{ entry.Published|atom_date }}">
            {{ entry.Published|date_format:"Jan 2, 2006" }}
          </time>
          {% endif %}
          {% if entry.ReadingTime > 0 %}
          <span class="reading-time">{{ entry.ReadingTime }} min read</span>
          {% endif %}
        </div>
        {% if entry.Description %}
        <p class="description">{{ entry.Description|striptags|truncate:200 }}</p>
        {% endif %}
      </article>
    </li>
    {% endfor %}
  </ul>
</main>
{% endblock %}

Template Variables #

Blogroll Template Variables #

Variable Type Description
title string Page title (“Blogroll”)
description string Page description
feeds []ExternalFeed All feeds
categories []BlogrollCategory Feeds grouped by category
feed_count int Total number of feeds

Reader Template Variables #

Variable Type Description
title string Page title (“Reader”)
description string Page description
entries []ExternalEntry Entries for current page (newest first)
entry_count int Total number of entries across all pages
page ReaderPage Pagination information
pagination_type string Pagination type (“manual”, “htmx”, “js”)

ReaderPage Fields #

Field Type Description
number int Current page number (1-indexed)
has_prev bool True if previous page exists
has_next bool True if next page exists
prev_url string URL of previous page
next_url string URL of next page
total_pages int Total number of pages
total_items int Total number of entries
items_per_page int Entries per page
page_urls []string URLs for all pages
pagination_type string Pagination type

ExternalFeed Fields #

Field Type Description
Title string Feed title
Description string Feed description
SiteURL string Main website URL
FeedURL string RSS/Atom feed URL
ImageURL string Feed logo/icon
Category string Feed category
Tags []string Feed tags
EntryCount int Number of entries
Entries []ExternalEntry Feed entries
LastFetched *time.Time When feed was last fetched
LastUpdated *time.Time Feed’s last update date
Error string Error message if fetch failed

ExternalEntry Fields #

Field Type Description
Title string Entry title
URL string Link to full article
Description string Summary or excerpt
Content string Full content (HTML)
Author string Entry author
Published *time.Time Publication date
Updated *time.Time Last update date
Categories []string Entry categories/tags
ImageURL string Featured image
ReadingTime int Estimated reading time (minutes)
FeedURL string Source feed URL
FeedTitle string Source feed title

BlogrollCategory Fields #

Field Type Description
Name string Category name
Slug string URL-safe identifier
Feeds []ExternalFeed Feeds in this category

Caching #

The blogroll plugin caches fetched feeds to avoid hitting external servers on every build.

Cache Configuration #

[markata-go.blogroll]
cache_dir = "cache/blogroll"    # Cache directory
cache_duration = "1h"           # How long to cache feeds

Cache Behavior #

  1. On first build, all feeds are fetched and cached
  2. On subsequent builds, cached feeds are used if still valid
  3. Cache expires after cache_duration
  4. Delete cache/blogroll/ to force a fresh fetch

Cache Duration Examples #

cache_duration = "30m"    # 30 minutes
cache_duration = "1h"     # 1 hour (default)
cache_duration = "6h"     # 6 hours
cache_duration = "24h"    # 1 day
cache_duration = "168h"   # 1 week

Recommendations:

  • Development: "5m" - See changes quickly
  • Production: "1h" to "6h" - Balance freshness and build speed
  • High-traffic: "24h" or more - Reduce external requests

Error Handling #

When a feed fails to fetch, the plugin:

  1. Records the error in feed.Error
  2. Continues processing other feeds
  3. Includes the feed in the blogroll (with error indicator)
  4. Uses cached data if available

Handling Errors in Templates #

{% for feed in feeds %}
<article class="feed-card {% if feed.Error %}feed-error{% endif %}">
  <h3>{{ feed.Title }}</h3>
  {% if feed.Error %}
  <p class="error">Unable to fetch: {{ feed.Error }}</p>
  {% else %}
  <p>{{ feed.EntryCount }} posts</p>
  {% endif %}
</article>
{% endfor %}

Screenshot Service Integration #

Many RSS feed entries don’t include featured images. The fallback_image_service option lets you automatically generate preview images using screenshot services.

Configuration #

Set a URL template with {url} as a placeholder for the entry URL:

[blogroll]
enabled = true
fallback_image_service = "https://shots.so/s/{url}"

When an entry doesn’t have an image_url, markata-go will:

  1. URL-encode the entry’s URL
  2. Replace {url} in the template with the encoded URL
  3. Use the resulting URL as the fallback image

shots.so (Free) #

shots.so provides free website screenshots with no API key required:

fallback_image_service = "https://shots.so/s/{url}"

Features:

  • No registration or API key needed
  • Automatic caching of screenshots
  • Customizable viewport and device type

For custom parameters:

fallback_image_service = "https://shots.so/s/{url}?width=1200&height=630"

API Flash #

API Flash offers high-quality screenshots with customization:

fallback_image_service = "https://api.apiflash.com/v1/urltoimage?access_key=YOUR_ACCESS_KEY&url={url}&width=1200&height=630"

Features:

  • Requires API key (free tier available)
  • Extensive customization options
  • Reliable caching and CDN delivery

Get your access key from apiflash.com.

ScreenshotOne #

ScreenshotOne provides API-based screenshot generation:

fallback_image_service = "https://api.screenshotone.com/take?access_key=YOUR_ACCESS_KEY&url={url}&viewport_width=1200&viewport_height=630&image_quality=80"

Features:

  • API key required (free tier available)
  • Advanced rendering options
  • GDPR compliant

Get your access key from screenshotone.com.

URL Encoding #

The {url} placeholder is automatically URL-encoded, so you don’t need to manually encode it. For example:

Entry URL: https://example.com/blog/post?id=123&ref=home
Encoded:   https%3A%2F%2Fexample.com%2Fblog%2Fpost%3Fid%3D123%26ref%3Dhome
Result:    https://shots.so/s/https%3A%2F%2Fexample.com%2Fblog%2Fpost%3Fid%3D123%26ref%3Dhome

Rate Limits and Caching #

Important considerations:

  1. Rate Limits - Most screenshot services have rate limits on their free tiers. If you have many feeds with many entries, you may hit these limits during builds.

  2. Build Time - Screenshot services generate images on-demand, which can slow down your first build. Subsequent builds are faster as services cache screenshots.

  3. Service Caching - Screenshots are typically cached by the service, so the same URL will return quickly on subsequent requests.

  4. Local Caching - Consider the blogroll plugin’s cache_duration setting to reduce build frequency and screenshot service requests:

[blogroll]
cache_duration = "6h"    # Reduces rebuild frequency
fallback_image_service = "https://shots.so/s/{url}"
  1. Cost Considerations - For high-traffic sites or large blogrolls, consider:
    • Using services with generous free tiers (like shots.so)
    • Limiting max_entries_per_feed to reduce screenshot requests
    • Increasing cache_duration to minimize rebuilds
    • Upgrading to paid tiers for higher rate limits

Example Configuration #

[blogroll]
enabled = true
cache_duration = "6h"              # Cache for 6 hours to reduce rebuilds
max_entries_per_feed = 20          # Limit entries to reduce screenshot requests
fallback_image_service = "https://shots.so/s/{url}?width=1200&height=630"

[[blogroll.feeds]]
url = "https://simonwillison.net/atom/everything/"
title = "Simon Willison"
category = "Technology"

With this configuration, entries without featured images will automatically get preview screenshots from shots.so.

Performance Tips #

Optimize Build Times #

  1. Increase cache duration for production builds
  2. Limit max_entries_per_feed if you only need recent posts
  3. Reduce concurrent_requests if you’re hitting rate limits
  4. Disable inactive feeds with active = false instead of removing them

Example Production Config #

[markata-go.blogroll]
enabled = true
cache_dir = "cache/blogroll"
cache_duration = "6h"           # Cache for 6 hours
timeout = 15                    # Shorter timeout
concurrent_requests = 3         # Be nice to servers
max_entries_per_feed = 20       # Only recent posts

Configuration Reference #

Full Configuration #

[markata-go.blogroll]
# Enable/disable the entire feature
enabled = true

# Cache settings
cache_dir = "cache/blogroll"
cache_duration = "1h"

# HTTP settings
timeout = 30
concurrent_requests = 5
max_entries_per_feed = 50

# Pagination settings
items_per_page = 50
orphan_threshold = 3
pagination_type = "manual"    # "manual", "htmx", or "js"

# Screenshot service for fallback images
fallback_image_service = ""   # Optional URL template for entries without images

# Custom templates
[blogroll.templates]
blogroll = "blogroll.html"
reader = "reader.html"

# Feeds
[[markata-go.blogroll.feeds]]
url = "https://example.com/feed.xml"
title = "Example"
description = "Description"
category = "Category"
tags = ["tag1", "tag2"]
site_url = "https://example.com"
image_url = "https://example.com/logo.png"
active = true
max_entries = 50              # Override global max_entries_per_feed

Configuration Fields #

Field Type Default Description
enabled bool false Enable blogroll plugin
cache_dir string "cache/blogroll" Cache directory
cache_duration string "1h" Cache TTL (Go duration)
timeout int 30 HTTP timeout in seconds
concurrent_requests int 5 Max parallel fetches
max_entries_per_feed int 50 Global max entries per feed
items_per_page int 50 Entries per reader page
orphan_threshold int 3 Min entries for separate page
pagination_type string "manual" Pagination style
fallback_image_service string "" URL template for fallback images
feeds []Feed [] List of feeds
templates.blogroll string "blogroll.html" Blogroll template
templates.reader string "reader.html" Reader template

Fast Builds During Development #

When working on large sites with many blogroll feeds, fetching external feeds on every build can significantly slow down development. You can use environment variables to temporarily disable the blogroll for faster builds:

Disable Blogroll via Environment Variable #

# Build without fetching blogroll feeds
MARKATA_GO_BLOGROLL_ENABLED=false markata-go build

# Or combine with glob patterns to build just one post
MARKATA_GO_GLOB_PATTERNS="posts/my-draft.md" MARKATA_GO_BLOGROLL_ENABLED=false markata-go build

Using a Local .env File #

Create a .env.local file (and add it to .gitignore):

# .env.local
MARKATA_GO_BLOGROLL_ENABLED=false

Then source it before building:

source .env.local && markata-go build

When to Use Fast Builds #

Use fast builds when:

  • Writing or editing posts (blogroll content doesn’t affect your writing)
  • Testing template changes
  • Running local development server
  • Debugging build issues

Use full builds when:

  • Deploying to production
  • Testing blogroll template changes
  • Verifying complete site output

Build Time Impact #

Blogroll fetch times depend on:

  • Number of feeds configured
  • Feed response times
  • concurrent_requests setting (default: 5 parallel)
  • Cache hit rate

For a blogroll with 20 feeds, expect:

  • First build: 5-15 seconds (fetching all feeds)
  • Cached build: 0.5-2 seconds (using cached feeds)
  • Disabled: 0 seconds (blogroll plugin skipped entirely)

Next Steps #


See Also #