Static site generator supporting Markdown and reStructuredText
—
Signal-based plugin architecture that enables extensibility through custom generators, content processors, output handlers, and theme enhancements. The plugin system provides hooks throughout the site generation pipeline.
Core signals that plugins can connect to for extending Pelican functionality.
from pelican.plugins import signals
# Run-level lifecycle signals
signals.initialized # Fired after Pelican initialization
signals.get_generators # Request additional generators
signals.all_generators_finalized # Fired after all generators complete
signals.get_writer # Request custom writer
signals.finalized # Fired after site generation completion
# Reader-level signals
signals.readers_init # After readers initialization
# Generator-level signals
signals.generator_init # Generic generator initialization
signals.article_generator_init # Before article generator initialization
signals.article_generator_pretaxonomy # Before article taxonomy processing
signals.article_generator_finalized # After article generator completion
signals.article_generator_write_article # Before writing individual articles
signals.article_writer_finalized # After article writer completion
signals.page_generator_init # Before page generator initialization
signals.page_generator_finalized # After page generator completion
signals.page_generator_write_page # Before writing individual pages
signals.page_writer_finalized # After page writer completion
signals.static_generator_init # Before static generator initialization
signals.static_generator_finalized # After static generator completion
# Content-level signals
signals.article_generator_preread # Before reading article content
signals.article_generator_context # After article context generation
signals.page_generator_preread # Before reading page content
signals.page_generator_context # After page context generation
signals.static_generator_preread # Before reading static content
signals.static_generator_context # After static context generation
signals.content_object_init # After content object initialization
# Writer signals
signals.content_written # After content file is written
signals.feed_generated # After feed generation
signals.feed_written # After feed file is writtenHelper functions for plugin development and management.
def load_plugins(settings: dict) -> list:
"""
Load enabled plugins from settings.
Parameters:
- settings (dict): Site settings dictionary
Returns:
list: List of loaded plugin modules
"""
def get_plugin_name(plugin) -> str:
"""
Get display name for a plugin.
Parameters:
- plugin: Plugin module or object
Returns:
str: Plugin display name
"""
def plugin_enabled(plugin_name: str, settings: dict) -> bool:
"""
Check if a specific plugin is enabled.
Parameters:
- plugin_name (str): Name of plugin to check
- settings (dict): Site settings dictionary
Returns:
bool: True if plugin is enabled
"""
def list_plugins() -> None:
"""List all available plugins in plugin paths."""# my_plugin.py - Basic plugin example
from pelican import signals
def initialize_plugin(pelican):
"""Initialize plugin with Pelican instance."""
print(f"Plugin initialized for site: {pelican.settings['SITENAME']}")
def modify_articles(generator):
"""Modify articles after generation."""
for article in generator.articles:
# Add custom processing
article.custom_field = "Added by plugin"
def register():
"""Register plugin signals."""
signals.initialized.connect(initialize_plugin)
signals.article_generator_finalized.connect(modify_articles)# content_processor.py - Content modification plugin
from pelican import signals
import re
def process_content(content):
"""Process content object after initialization."""
if hasattr(content, 'content'):
# Add custom processing to content HTML
content.content = enhance_content(content.content)
# Add metadata
content.word_count = len(content.content.split())
content.reading_time = max(1, content.word_count // 200)
def enhance_content(html_content):
"""Enhance HTML content with custom features."""
# Add responsive image classes
html_content = re.sub(
r'<img([^>]+)>',
r'<img\1 class="responsive-image">',
html_content
)
# Wrap tables for responsive design
html_content = re.sub(
r'<table([^>]*)>',
r'<div class="table-wrapper"><table\1>',
html_content
)
html_content = re.sub(
r'</table>',
r'</table></div>',
html_content
)
return html_content
def register():
"""Register plugin signals."""
signals.content_object_init.connect(process_content)# custom_generator.py - Custom content generator
from pelican import signals
from pelican.generators import Generator
import json
from pathlib import Path
class DataGenerator(Generator):
"""Generator for JSON data files."""
def generate_context(self):
"""Generate context from JSON data files."""
data_path = Path(self.path) / 'data'
if not data_path.exists():
return
data_files = {}
for json_file in data_path.glob('*.json'):
with open(json_file) as f:
data_files[json_file.stem] = json.load(f)
self.context['data_files'] = data_files
def generate_output(self, writer):
"""Generate data-driven pages."""
if 'data_files' not in self.context:
return
template = self.get_template('data_page.html')
for name, data in self.context['data_files'].items():
writer.write_file(
f'data/{name}.html',
template,
self.context,
data=data,
page_name=name
)
def add_generator(pelican):
"""Add custom generator to Pelican."""
return DataGenerator
def register():
"""Register plugin."""
signals.get_generators.connect(add_generator)# template_helpers.py - Template helper functions
from pelican import signals
from jinja2 import Markup
import markdown
def markdown_filter(text):
"""Jinja2 filter to render Markdown in templates."""
md = markdown.Markdown(extensions=['extra', 'codehilite'])
return Markup(md.convert(text))
def reading_time_filter(content):
"""Calculate reading time for content."""
word_count = len(content.split())
return max(1, word_count // 200)
def related_posts_filter(article, articles, count=5):
"""Find related posts based on tags."""
if not hasattr(article, 'tags') or not article.tags:
return []
article_tags = set(tag.name for tag in article.tags)
related = []
for other in articles:
if other == article:
continue
if hasattr(other, 'tags') and other.tags:
other_tags = set(tag.name for tag in other.tags)
common_tags = len(article_tags & other_tags)
if common_tags > 0:
related.append((other, common_tags))
# Sort by number of common tags and return top N
related.sort(key=lambda x: x[1], reverse=True)
return [post for post, _ in related[:count]]
def add_template_filters(pelican):
"""Add custom template filters."""
pelican.env.filters.update({
'markdown': markdown_filter,
'reading_time': reading_time_filter,
'related_posts': related_posts_filter,
})
def register():
"""Register plugin."""
signals.initialized.connect(add_template_filters)# pelicanconf.py - Plugin configuration
PLUGINS = [
'my_plugin',
'content_processor',
'custom_generator',
'template_helpers',
'sitemap',
'related_posts',
]
# Plugin search paths
PLUGIN_PATHS = [
'plugins',
'/usr/local/lib/pelican/plugins',
]
# Plugin-specific settings
PLUGIN_SETTINGS = {
'my_plugin': {
'option1': 'value1',
'option2': True,
},
'sitemap': {
'format': 'xml',
'priorities': {
'articles': 0.8,
'pages': 0.5,
'indexes': 1.0,
}
}
}# Install plugin from Git repository
cd plugins/
git clone https://github.com/author/pelican-plugin.git
# Install via pip (if packaged)
pip install pelican-plugin-name
# Manual installation
mkdir -p plugins/my_plugin/
cp my_plugin.py plugins/my_plugin/__init__.py# seo_plugin.py - SEO optimization plugin
from pelican import signals
from pelican.contents import Article, Page
import re
from urllib.parse import urljoin
def add_seo_metadata(content):
"""Add SEO metadata to content."""
if isinstance(content, (Article, Page)):
# Generate meta description from summary or content
if hasattr(content, 'summary') and content.summary:
content.meta_description = clean_text(content.summary)[:160]
else:
# Extract first paragraph from content
first_para = extract_first_paragraph(content.content)
content.meta_description = clean_text(first_para)[:160]
# Generate Open Graph metadata
content.og_title = content.title
content.og_description = content.meta_description
content.og_type = 'article' if isinstance(content, Article) else 'website'
# Add canonical URL
if hasattr(content, 'url'):
content.canonical_url = urljoin(content.settings['SITEURL'], content.url)
def clean_text(html_text):
"""Remove HTML tags and clean text."""
clean = re.sub(r'<[^>]+>', '', html_text)
clean = re.sub(r'\s+', ' ', clean).strip()
return clean
def extract_first_paragraph(html_content):
"""Extract first paragraph from HTML content."""
match = re.search(r'<p[^>]*>(.*?)</p>', html_content, re.DOTALL)
return match.group(1) if match else html_content[:200]
def generate_sitemap(generator):
"""Generate XML sitemap."""
if not hasattr(generator, 'articles'):
return
sitemap_entries = []
# Add articles
for article in generator.articles:
sitemap_entries.append({
'url': urljoin(generator.settings['SITEURL'], article.url),
'lastmod': article.date.strftime('%Y-%m-%d'),
'priority': '0.8'
})
# Add pages
if hasattr(generator, 'pages'):
for page in generator.pages:
sitemap_entries.append({
'url': urljoin(generator.settings['SITEURL'], page.url),
'lastmod': page.date.strftime('%Y-%m-%d') if hasattr(page, 'date') else '',
'priority': '0.5'
})
# Generate sitemap XML
sitemap_xml = generate_sitemap_xml(sitemap_entries)
# Write sitemap file
output_path = generator.output_path
with open(f"{output_path}/sitemap.xml", 'w') as f:
f.write(sitemap_xml)
def generate_sitemap_xml(entries):
"""Generate XML sitemap content."""
xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
for entry in entries:
xml += ' <url>\n'
xml += f' <loc>{entry["url"]}</loc>\n'
if entry['lastmod']:
xml += f' <lastmod>{entry["lastmod"]}</lastmod>\n'
xml += f' <priority>{entry["priority"]}</priority>\n'
xml += ' </url>\n'
xml += '</urlset>\n'
return xml
def register():
"""Register SEO plugin."""
signals.content_object_init.connect(add_seo_metadata)
signals.all_generators_finalized.connect(generate_sitemap)# analytics_plugin.py - Site analytics plugin
from pelican import signals
import json
from collections import defaultdict, Counter
from datetime import datetime
class SiteAnalytics:
"""Site analytics collector."""
def __init__(self):
self.reset_stats()
def reset_stats(self):
"""Reset analytics statistics."""
self.stats = {
'total_articles': 0,
'total_pages': 0,
'categories': Counter(),
'tags': Counter(),
'authors': Counter(),
'monthly_posts': defaultdict(int),
'yearly_posts': defaultdict(int),
'total_words': 0,
'avg_reading_time': 0,
}
def analyze_articles(self, generator):
"""Analyze article statistics."""
if not hasattr(generator, 'articles'):
return
articles = generator.articles
self.stats['total_articles'] = len(articles)
total_words = 0
for article in articles:
# Category stats
if hasattr(article, 'category'):
self.stats['categories'][article.category.name] += 1
# Tag stats
if hasattr(article, 'tags'):
for tag in article.tags:
self.stats['tags'][tag.name] += 1
# Author stats
if hasattr(article, 'author'):
self.stats['authors'][article.author.name] += 1
# Date stats
if hasattr(article, 'date'):
month_key = article.date.strftime('%Y-%m')
year_key = str(article.date.year)
self.stats['monthly_posts'][month_key] += 1
self.stats['yearly_posts'][year_key] += 1
# Word count
if hasattr(article, 'content'):
word_count = len(article.content.split())
total_words += word_count
self.stats['total_words'] = total_words
if articles:
self.stats['avg_reading_time'] = (total_words // len(articles)) // 200
def analyze_pages(self, generator):
"""Analyze page statistics."""
if hasattr(generator, 'pages'):
self.stats['total_pages'] = len(generator.pages)
def generate_report(self, pelican):
"""Generate analytics report."""
output_path = pelican.output_path
# Convert Counter objects to regular dicts for JSON serialization
report_data = dict(self.stats)
report_data['categories'] = dict(report_data['categories'])
report_data['tags'] = dict(report_data['tags'])
report_data['authors'] = dict(report_data['authors'])
report_data['monthly_posts'] = dict(report_data['monthly_posts'])
report_data['yearly_posts'] = dict(report_data['yearly_posts'])
report_data['generated_at'] = datetime.now().isoformat()
# Write JSON report
with open(f"{output_path}/analytics.json", 'w') as f:
json.dump(report_data, f, indent=2)
# Write human-readable report
with open(f"{output_path}/analytics.txt", 'w') as f:
f.write("Site Analytics Report\n")
f.write("=" * 20 + "\n\n")
f.write(f"Total Articles: {report_data['total_articles']}\n")
f.write(f"Total Pages: {report_data['total_pages']}\n")
f.write(f"Total Words: {report_data['total_words']:,}\n")
f.write(f"Average Reading Time: {report_data['avg_reading_time']} minutes\n\n")
f.write("Top Categories:\n")
for cat, count in Counter(report_data['categories']).most_common(10):
f.write(f" {cat}: {count}\n")
f.write("\nTop Tags:\n")
for tag, count in Counter(report_data['tags']).most_common(10):
f.write(f" {tag}: {count}\n")
# Global analytics instance
analytics = SiteAnalytics()
def collect_article_stats(generator):
"""Collect article statistics."""
analytics.analyze_articles(generator)
def collect_page_stats(generator):
"""Collect page statistics."""
analytics.analyze_pages(generator)
def generate_analytics_report(pelican):
"""Generate final analytics report."""
analytics.generate_report(pelican)
def register():
"""Register analytics plugin."""
signals.article_generator_finalized.connect(collect_article_stats)
signals.page_generator_finalized.connect(collect_page_stats)
signals.finalized.connect(generate_analytics_report)# test_my_plugin.py - Plugin testing example
import unittest
from unittest.mock import Mock, MagicMock
from pelican.contents import Article
from pelican.settings import DEFAULT_CONFIG
import my_plugin
class TestMyPlugin(unittest.TestCase):
"""Test cases for my_plugin."""
def setUp(self):
"""Set up test fixtures."""
self.settings = DEFAULT_CONFIG.copy()
self.pelican_mock = Mock()
self.pelican_mock.settings = self.settings
def test_initialize_plugin(self):
"""Test plugin initialization."""
my_plugin.initialize_plugin(self.pelican_mock)
# Add assertions
def test_modify_articles(self):
"""Test article modification."""
# Create mock generator with articles
generator_mock = Mock()
article_mock = Mock(spec=Article)
generator_mock.articles = [article_mock]
# Call plugin function
my_plugin.modify_articles(generator_mock)
# Assert modifications
self.assertTrue(hasattr(article_mock, 'custom_field'))
self.assertEqual(article_mock.custom_field, "Added by plugin")
if __name__ == '__main__':
unittest.main()Install with Tessl CLI
npx tessl i tessl/pypi-pelican