第9章:插件开发
第9章:插件开发
9.1 插件系统概述
Jekyll 的插件系统允许你扩展其核心功能。插件使用 Ruby 编写,可以在构建过程的不同阶段介入。
插件类型
| 类型 | 说明 | 基类 |
|---|---|---|
| Generators(生成器) | 生成新内容/页面 | Jekyll::Generator |
| Converters(转换器) | 转换文件格式 | Jekyll::Converter |
| Commands(命令) | 添加 CLI 命令 | Jekyll::Command |
| Tags(标签) | 自定义 Liquid 标签 | Liquid::Tag |
| Filters(过滤器) | 自定义 Liquid 过滤器 | Ruby Module |
| Hooks(钩子) | 在特定事件触发 | — |
插件存放位置
_plugins/ # 本地插件目录
├── my_generator.rb
├── my_converter.rb
└── my_filters.rb
# 或作为 Gem 安装
# Gemfile
gem "jekyll-sitemap"
gem "jekyll-feed"
9.2 Gem 插件(使用他人插件)
常用 Gem 插件
| 插件 | 说明 | GitHub Pages 支持 |
|---|---|---|
jekyll-paginate | 文章分页 | ✅ |
jekyll-sitemap | 生成 sitemap.xml | ✅ |
jekyll-seo-tag | SEO 元标签 | ✅ |
jekyll-feed | 生成 RSS/Atom | ✅ |
jekyll-include-cache | include 缓存 | ✅ |
jekyll-redirect-from | URL 重定向 | ✅ |
jekyll-archives | 分类/标签归档 | ❌ |
jekyll-paginate-v2 | 高级分页 | ❌ |
jekyll-spaceship | 数学公式/图表 | ❌ |
jekyll-compose | 文章创建工具 | ❌ |
安装 Gem 插件
# Gemfile
source "https://rubygems.org"
gem "jekyll", "~> 4.3"
group :jekyll_plugins do
gem "jekyll-paginate"
gem "jekyll-sitemap"
gem "jekyll-seo-tag"
gem "jekyll-feed"
gem "jekyll-include-cache"
end
# _config.yml
plugins:
- jekyll-paginate
- jekyll-sitemap
- jekyll-seo-tag
- jekyll-feed
- jekyll-include-cache
# 安装
bundle install
# 在模板中使用
# jekyll-sitemap 自动生成 /sitemap.xml
# jekyll-feed 自动生成 /feed.xml
# jekyll-seo-tag 在 head 中添加
{% seo %}
{% feed_meta %}
注意事项:
- GitHub Pages 只支持白名单中的插件
- 使用 GitHub Actions 构建可以绕过此限制
plugins配置项在旧版本中叫gems
9.3 自定义过滤器(Custom Filters)
# _plugins/custom_filters.rb
module Jekyll
module CustomFilters
# 中文阅读时间
def reading_time(content)
words = content.gsub(/<[^>]*>/, '').gsub(/\s+/, '').length
minutes = (words / 300.0).ceil
if minutes < 1
"不到 1 分钟"
else
"约 #{minutes} 分钟"
end
end
# 相对时间(时间戳格式化)
def timeago(date)
now = Time.now
diff = now - date
case diff
when 0...60
"#{diff.to_i} 秒前"
when 60...3600
"#{(diff / 60).to_i} 分钟前"
when 3600...86400
"#{(diff / 3600).to_i} 小时前"
when 86400...2592000
"#{(diff / 86400).to_i} 天前"
when 2592000...31536000
"#{(diff / 2592000).to_i} 个月前"
else
"#{(diff / 31536000).to_i} 年前"
end
end
# 数字格式化(添加千分位)
def number_format(number)
number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
end
# Markdown 转纯文本
def strip_markdown(input)
input.gsub(/```[\s\S]*?```/, '') # 移除代码块
.gsub(/`[^`]*`/, '') # 移除行内代码
.gsub(/!\[.*?\]\(.*?\)/, '') # 移除图片
.gsub(/\[([^\]]*)\]\(.*?\)/, '\1') # 移除链接保留文本
.gsub(/#+\s/, '') # 移除标题标记
.gsub(/[*_]{1,2}(.+?)[*_]{1,2}/, '\1') # 移除强调
.gsub(/^\s*[-*+]\s/, '') # 移除列表标记
.gsub(/^\s*\d+\.\s/, '') # 移除有序列表
.gsub(/^\s*>/, '') # 移除引用
.gsub(/\n{3,}/, "\n\n") # 压缩空行
.strip
end
# 中文排序(按拼音首字母)
def sort_chinese(array, property = nil)
sorted = if property
array.sort_by { |item| item[property].to_s.encode('UTF-8') }
else
array.sort_by { |item| item.to_s.encode('UTF-8') }
end
sorted
end
# 截取 HTML 内容的前 N 个字符(保留标签完整性)
def truncate_html(input, max_length = 200)
text = strip_html(input)
if text.length > max_length
"#{text[0...max_length]}..."
else
text
end
end
end
end
Liquid::Template.register_filter(Jekyll::CustomFilters)
9.4 自定义标签(Custom Tags)
简单标签
# _plugins/tags/copyright_tag.rb
module Jekyll
class CopyrightTag < Liquid::Tag
def initialize(tag_name, text, tokens)
super
@year = text.strip.to_i
@year = Time.now.year if @year == 0
end
def render(context)
site_title = context.registers[:site].config['title']
"© #{@year} #{site_title}. All rights reserved."
end
end
end
Liquid::Template.register_tag('copyright', Jekyll::CopyrightTag)
<!-- 使用 -->
<footer>
{% copyright 2025 %}
<!-- 输出: © 2025 My Site. All rights reserved. -->
</footer>
带参数的块标签
# _plugins/tags/alert_tag.rb
module Jekyll
class AlertTag < Liquid::Block
SYNTAX = /^\s*(\w+)\s*/.freeze
def initialize(tag_name, markup, tokens)
super
if markup =~ SYNTAX
@type = $1 # info, warning, danger, success
else
raise SyntaxError, "Syntax: {% alert type %}content{% endalert %}"
end
end
def render(context)
content = super.strip
<<~HTML
<div class="alert alert-#{@type}" role="alert">
#{content}
</div>
HTML
end
end
end
Liquid::Template.register_tag('alert', Jekyll::AlertTag)
<!-- 使用 -->
{% alert info %}
**提示**:这是一个信息提示框。
{% endalert %}
{% alert warning %}
**警告**:此操作不可撤销。
{% endalert %}
{% alert danger %}
**危险**:请谨慎操作!
{% endalert %}
带解析参数的标签
# _plugins/tags/youtube_tag.rb
module Jekyll
class YouTubeTag < Liquid::Tag
def initialize(tag_name, markup, tokens)
super
@video_id = markup.strip
end
def render(context)
<<~HTML
<div class="video-container">
<iframe
src="https://www.youtube.com/embed/#{@video_id}"
frameborder="0"
allowfullscreen>
</iframe>
</div>
HTML
end
end
end
Liquid::Template.register_tag('youtube', Jekyll::YouTubeTag)
<!-- 使用 -->
{% youtube dQw4w9WgXcQ %}
支持命名参数的标签
# _plugins/tags/tabs_tag.rb
module Jekyll
class TabsTag < Liquid::Block
def initialize(tag_name, markup, tokens)
super
@id = markup.strip.gsub(/\s+/, '-')
end
def render(context)
content = super
<<~HTML
<div class="tabs" id="tabs-#{@id}">
<div class="tab-headers">#{render_headers(content)}</div>
<div class="tab-contents">#{content}</div>
</div>
HTML
end
private
def render_headers(content)
headers = content.scan(/<div class="tab" data-label="([^"]+)">/)
headers.map.with_index do |h, i|
"<button class='tab-btn #{'active' if i == 0}' data-tab='#{i}'>#{h[0]}</button>"
end.join("\n")
end
end
end
Liquid::Template.register_tag('tabs', Jekyll::TabsTag)
9.5 生成器(Generators)
生成器在构建时生成额外的内容。
# _plugins/generators/category_generator.rb
module Jekyll
class CategoryPageGenerator < Generator
safe true
priority :low
def generate(site)
# 获取所有分类
site.categories.each do |category, posts|
# 为每个分类创建一个页面
site.pages << CategoryPage.new(site, category, posts)
end
end
end
class CategoryPage < Page
def initialize(site, category, posts)
@site = site
@base = site.source
@dir = File.join('categories', Utils.slugify(category, mode: 'latin'))
@name = 'index.html'
process(@name)
read_yaml(File.join(site.source, '_layouts'), 'category.html')
data['category'] = category
data['posts'] = posts.sort_by { |p| p.date }.reverse
data['title'] = "分类: #{category}"
data['permalink'] = "/categories/#{Utils.slugify(category, mode: 'latin')}/"
end
end
end
# _plugins/generators/sitemap_generator.rb
module Jekyll
class SitemapGenerator < Generator
safe true
priority :lowest
def generate(site)
site.pages << SitemapPage.new(site)
end
end
class SitemapPage < Page
def initialize(site)
@site = site
@base = site.source
@dir = '/'
@name = 'sitemap.xml'
process(@name)
read_yaml(File.join(site.source, '_layouts'), 'sitemap.xml')
data['layout'] = nil
end
end
end
9.6 转换器(Converters)
转换器处理非标准文件格式。
# _plugins/converters/asciidoc_converter.rb
module Jekyll
class AsciiDocConverter < Converter
safe true
priority :low
def matches(ext)
ext =~ /^\.asciidoc$/i
end
def output_ext(ext)
".html"
end
def convert(content)
# 使用 Asciidoctor 转换
require 'asciidoc'
Asciidoctor.convert(content, safe: :safe)
rescue LoadError
Jekyll.logger.warn "Converters:", "Install asciidoctor gem"
content
end
end
end
9.7 钩子(Hooks)
钩子允许在构建生命周期的特定时刻执行代码。
钩子类型
| 钩子 | 触发时机 |
|---|---|
:site, :after_init | 站点初始化后 |
:site, :post_read | 读取所有文件后 |
:site, :post_write | 写入所有文件后 |
:pages, :post_init | 页面初始化后 |
:pages, :pre_render | 页面渲染前 |
:pages, :post_render | 页面渲染后 |
:pages, :post_write | 页面写入后 |
:posts, :post_init | 文章初始化后 |
:posts, :pre_render | 文章渲染前 |
:posts, :post_render | 文章渲染后 |
:posts, :post_write | 文章写入后 |
:documents, :pre_render | 文档渲染前 |
:documents, :post_render | 文档渲染后 |
钩子示例
# _plugins/hooks/reading_time.rb
Jekyll::Hooks.register :posts, :post_render do |post|
# 为每篇文章计算阅读时间
words = post.content.gsub(/<[^>]*>/, '').gsub(/\s+/, '').length
minutes = (words / 300.0).ceil
post.data['reading_time'] = minutes
end
# _plugins/hooks/auto_excerpt.rb
Jekyll::Hooks.register :posts, :post_render do |post|
# 自动生成 excerpt 如果没有手动设置
unless post.data['excerpt']
text = post.content.gsub(/<[^>]*>/, '').strip
post.data['excerpt'] = text.length > 200 ? "#{text[0...200]}..." : text
end
end
# _plugins/hooks/last_modified.rb
Jekyll::Hooks.register :posts, :pre_render do |post|
# 使用 Git 记录最后修改时间
path = post.path
if File.exist?(path)
last_modified = `git log -1 --format="%ai" -- "#{path}"`.strip
unless last_modified.empty?
post.data['last_modified_at'] = Time.parse(last_modified)
end
end
rescue
# Git 不可用时忽略
end
# _plugins/hooks/notify_build.rb
Jekyll::Hooks.register :site, :post_write do |site|
# 构建完成后发送通知
puts "✅ Site built successfully!"
puts " Posts: #{site.posts.size}"
puts " Pages: #{site.pages.size}"
puts " Output: #{site.dest}"
puts " Time: #{Time.now}"
end
9.8 插件最佳实践
错误处理
module Jekyll
module MyPlugin
def safe_operation(input)
result = process(input)
result
rescue StandardError => e
Jekyll.logger.warn "MyPlugin:", "Error: #{e.message}"
input # 返回原始输入
end
end
end
性能优化
# 缓存计算结果
module Jekyll
module MyFilters
@@cache = {}
def expensive_calculation(input)
return @@cache[input] if @@cache.key?(input)
result = # ... 复杂计算
@@cache[input] = result
result
end
end
end
插件测试
# test/test_custom_filters.rb
require 'minitest/autorun'
require 'liquid'
require_relative '../_plugins/custom_filters'
class TestCustomFilters < Minitest::Test
include Jekyll::CustomFilters
def test_reading_time_short
assert_equal "不到 1 分钟", reading_time("短文本")
end
def test_reading_time_long
long_text = "字" * 600
assert_equal "约 2 分钟", reading_time(long_text)
end
def test_number_format
assert_equal "1,234,567", number_format(1234567)
end
end
9.9 业务场景:自动目录生成插件
# _plugins/toc_generator.rb
module Jekyll
class TOCGenerator < Generator
safe true
def generate(site)
site.posts.docs.each do |doc|
next unless doc.data['toc'] == true
headings = extract_headings(doc.content)
doc.data['toc_items'] = headings
end
end
private
def extract_headings(html)
headings = []
html.scan(/<h([2-4])[^>]*id="([^"]*)"[^>]*>(.*?)<\/h\1>/) do
headings << {
'level' => $1.to_i,
'id' => $2,
'text' => $3.gsub(/<[^>]*>/, '')
}
end
headings
end
end
end
<!-- 使用 -->
{% if page.toc_items.size > 0 %}
<nav class="toc">
<h4>目录</h4>
<ul>
{% for item in page.toc_items %}
<li class="toc-level-{{ item.level }}">
<a href="#{{ item.id }}">{{ item.text }}</a>
</li>
{% endfor %}
</ul>
</nav>
{% endif %}
9.10 扩展阅读
本章小结
| 要点 | 说明 |
|---|---|
| 插件类型 | Generators、Converters、Tags、Filters、Hooks |
| Gem 插件 | 通过 Gemfile 安装,_config.yml 的 plugins 启用 |
| 自定义插件 | 放在 _plugins/ 目录,Ruby 编写 |
| 钩子 | 在构建生命周期特定时刻触发 |
| GitHub Pages 限制 | 仅支持白名单插件,需 GitHub Actions 绕过 |
下一章:主题系统