强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

Jekyll 静态站点完全教程 / 第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-tagSEO 元标签
jekyll-feed生成 RSS/Atom
jekyll-include-cacheinclude 缓存
jekyll-redirect-fromURL 重定向
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.ymlplugins 启用
自定义插件放在 _plugins/ 目录,Ruby 编写
钩子在构建生命周期特定时刻触发
GitHub Pages 限制仅支持白名单插件,需 GitHub Actions 绕过

下一章:主题系统