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 onlyhttps://simonwillison.net/atom/links/- Links/bookmarks only
Finding Feed URLs ¶ #
Most blogs have RSS/Atom feeds. Here’s how to find them:
- Look for feed icons - Usually in the header, footer, or sidebar
- Check common paths:
/feed//rss//atom.xml/feed.xml/rss.xml/index.xml
- View page source - Search for
application/rss+xmlorapplication/atom+xml - 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:
- Most recent publish/update date first
- Feed URL (ascending)
- Entry ID (ascending)
- 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 ¶ #
- On first build, all feeds are fetched and cached
- On subsequent builds, cached feeds are used if still valid
- Cache expires after
cache_duration - 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:
- Records the error in
feed.Error - Continues processing other feeds
- Includes the feed in the blogroll (with error indicator)
- 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:
- URL-encode the entry’s URL
- Replace
{url}in the template with the encoded URL - Use the resulting URL as the fallback image
Popular Screenshot Services ¶ #
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:
-
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.
-
Build Time - Screenshot services generate images on-demand, which can slow down your first build. Subsequent builds are faster as services cache screenshots.
-
Service Caching - Screenshots are typically cached by the service, so the same URL will return quickly on subsequent requests.
-
Local Caching - Consider the blogroll plugin’s
cache_durationsetting to reduce build frequency and screenshot service requests:
[blogroll]
cache_duration = "6h" # Reduces rebuild frequency
fallback_image_service = "https://shots.so/s/{url}"
- Cost Considerations - For high-traffic sites or large blogrolls, consider:
- Using services with generous free tiers (like shots.so)
- Limiting
max_entries_per_feedto reduce screenshot requests - Increasing
cache_durationto 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 ¶ #
- Increase cache duration for production builds
- Limit
max_entries_per_feedif you only need recent posts - Reduce
concurrent_requestsif you’re hitting rate limits - Disable inactive feeds with
active = falseinstead 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_requestssetting (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 ¶ #
- Feeds Guide - Create feeds from your own content
- Templates Guide - Customize blogroll appearance
- Syndication Guide - Share your own content via RSS
See Also ¶ #
- Configuration Guide - Full configuration reference
- Themes Guide - Style your blogroll with themes