or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

Files

docs

cli-tools.mdcontent-generation.mdcontent-management.mdcontent-reading.mdindex.mdmain-application.mdplugin-system.mdsettings-configuration.mdutilities.md

plugin-system.mddocs/

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

```