- 前端
- 开发工具
【免费下载链接】middleman
Hand-crafted frontend development
本文以 middleman-core 仓库中paginate-app测试夹具(fixture)为核心,讲解 Middleman 静态博客分页功能的工作原理与配置方法。你将掌握博客文章 frontmatter(title/date/tags)如何成为分页数据源、per_page分页 API 的两种调用方式、分页模板变量(pagination / items / prev_page / next_page)的完整用法,以及分页背后Array#per_page与 Collections 扩展的源码实现。
一、关联文档与它在分页测试中的角色
在 paginate-app/source/blog/2011-02-02-test-article.html.markdown 中,存放着一篇典型的 Middleman 博客文章,其完整内容如下:
--- title: "Test Article" date: 2011-02-02 tags: bar --- Test Article Content这个文件是 Middleman 分页功能测试夹具paginate-app的一篇输入数据。它由两部分组成:
- frontmatter(YAML 前置数据块):声明了
title: "Test Article"、date: 2011-02-02、tags: bar三个元数据字段,供分页逻辑按日期排序、按标签分组; - 正文内容:仅一行
Test Article Content,用于验证文章被正确渲染进分页页面。
在paginate-app/source/blog/目录下共有 7 篇结构完全相同的测试文章(2011-01-01至2011-02-02),它们共同构成一个 7 篇规模的小型博客数据集。整个夹具只包含三种文件,职责非常清晰:
| 目录/文件 | 职责 |
|---|---|
| source/blog/*.html.markdown | 博客文章数据源(frontmatter + 正文) |
| source/archive/2011/index.html.erb | 分页目标模板,消费items、pagination等局部变量 |
| config.rb | 分页配置(运行时由测试动态写入) |
提示:
paginate-app/config.rb在仓库中为空文件,实际的配置内容由 paginate.feature 中的 Cucumber 场景在测试运行时动态写入,这正是下面要展开讲解的完整配置。
二、把文章集合变成分页数据源:per_page 核心用法
在 paginate.feature 的“Basic configuration”场景中,第一段配置演示了最常用的分页写法——按日期倒序排列后每 5 篇一页:
articles = resources.select { |r| matcher = ::Middleman::Util::UriTemplates.uri_template('blog/2011-{remaining}') ::Middleman::Util::UriTemplates.extract_params(matcher, ::Middleman::Util.normalize_path(r.url)) } articles.sort { |a, b| b.data.date <=> a.data.date }.per_page(5) do |items, num, meta, is_last| page_path = num == 1 ? '/2011/index.html' : "/2011/page/#{num}.html" prev_page = case num when 1 nil when 2 '/2011/index.html' when 3 "/2011/page/#{num-1}.html" end next_page = is_last ? nil : "/2011/page/#{num+1}.html" proxy page_path, "/archive/2011/index.html", locals: { items: items, pagination: meta, prev_page: prev_page, next_page: next_page } end逐段拆解这个配置:
- 筛选文章:通过
resources.select配合Middleman::Util::UriTemplates的 URL 模板匹配,把source/blog/2011-{remaining}模式下的文章筛选出来,构成候选集合; - 按日期倒序:
sort { |a, b| b.data.date <=> a.data.date }用 frontmatter 中的date字段做倒序排列,保证最新文章排在最前; - 分页:
.per_page(5) do |items, num, meta, is_last|是分页的入口,块参数依次代表本页文章、页码(从 1 开始)、分页元数据、是否为最后一页; - 生成页面:首页输出到
/2011/index.html,后续页输出到/2011/page/2.html等,并通过proxy指令把每页数据以locals形式注入目标模板/archive/2011/index.html。
prev_page/next_page的取值逻辑值得留意:首页(num == 1)没有上一页返回nil;第二页的上一页指向首页/2011/index.html;其余页指向page/#{num-1}.html;最后一页由is_last判定,此时next_page为nil,避免生成不存在的“下一页”链接。
三、frontmatter 驱动标签分页:tags 字段的进阶玩法
同一个夹具中的文章全部带有tags: bar字段(如 2011-02-02-test-article.html.markdown 中的tags: bar),因此 paginate.feature 的同一场景还演示了按标签分组的完整流程:
def get_tags(resource) if resource.data.tags.is_a? String resource.data.tags.split(',').map(&:strip) else resource.data.tags end end def group_lookup(resource, sum) results = Array(get_tags(resource)).map(&:to_s).map(&:to_sym) results.each do |k| sum[k] ||= [] sum[k] << resource end end tags = articles .select { |resource| resource.data.tags } .each_with_object({}, &method(:group_lookup)) tags.each do |k, articles_in_tag| articles_in_tag.sort { |a, b| b.data.date <=> a.data.date }.per_page(2).each do |items, num, meta, is_last| page_path = num == 1 ? "/tags/#{k}.html" : "/tags/#{k}/page/#{num}.html" # ...与主分页相同的 prev_page / next_page / proxy 逻辑 end end这里有三个值得注意的设计点:
get_tags对tags字段做了字符串与数组的双重兼容:当 frontmatter 中tags: bar是字符串时按逗号拆分并去空格;当它是数组时直接使用。这让同一套分页代码既能处理单标签文章,也能处理tags: [ruby, web]这种多标签写法;group_lookup把文章按标签符号分组,得到{ bar: [文章列表], ... }这样的 Hash;- 标签分页用
.per_page(2),每页只放 2 篇文章,第一页输出到/tags/bar.html,后续页输出到/tags/bar/page/2.html,与主分页共用/archive/2011/index.html作为模板。
自定义分页粒度是另一个亮点。paginate.feature 的“Custom pager method”场景演示了传入 Proc 而非数字的分页方式:
def items_per_page(all_items) [ all_items.shift(2), all_items ] end articles.sort { |a, b| b.data.date <=> a.data.date }.per_page(method(:items_per_page).to_proc).each do |items, num, meta, is_last| # ...同上,生成 /2011/index.html 与 /2011/page/2.html endper_page接受一个把整份集合切分为若干页数组的 Proc:这里第一页取前 2 篇,第二页容纳剩余全部 5 篇。对照特征文件中的断言,第一页Article Count: 2、Page Start: 1、Page End: 2,第二页Article Count: 5、Page Start: 3、Page End: 7——说明每页的文章数量完全由自定义切分逻辑决定,而不是固定值。
四、模板侧消费分页数据:pagination 与 items 变量的约定
分页产物由 archive/2011/index.html.erb 渲染,它完整展示了模板侧可用的变量:
Paginate: <%= !!pagination %> Article Count: <%= items.length %> <% if pagination %> Page Num: <%= pagination.page_number %> Num Pages: <%= pagination.num_pages %> Per Page: <%= pagination.per_page %> Page Start: <%= pagination.page_start %> Page End: <%= pagination.page_end %> Next Page: '<%= sitemap.find_resource_by_destination_path(next_page).url if next_page %>' Prev Page: '<%= sitemap.find_resource_by_destination_path(prev_page).url if prev_page %>' <% end %> <% items.each do |article| %> <article> <%= article.data.title %> <%= article.url %> </article> <% end %>各变量的含义与来源如下:
| 变量 | 含义 | 数据来源 |
|---|---|---|
pagination | 分页元数据对象(无分页时为 nil,可用!!pagination判断) | config.rb 中locals传入的meta |
items | 当前页的文章列表,items.length即本页文章数 | config.rb 中locals传入的items |
pagination.page_number | 当前页码(从 1 开始) | page_locals计算 |
pagination.num_pages | 总页数 | page_locals计算 |
pagination.per_page | 本页实际文章数 | page_locals计算 |
pagination.page_start/page_end | 本页文章在全集中的起始/结束序号(1 起始),用于渲染“第 X 到 Y 篇” | page_locals计算 |
next_page/prev_page | 上/下一页的目标路径(字符串),由 config.rb 的 case 逻辑算出 | config.rb |
Next Page: '<%= sitemap.find_resource_by_destination_path(next_page).url if next_page %>'一行展示了路径到 URL 的转换方式:next_page在 config.rb 中是形如/2011/page/2.html的目标路径,模板中通过sitemap.find_resource_by_destination_path找到对应资源后再取.url,从而在首页正确输出Next Page: '/2011/page/2.html'。
在 paginate.feature 的断言中可以验证这套约定的实际效果:
- 访问
/2011/index.html时:Article Count: 5、Page Num: 1、Num Pages: 2、Page Start: 1、Page End: 5、Next Page: '/2011/page/2.html',页面上能看到2011-01-03至2011-02-02共 5 篇文章,而最早的两篇(2011-01-01、2011-01-02)被正确地移到了第二页; - 访问
/2011/page/2.html时:Article Count: 2、Page Start: 6、Page End: 7、Prev Page: '/2011/',只剩下两篇最早的旧文章; - 访问
/tags/bar.html时:7 篇文章按每页 2 篇切成 3 页(Num Pages: 3),第一页只包含日期最新的两篇。
五、源码级原理:Array#per_page 与 Collections 扩展如何工作
分页能力并非 Middleman 模板渲染器的内置特性,而是通过 collections.rb 中一段“类级注入”实现的——它把per_page直接装进了 Ruby 原生Array:
require 'middleman-core/core_extensions/collections/pagination' # Super "class-y" injection of array helpers class Array include Middleman::Pagination::ArrayHelpers end这解释了为什么 config.rb 里任何resources.select {...}产生的数组都能直接调用.per_page(...)——它本质上就是Array的一个实例方法。
其核心实现在 pagination.rb 中:
def per_page(per_page) return to_enum(__method__, per_page) unless block_given? parts = if per_page.respond_to? :call per_page.call(dup) else each_slice(per_page).reduce([]) do |sum, items| sum << items end end num_pages = parts.length collection = self current_start_i = 0 parts.each_with_index do |items, i| num = i + 1 meta = ::Middleman::Pagination.page_locals( num, num_pages, collection, items, current_start_i ) yield items, num, meta, num >= num_pages current_start_i += items.length end end从实现可以看到三个关键设计:
- 双重入口:
per_page参数既可以传数字(走each_slice(per_page)按固定大小切分),也可以传可调用对象(respond_to? :call分支直接调用切分函数),这正好对应 paginate.feature 中“Basic configuration”与“Custom pager method”两种场景; - 无块时返回 Enumerator:
return to_enum(__method__, per_page) unless block_given?让per_page在无块调用时返回可枚举对象,支持链式.each写法(自定义分页场景正是per_page(...).each do |...|); - 每页 yield 四个参数:
items(本页文章)、num(页码)、meta(分页元数据)、is_last(是否最后一页,即num >= num_pages),与 config.rb 中的块签名一一对应。
分页元数据由同文件的page_locals方法计算(pagination.rb):
def self.page_locals(page_num, num_pages, collection, items, page_start) per_page = items.length # Index into collection of the last item of this page page_end = (page_start + per_page) - 1 ::Middleman::Util.recursively_enhance(page_number: page_num, num_pages: num_pages, per_page: per_page, page_start: page_start + 1, page_end: [page_end + 1, collection.length].min, collection: collection) end这里把page_start从 0 起始的内部游标转成 1 起始的展示序号(page_start + 1),并用[page_end + 1, collection.length].min确保最后一页的page_end不超过文章总数。模板中看到的Page Start: 6、Page End: 7正是这套计算的产物。
此外,collections.rb 还向模板暴露了一个便捷 helper:
helpers do def pagination current_resource.data.pagination end end即模板中可以直接调用pagination取当前资源的分页元数据——这也是paginate-app的另一份模板 tag.html.erb 中paginate/page_articles等变量的相关基础设施(该模板还展示了通过 frontmatter 声明pageable: true与per_page: 2的另一种分页入口)。
六、把分页能力迁移到自己的 Middleman 项目
综合以上分析,要在自己的 Middleman 项目中落地同样的分页功能,只需三步:
- 准备文章数据源:按 2011-02-02-test-article.html.markdown 的格式,为每篇文章写好 frontmatter(至少包含
title与date,需要标签归档时再加tags); - 在 config.rb 中编写分页配置:用
resources.select筛选文章 → 按data.date倒序 → 调.per_page(N)(或自定义切分 Proc)→ 在块内用proxy生成各页,并把items、pagination、prev_page、next_page通过locals传给模板; - 编写分页模板:参照 archive/2011/index.html.erb,遍历
items输出文章列表,并用pagination.page_number/num_pages/page_start/page_end渲染页码信息,用next_page/prev_page渲染上下页导航。
验证时可对照 paginate.feature 中的断言标准:确认首页/中间页/末页的Article Count、Page Start、Page End、Next Page、Prev Page输出符合预期,且每一页的文章互不重复、无遗漏。
- 前端
- 开发工具
【免费下载链接】middleman
Hand-crafted frontend development
相关推荐
Middleman 博客分页实战:从 frontmatter 文章到 per_page 分页代理
Middleman 博客分页实战:从 frontmatter 文章到 per_page 分页代理 Middleman 的集合(Collections)扩展提供了
前端开发工具Eleventy 博客文章结构与 frontmatter 规范解析:以 Vercel static-build 测试 fixture 为例
Eleventy 博客文章结构与 frontmatter 规范解析:以 Vercel static build 测试 fixture 为例 本文以 Vercel
CLI后端云原生Eleventy 2 博客文章模板实战:以 Vercel 静态构建测试夹具为例解析 Markdown 前置元数据与内容编排
Eleventy 2 博客文章模板实战:以 Vercel 静态构建测试夹具为例解析 Markdown 前置元数据与内容编排 本指南以 Vercel 开源仓库中
CLI后端云原生
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考