0
# Plugin System
1
2
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.
3
4
## Capabilities
5
6
### Plugin Signals
7
8
Core signals that plugins can connect to for extending Pelican functionality.
9
10
```python { .api }
11
from pelican.plugins import signals
12
13
# Run-level lifecycle signals
14
signals.initialized # Fired after Pelican initialization
15
signals.get_generators # Request additional generators
16
signals.all_generators_finalized # Fired after all generators complete
17
signals.get_writer # Request custom writer
18
signals.finalized # Fired after site generation completion
19
20
# Reader-level signals
21
signals.readers_init # After readers initialization
22
23
# Generator-level signals
24
signals.generator_init # Generic generator initialization
25
signals.article_generator_init # Before article generator initialization
26
signals.article_generator_pretaxonomy # Before article taxonomy processing
27
signals.article_generator_finalized # After article generator completion
28
signals.article_generator_write_article # Before writing individual articles
29
signals.article_writer_finalized # After article writer completion
30
31
signals.page_generator_init # Before page generator initialization
32
signals.page_generator_finalized # After page generator completion
33
signals.page_generator_write_page # Before writing individual pages
34
signals.page_writer_finalized # After page writer completion
35
36
signals.static_generator_init # Before static generator initialization
37
signals.static_generator_finalized # After static generator completion
38
39
# Content-level signals
40
signals.article_generator_preread # Before reading article content
41
signals.article_generator_context # After article context generation
42
signals.page_generator_preread # Before reading page content
43
signals.page_generator_context # After page context generation
44
signals.static_generator_preread # Before reading static content
45
signals.static_generator_context # After static context generation
46
47
signals.content_object_init # After content object initialization
48
49
# Writer signals
50
signals.content_written # After content file is written
51
signals.feed_generated # After feed generation
52
signals.feed_written # After feed file is written
53
```
54
55
### Plugin Utilities
56
57
Helper functions for plugin development and management.
58
59
```python { .api }
60
def load_plugins(settings: dict) -> list:
61
"""
62
Load enabled plugins from settings.
63
64
Parameters:
65
- settings (dict): Site settings dictionary
66
67
Returns:
68
list: List of loaded plugin modules
69
"""
70
71
def get_plugin_name(plugin) -> str:
72
"""
73
Get display name for a plugin.
74
75
Parameters:
76
- plugin: Plugin module or object
77
78
Returns:
79
str: Plugin display name
80
"""
81
82
def plugin_enabled(plugin_name: str, settings: dict) -> bool:
83
"""
84
Check if a specific plugin is enabled.
85
86
Parameters:
87
- plugin_name (str): Name of plugin to check
88
- settings (dict): Site settings dictionary
89
90
Returns:
91
bool: True if plugin is enabled
92
"""
93
94
def list_plugins() -> None:
95
"""List all available plugins in plugin paths."""
96
```
97
98
## Plugin Development
99
100
### Basic Plugin Structure
101
102
```python
103
# my_plugin.py - Basic plugin example
104
from pelican import signals
105
106
def initialize_plugin(pelican):
107
"""Initialize plugin with Pelican instance."""
108
print(f"Plugin initialized for site: {pelican.settings['SITENAME']}")
109
110
def modify_articles(generator):
111
"""Modify articles after generation."""
112
for article in generator.articles:
113
# Add custom processing
114
article.custom_field = "Added by plugin"
115
116
def register():
117
"""Register plugin signals."""
118
signals.initialized.connect(initialize_plugin)
119
signals.article_generator_finalized.connect(modify_articles)
120
```
121
122
### Content Processing Plugin
123
124
```python
125
# content_processor.py - Content modification plugin
126
from pelican import signals
127
import re
128
129
def process_content(content):
130
"""Process content object after initialization."""
131
if hasattr(content, 'content'):
132
# Add custom processing to content HTML
133
content.content = enhance_content(content.content)
134
135
# Add metadata
136
content.word_count = len(content.content.split())
137
content.reading_time = max(1, content.word_count // 200)
138
139
def enhance_content(html_content):
140
"""Enhance HTML content with custom features."""
141
# Add responsive image classes
142
html_content = re.sub(
143
r'<img([^>]+)>',
144
r'<img\1 class="responsive-image">',
145
html_content
146
)
147
148
# Wrap tables for responsive design
149
html_content = re.sub(
150
r'<table([^>]*)>',
151
r'<div class="table-wrapper"><table\1>',
152
html_content
153
)
154
html_content = re.sub(
155
r'</table>',
156
r'</table></div>',
157
html_content
158
)
159
160
return html_content
161
162
def register():
163
"""Register plugin signals."""
164
signals.content_object_init.connect(process_content)
165
```
166
167
### Custom Generator Plugin
168
169
```python
170
# custom_generator.py - Custom content generator
171
from pelican import signals
172
from pelican.generators import Generator
173
import json
174
from pathlib import Path
175
176
class DataGenerator(Generator):
177
"""Generator for JSON data files."""
178
179
def generate_context(self):
180
"""Generate context from JSON data files."""
181
data_path = Path(self.path) / 'data'
182
if not data_path.exists():
183
return
184
185
data_files = {}
186
for json_file in data_path.glob('*.json'):
187
with open(json_file) as f:
188
data_files[json_file.stem] = json.load(f)
189
190
self.context['data_files'] = data_files
191
192
def generate_output(self, writer):
193
"""Generate data-driven pages."""
194
if 'data_files' not in self.context:
195
return
196
197
template = self.get_template('data_page.html')
198
199
for name, data in self.context['data_files'].items():
200
writer.write_file(
201
f'data/{name}.html',
202
template,
203
self.context,
204
data=data,
205
page_name=name
206
)
207
208
def add_generator(pelican):
209
"""Add custom generator to Pelican."""
210
return DataGenerator
211
212
def register():
213
"""Register plugin."""
214
signals.get_generators.connect(add_generator)
215
```
216
217
### Template Enhancement Plugin
218
219
```python
220
# template_helpers.py - Template helper functions
221
from pelican import signals
222
from jinja2 import Markup
223
import markdown
224
225
def markdown_filter(text):
226
"""Jinja2 filter to render Markdown in templates."""
227
md = markdown.Markdown(extensions=['extra', 'codehilite'])
228
return Markup(md.convert(text))
229
230
def reading_time_filter(content):
231
"""Calculate reading time for content."""
232
word_count = len(content.split())
233
return max(1, word_count // 200)
234
235
def related_posts_filter(article, articles, count=5):
236
"""Find related posts based on tags."""
237
if not hasattr(article, 'tags') or not article.tags:
238
return []
239
240
article_tags = set(tag.name for tag in article.tags)
241
related = []
242
243
for other in articles:
244
if other == article:
245
continue
246
if hasattr(other, 'tags') and other.tags:
247
other_tags = set(tag.name for tag in other.tags)
248
common_tags = len(article_tags & other_tags)
249
if common_tags > 0:
250
related.append((other, common_tags))
251
252
# Sort by number of common tags and return top N
253
related.sort(key=lambda x: x[1], reverse=True)
254
return [post for post, _ in related[:count]]
255
256
def add_template_filters(pelican):
257
"""Add custom template filters."""
258
pelican.env.filters.update({
259
'markdown': markdown_filter,
260
'reading_time': reading_time_filter,
261
'related_posts': related_posts_filter,
262
})
263
264
def register():
265
"""Register plugin."""
266
signals.initialized.connect(add_template_filters)
267
```
268
269
## Plugin Configuration
270
271
### Enabling Plugins
272
273
```python
274
# pelicanconf.py - Plugin configuration
275
PLUGINS = [
276
'my_plugin',
277
'content_processor',
278
'custom_generator',
279
'template_helpers',
280
'sitemap',
281
'related_posts',
282
]
283
284
# Plugin search paths
285
PLUGIN_PATHS = [
286
'plugins',
287
'/usr/local/lib/pelican/plugins',
288
]
289
290
# Plugin-specific settings
291
PLUGIN_SETTINGS = {
292
'my_plugin': {
293
'option1': 'value1',
294
'option2': True,
295
},
296
'sitemap': {
297
'format': 'xml',
298
'priorities': {
299
'articles': 0.8,
300
'pages': 0.5,
301
'indexes': 1.0,
302
}
303
}
304
}
305
```
306
307
### Plugin Installation
308
309
```bash
310
# Install plugin from Git repository
311
cd plugins/
312
git clone https://github.com/author/pelican-plugin.git
313
314
# Install via pip (if packaged)
315
pip install pelican-plugin-name
316
317
# Manual installation
318
mkdir -p plugins/my_plugin/
319
cp my_plugin.py plugins/my_plugin/__init__.py
320
```
321
322
## Advanced Plugin Examples
323
324
### SEO Enhancement Plugin
325
326
```python
327
# seo_plugin.py - SEO optimization plugin
328
from pelican import signals
329
from pelican.contents import Article, Page
330
import re
331
from urllib.parse import urljoin
332
333
def add_seo_metadata(content):
334
"""Add SEO metadata to content."""
335
if isinstance(content, (Article, Page)):
336
# Generate meta description from summary or content
337
if hasattr(content, 'summary') and content.summary:
338
content.meta_description = clean_text(content.summary)[:160]
339
else:
340
# Extract first paragraph from content
341
first_para = extract_first_paragraph(content.content)
342
content.meta_description = clean_text(first_para)[:160]
343
344
# Generate Open Graph metadata
345
content.og_title = content.title
346
content.og_description = content.meta_description
347
content.og_type = 'article' if isinstance(content, Article) else 'website'
348
349
# Add canonical URL
350
if hasattr(content, 'url'):
351
content.canonical_url = urljoin(content.settings['SITEURL'], content.url)
352
353
def clean_text(html_text):
354
"""Remove HTML tags and clean text."""
355
clean = re.sub(r'<[^>]+>', '', html_text)
356
clean = re.sub(r'\s+', ' ', clean).strip()
357
return clean
358
359
def extract_first_paragraph(html_content):
360
"""Extract first paragraph from HTML content."""
361
match = re.search(r'<p[^>]*>(.*?)</p>', html_content, re.DOTALL)
362
return match.group(1) if match else html_content[:200]
363
364
def generate_sitemap(generator):
365
"""Generate XML sitemap."""
366
if not hasattr(generator, 'articles'):
367
return
368
369
sitemap_entries = []
370
371
# Add articles
372
for article in generator.articles:
373
sitemap_entries.append({
374
'url': urljoin(generator.settings['SITEURL'], article.url),
375
'lastmod': article.date.strftime('%Y-%m-%d'),
376
'priority': '0.8'
377
})
378
379
# Add pages
380
if hasattr(generator, 'pages'):
381
for page in generator.pages:
382
sitemap_entries.append({
383
'url': urljoin(generator.settings['SITEURL'], page.url),
384
'lastmod': page.date.strftime('%Y-%m-%d') if hasattr(page, 'date') else '',
385
'priority': '0.5'
386
})
387
388
# Generate sitemap XML
389
sitemap_xml = generate_sitemap_xml(sitemap_entries)
390
391
# Write sitemap file
392
output_path = generator.output_path
393
with open(f"{output_path}/sitemap.xml", 'w') as f:
394
f.write(sitemap_xml)
395
396
def generate_sitemap_xml(entries):
397
"""Generate XML sitemap content."""
398
xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
399
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
400
401
for entry in entries:
402
xml += ' <url>\n'
403
xml += f' <loc>{entry["url"]}</loc>\n'
404
if entry['lastmod']:
405
xml += f' <lastmod>{entry["lastmod"]}</lastmod>\n'
406
xml += f' <priority>{entry["priority"]}</priority>\n'
407
xml += ' </url>\n'
408
409
xml += '</urlset>\n'
410
return xml
411
412
def register():
413
"""Register SEO plugin."""
414
signals.content_object_init.connect(add_seo_metadata)
415
signals.all_generators_finalized.connect(generate_sitemap)
416
```
417
418
### Analytics Plugin
419
420
```python
421
# analytics_plugin.py - Site analytics plugin
422
from pelican import signals
423
import json
424
from collections import defaultdict, Counter
425
from datetime import datetime
426
427
class SiteAnalytics:
428
"""Site analytics collector."""
429
430
def __init__(self):
431
self.reset_stats()
432
433
def reset_stats(self):
434
"""Reset analytics statistics."""
435
self.stats = {
436
'total_articles': 0,
437
'total_pages': 0,
438
'categories': Counter(),
439
'tags': Counter(),
440
'authors': Counter(),
441
'monthly_posts': defaultdict(int),
442
'yearly_posts': defaultdict(int),
443
'total_words': 0,
444
'avg_reading_time': 0,
445
}
446
447
def analyze_articles(self, generator):
448
"""Analyze article statistics."""
449
if not hasattr(generator, 'articles'):
450
return
451
452
articles = generator.articles
453
self.stats['total_articles'] = len(articles)
454
455
total_words = 0
456
457
for article in articles:
458
# Category stats
459
if hasattr(article, 'category'):
460
self.stats['categories'][article.category.name] += 1
461
462
# Tag stats
463
if hasattr(article, 'tags'):
464
for tag in article.tags:
465
self.stats['tags'][tag.name] += 1
466
467
# Author stats
468
if hasattr(article, 'author'):
469
self.stats['authors'][article.author.name] += 1
470
471
# Date stats
472
if hasattr(article, 'date'):
473
month_key = article.date.strftime('%Y-%m')
474
year_key = str(article.date.year)
475
self.stats['monthly_posts'][month_key] += 1
476
self.stats['yearly_posts'][year_key] += 1
477
478
# Word count
479
if hasattr(article, 'content'):
480
word_count = len(article.content.split())
481
total_words += word_count
482
483
self.stats['total_words'] = total_words
484
if articles:
485
self.stats['avg_reading_time'] = (total_words // len(articles)) // 200
486
487
def analyze_pages(self, generator):
488
"""Analyze page statistics."""
489
if hasattr(generator, 'pages'):
490
self.stats['total_pages'] = len(generator.pages)
491
492
def generate_report(self, pelican):
493
"""Generate analytics report."""
494
output_path = pelican.output_path
495
496
# Convert Counter objects to regular dicts for JSON serialization
497
report_data = dict(self.stats)
498
report_data['categories'] = dict(report_data['categories'])
499
report_data['tags'] = dict(report_data['tags'])
500
report_data['authors'] = dict(report_data['authors'])
501
report_data['monthly_posts'] = dict(report_data['monthly_posts'])
502
report_data['yearly_posts'] = dict(report_data['yearly_posts'])
503
report_data['generated_at'] = datetime.now().isoformat()
504
505
# Write JSON report
506
with open(f"{output_path}/analytics.json", 'w') as f:
507
json.dump(report_data, f, indent=2)
508
509
# Write human-readable report
510
with open(f"{output_path}/analytics.txt", 'w') as f:
511
f.write("Site Analytics Report\n")
512
f.write("=" * 20 + "\n\n")
513
f.write(f"Total Articles: {report_data['total_articles']}\n")
514
f.write(f"Total Pages: {report_data['total_pages']}\n")
515
f.write(f"Total Words: {report_data['total_words']:,}\n")
516
f.write(f"Average Reading Time: {report_data['avg_reading_time']} minutes\n\n")
517
518
f.write("Top Categories:\n")
519
for cat, count in Counter(report_data['categories']).most_common(10):
520
f.write(f" {cat}: {count}\n")
521
522
f.write("\nTop Tags:\n")
523
for tag, count in Counter(report_data['tags']).most_common(10):
524
f.write(f" {tag}: {count}\n")
525
526
# Global analytics instance
527
analytics = SiteAnalytics()
528
529
def collect_article_stats(generator):
530
"""Collect article statistics."""
531
analytics.analyze_articles(generator)
532
533
def collect_page_stats(generator):
534
"""Collect page statistics."""
535
analytics.analyze_pages(generator)
536
537
def generate_analytics_report(pelican):
538
"""Generate final analytics report."""
539
analytics.generate_report(pelican)
540
541
def register():
542
"""Register analytics plugin."""
543
signals.article_generator_finalized.connect(collect_article_stats)
544
signals.page_generator_finalized.connect(collect_page_stats)
545
signals.finalized.connect(generate_analytics_report)
546
```
547
548
## Plugin Testing
549
550
```python
551
# test_my_plugin.py - Plugin testing example
552
import unittest
553
from unittest.mock import Mock, MagicMock
554
from pelican.contents import Article
555
from pelican.settings import DEFAULT_CONFIG
556
import my_plugin
557
558
class TestMyPlugin(unittest.TestCase):
559
"""Test cases for my_plugin."""
560
561
def setUp(self):
562
"""Set up test fixtures."""
563
self.settings = DEFAULT_CONFIG.copy()
564
self.pelican_mock = Mock()
565
self.pelican_mock.settings = self.settings
566
567
def test_initialize_plugin(self):
568
"""Test plugin initialization."""
569
my_plugin.initialize_plugin(self.pelican_mock)
570
# Add assertions
571
572
def test_modify_articles(self):
573
"""Test article modification."""
574
# Create mock generator with articles
575
generator_mock = Mock()
576
article_mock = Mock(spec=Article)
577
generator_mock.articles = [article_mock]
578
579
# Call plugin function
580
my_plugin.modify_articles(generator_mock)
581
582
# Assert modifications
583
self.assertTrue(hasattr(article_mock, 'custom_field'))
584
self.assertEqual(article_mock.custom_field, "Added by plugin")
585
586
if __name__ == '__main__':
587
unittest.main()
588
```