diff --git a/features/hooks.feature b/features/hooks.feature new file mode 100644 index 00000000..b0846bab --- /dev/null +++ b/features/hooks.feature @@ -0,0 +1,327 @@ +Feature: Hooks + As a plugin author + I want to be able to run code during various stages of the build process + + Scenario: Run some code after site reset + Given I have a _plugins directory + And I have a "_plugins/ext.rb" file with content: + """ + Jekyll::Hooks.register :site, :after_reset do |site| + pageklass = Class.new(Jekyll::Page) do + def initialize(site, base) + @site = site + @base = base + @data = {} + @dir = '/' + @name = 'foo.html' + @content = 'mytinypage' + + self.process(@name) + end + end + + site.pages << pageklass.new(site, site.source) + end + """ + When I run jekyll build + Then the _site directory should exist + And I should see "mytinypage" in "_site/foo.html" + + Scenario: Modify the payload before rendering the site + Given I have a _plugins directory + And I have a "index.html" page that contains "{{ site.injected }}!" + And I have a "_plugins/ext.rb" file with content: + """ + Jekyll::Hooks.register :site, :pre_render do |site, payload| + payload['site']['injected'] = 'myparam' + end + """ + When I run jekyll build + Then the _site directory should exist + And I should see "myparam!" in "_site/index.html" + + Scenario: Modify the site contents after reading + Given I have a _plugins directory + And I have a "page1.html" page that contains "page1" + And I have a "page2.html" page that contains "page2" + And I have a "_plugins/ext.rb" file with content: + """ + Jekyll::Hooks.register :site, :post_read do |site| + site.pages.delete_if { |p| p.name == 'page1.html' } + end + """ + When I run jekyll build + Then the _site directory should exist + And the "_site/page1.html" file should not exist + And I should see "page2" in "_site/page2.html" + + Scenario: Work with the site files after they've been written to disk + Given I have a _plugins directory + And I have a "_plugins/ext.rb" file with content: + """ + Jekyll::Hooks.register :site, :post_write do |site| + firstpage = site.pages.first + content = File.read firstpage.destination(site.dest) + File.write(File.join(site.dest, 'firstpage.html'), content) + end + """ + And I have a "page1.html" page that contains "page1" + When I run jekyll build + Then the _site directory should exist + And I should see "page1" in "_site/firstpage.html" + + Scenario: Alter a page right after it is initialized + Given I have a _plugins directory + And I have a "_plugins/ext.rb" file with content: + """ + Jekyll::Hooks.register :page, :post_init do |page| + page.name = 'renamed.html' + page.process(page.name) + end + """ + And I have a "page1.html" page that contains "page1" + When I run jekyll build + Then the _site directory should exist + And I should see "page1" in "_site/renamed.html" + + Scenario: Alter the payload for one page but not another + Given I have a _plugins directory + And I have a "_plugins/ext.rb" file with content: + """ + Jekyll::Hooks.register :page, :pre_render do |page, payload| + payload['myparam'] = 'special' if page.name == 'page1.html' + end + """ + And I have a "page1.html" page that contains "{{ myparam }}" + And I have a "page2.html" page that contains "{{ myparam }}" + When I run jekyll build + Then I should see "special" in "_site/page1.html" + And I should not see "special" in "_site/page2.html" + + Scenario: Modify page contents before writing to disk + Given I have a _plugins directory + And I have a "index.html" page that contains "WRAP ME" + And I have a "_plugins/ext.rb" file with content: + """ + Jekyll::Hooks.register :page, :post_render do |page| + page.output = "{{{{{ #{page.output.chomp} }}}}}" + end + """ + When I run jekyll build + Then I should see "{{{{{ WRAP ME }}}}}" in "_site/index.html" + + Scenario: Work with a page after writing it to disk + Given I have a _plugins directory + And I have a "index.html" page that contains "HELLO FROM A PAGE" + And I have a "_plugins/ext.rb" file with content: + """ + Jekyll::Hooks.register :page, :post_write do |page| + require 'fileutils' + filename = page.destination(page.site.dest) + FileUtils.mv(filename, "#{filename}.moved") + end + """ + When I run jekyll build + Then I should see "HELLO FROM A PAGE" in "_site/index.html.moved" + + Scenario: Alter a post right after it is initialized + Given I have a _plugins directory + And I have a "_plugins/ext.rb" file with content: + """ + # rot13 translate + Jekyll::Hooks.register :post, :post_init do |post| + post.content.tr!('abcdefghijklmnopqrstuvwxyz', + 'nopqrstuvwxyzabcdefghijklm') + end + """ + And I have a _posts directory + And I have the following posts: + | title | date | layout | content | + | entry1 | 2015-03-14 | nil | content for entry1. | + When I run jekyll build + Then the _site directory should exist + And I should see "pbagrag sbe ragel1." in "_site/2015/03/14/entry1.html" + + Scenario: Alter the payload for certain posts + Given I have a _plugins directory + And I have a "_plugins/ext.rb" file with content: + """ + # Add myvar = 'old' to posts before 2015-03-15, and myvar = 'new' for + # others + Jekyll::Hooks.register :post, :pre_render do |post, payload| + if post.date < Time.new(2015, 3, 15) + payload['myvar'] = 'old' + else + payload['myvar'] = 'new' + end + end + """ + And I have a _posts directory + And I have the following posts: + | title | date | layout | content | + | entry1 | 2015-03-14 | nil | {{ myvar }} post | + | entry2 | 2015-03-15 | nil | {{ myvar }} post | + When I run jekyll build + Then I should see "old post" in "_site/2015/03/14/entry1.html" + And I should see "new post" in "_site/2015/03/15/entry2.html" + + Scenario: Modify post contents before writing to disk + Given I have a _plugins directory + And I have a "_plugins/ext.rb" file with content: + """ + # Replace content after rendering + Jekyll::Hooks.register :post, :post_render do |post| + post.output.gsub! /42/, 'the answer to life, the universe and everything' + end + """ + And I have a _posts directory + And I have the following posts: + | title | date | layout | content | + | entry1 | 2015-03-14 | nil | {{ 6 \| times: 7 }} | + | entry2 | 2015-03-15 | nil | {{ 6 \| times: 8 }} | + When I run jekyll build + Then I should see "the answer to life, the universe and everything" in "_site/2015/03/14/entry1.html" + And I should see "48" in "_site/2015/03/15/entry2.html" + + Scenario: Work with a post after writing it to disk + Given I have a _plugins directory + And I have a "_plugins/ext.rb" file with content: + """ + # Log all post filesystem writes + Jekyll::Hooks.register :post, :post_write do |post| + filename = post.destination(post.site.dest) + open('_site/post-build.log', 'a') do |f| + f.puts "Wrote #{filename} at #{Time.now}" + end + end + """ + And I have a _posts directory + And I have the following posts: + | title | date | layout | content | + | entry1 | 2015-03-14 | nil | entry one | + | entry2 | 2015-03-15 | nil | entry two | + When I run jekyll build + Then I should see "_site/2015/03/14/entry1.html at" in "_site/post-build.log" + Then I should see "_site/2015/03/15/entry2.html at" in "_site/post-build.log" + + Scenario: Register a hook on multiple owners at the same time + Given I have a _plugins directory + And I have a "_plugins/ext.rb" file with content: + """ + Jekyll::Hooks.register [:page, :post], :post_render do |owner| + owner.output = "{{{{{ #{owner.output.chomp} }}}}}" + end + """ + And I have a "index.html" page that contains "WRAP ME" + And I have a _posts directory + And I have the following posts: + | title | date | layout | content | + | entry1 | 2015-03-14 | nil | entry one | + When I run jekyll build + Then I should see "{{{{{ WRAP ME }}}}}" in "_site/index.html" + And I should see "{{{{{
entry one
}}}}}" in "_site/2015/03/14/entry1.html" + + Scenario: Allow hooks to have a named priority + Given I have a _plugins directory + And I have a "_plugins/ext.rb" file with content: + """ + Jekyll::Hooks.register :page, :post_render, priority: :normal do |owner| + # first normal runs second + owner.output = "1 #{owner.output.chomp}" + end + Jekyll::Hooks.register :page, :post_render, priority: :high do |owner| + # high runs last + owner.output = "2 #{owner.output.chomp}" + end + Jekyll::Hooks.register :page, :post_render do |owner| + # second normal runs third (normal is default) + owner.output = "3 #{owner.output.chomp}" + end + Jekyll::Hooks.register :page, :post_render, priority: :low do |owner| + # low runs first + owner.output = "4 #{owner.output.chomp}" + end + """ + And I have a "index.html" page that contains "WRAP ME" + When I run jekyll build + Then I should see "2 3 1 4 WRAP ME" in "_site/index.html" + + Scenario: Alter a document right after it is initialized + Given I have a _plugins directory + And I have a "_plugins/ext.rb" file with content: + """ + Jekyll::Hooks.register :document, :pre_render do |doc, payload| + doc.data['text'] = doc.data['text'] << ' are belong to us' + end + """ + And I have a "_config.yml" file that contains "collections: [ memes ]" + And I have a _memes directory + And I have a "_memes/doc1.md" file with content: + """ + --- + text: all your base + --- + """ + And I have an "index.md" file with content: + """ + --- + --- + {{ site.memes.first.text }} + """ + When I run jekyll build + Then the _site directory should exist + And I should see "all your base are belong to us" in "_site/index.html" + + Scenario: Update a document after rendering it, but before writing it to disk + Given I have a _plugins directory + And I have a "_plugins/ext.rb" file with content: + """ + Jekyll::Hooks.register :document, :post_render do |doc| + doc.output.gsub! //, '
' + end + """ + And I have a "_config.yml" file with content: + """ + collections: + memes: + output: true + """ + And I have a _memes directory + And I have a "_memes/doc1.md" file with content: + """ + --- + text: all your base are belong to us + --- + {{ page.text }} + """ + When I run jekyll build + Then the _site directory should exist + And I should see "
all your base are belong to us" in "_site/memes/doc1.html" + + Scenario: Perform an action after every document is written + Given I have a _plugins directory + And I have a "_plugins/ext.rb" file with content: + """ + Jekyll::Hooks.register :document, :post_write do |doc| + open('_site/document-build.log', 'a') do |f| + f.puts "Wrote document #{doc.collection.docs.index doc} at #{Time.now}" + end + end + """ + And I have a "_config.yml" file with content: + """ + collections: + memes: + output: true + """ + And I have a _memes directory + And I have a "_memes/doc1.md" file with content: + """ + --- + text: all your base are belong to us + --- + {{ page.text }} + """ + When I run jekyll build + Then the _site directory should exist + And I should see "Wrote document 0" in "_site/document-build.log" diff --git a/lib/jekyll.rb b/lib/jekyll.rb index 7506553a..58eda759 100644 --- a/lib/jekyll.rb +++ b/lib/jekyll.rb @@ -48,6 +48,7 @@ module Jekyll autoload :External, 'jekyll/external' autoload :Filters, 'jekyll/filters' autoload :FrontmatterDefaults, 'jekyll/frontmatter_defaults' + autoload :Hooks, 'jekyll/hooks' autoload :Layout, 'jekyll/layout' autoload :CollectionReader, 'jekyll/readers/collection_reader' autoload :DataReader, 'jekyll/readers/data_reader' diff --git a/lib/jekyll/convertible.rb b/lib/jekyll/convertible.rb index fdbf3e85..5767ab02 100644 --- a/lib/jekyll/convertible.rb +++ b/lib/jekyll/convertible.rb @@ -236,6 +236,7 @@ module Jekyll # # Returns nothing. def do_layout(payload, layouts) + Jekyll::Hooks.trigger self, :pre_render, payload info = { :filters => [Jekyll::Filters], :registers => { :site => site, :page => payload['page'] } } # render and transform content (this becomes the final content of the object) @@ -249,6 +250,7 @@ module Jekyll self.output = content render_all_layouts(layouts, payload, info) if place_in_layout? + Jekyll::Hooks.trigger self, :post_render end # Write the generated page file to the destination directory. @@ -262,6 +264,7 @@ module Jekyll File.open(path, 'wb') do |f| f.write(output) end + Jekyll::Hooks.trigger self, :post_write end # Accessor for data properties by Liquid. diff --git a/lib/jekyll/document.rb b/lib/jekyll/document.rb index 9cf9ce12..045bdacb 100644 --- a/lib/jekyll/document.rb +++ b/lib/jekyll/document.rb @@ -189,6 +189,8 @@ module Jekyll File.open(path, 'wb') do |f| f.write(output) end + + Jekyll::Hooks.trigger self, :post_write end # Returns merged option hash for File.read of self.site (if exists) diff --git a/lib/jekyll/hooks.rb b/lib/jekyll/hooks.rb new file mode 100644 index 00000000..d01cae4c --- /dev/null +++ b/lib/jekyll/hooks.rb @@ -0,0 +1,107 @@ +module Jekyll + module Hooks + # Helps look up hooks from the registry by owner's class + OWNER_MAP = { + Jekyll::Site => :site, + Jekyll::Page => :page, + Jekyll::Post => :post, + Jekyll::Document => :document, + }.freeze + + DEFAULT_PRIORITY = 20 + + # compatibility layer for octopress-hooks users + PRIORITY_MAP = { + low: 10, + normal: 20, + high: 30, + }.freeze + + # initial empty hooks + @registry = { + :site => { + after_reset: [], + post_read: [], + pre_render: [], + post_write: [], + }, + :page => { + post_init: [], + pre_render: [], + post_render: [], + post_write: [], + }, + :post => { + post_init: [], + pre_render: [], + post_render: [], + post_write: [], + }, + :document => { + pre_render: [], + post_render: [], + post_write: [], + }, + } + + # map of all hooks and their priorities + @hook_priority = {} + + NotAvailable = Class.new(RuntimeError) + Uncallable = Class.new(RuntimeError) + + # register hook(s) to be called later, public API + def self.register(owners, event, priority: DEFAULT_PRIORITY, &block) + Array(owners).each do |owner| + register_one(owner, event, priority_value(priority), &block) + end + end + + # Ensure the priority is a Fixnum + def self.priority_value(priority) + return priority if priority.is_a?(Fixnum) + PRIORITY_MAP[priority] || DEFAULT_PRIORITY + end + + # register a single hook to be called later, internal API + def self.register_one(owner, event, priority, &block) + unless @registry[owner] + raise NotAvailable, "Hooks are only available for the following " << + "classes: #{@registry.keys.inspect}" + end + + unless @registry[owner][event] + raise NotAvailable, "Invalid hook. #{owner} supports only the " << + "following hooks #{@registry[owner].keys.inspect}" + end + + unless block.respond_to? :call + raise Uncallable, "Hooks must respond to :call" + end + + insert_hook owner, event, priority, &block + end + + def self.insert_hook(owner, event, priority, &block) + @hook_priority[block] = "#{priority}.#{@hook_priority.size}".to_f + @registry[owner][event] << block + end + + # interface for Jekyll core components to trigger hooks + def self.trigger(instance, event, *args) + owner_symbol = OWNER_MAP[instance.class] + + # proceed only if there are hooks to call + return unless @registry[owner_symbol] + return unless @registry[owner_symbol][event] + + # hooks to call for this owner and event + hooks = @registry[owner_symbol][event] + + # sort and call hooks according to priority and load order + hooks.sort_by { |h| @hook_priority[h] }.each do |hook| + hook.call(instance, *args) + end + end + end +end diff --git a/lib/jekyll/page.rb b/lib/jekyll/page.rb index 36b12aa1..f9040da8 100644 --- a/lib/jekyll/page.rb +++ b/lib/jekyll/page.rb @@ -35,6 +35,8 @@ module Jekyll data.default_proc = proc do |hash, key| site.frontmatter_defaults.find(File.join(dir, name), type, key) end + + Jekyll::Hooks.trigger self, :post_init end # The generated directory into which the page will be placed diff --git a/lib/jekyll/post.rb b/lib/jekyll/post.rb index 780f40f7..252cca42 100644 --- a/lib/jekyll/post.rb +++ b/lib/jekyll/post.rb @@ -68,6 +68,8 @@ module Jekyll populate_categories populate_tags + + Jekyll::Hooks.trigger self, :post_init end def published? diff --git a/lib/jekyll/renderer.rb b/lib/jekyll/renderer.rb index f25ca343..4a772978 100644 --- a/lib/jekyll/renderer.rb +++ b/lib/jekyll/renderer.rb @@ -35,6 +35,8 @@ module Jekyll "page" => document.to_liquid }, site_payload || site.site_payload) + Jekyll::Hooks.trigger document, :pre_render, payload + info = { filters: [Jekyll::Filters], registers: { :site => site, :page => payload['page'] } diff --git a/lib/jekyll/site.rb b/lib/jekyll/site.rb index 92aa0f39..e7307bbf 100644 --- a/lib/jekyll/site.rb +++ b/lib/jekyll/site.rb @@ -75,6 +75,8 @@ module Jekyll if limit_posts < 0 raise ArgumentError, "limit_posts must be a non-negative number" end + + Jekyll::Hooks.trigger self, :after_reset end # Load necessary libraries, plugins, converters, and generators. @@ -132,6 +134,7 @@ module Jekyll def read reader.read limit_posts! + Jekyll::Hooks.trigger self, :post_read end # Run each of the Generators. @@ -150,15 +153,18 @@ module Jekyll relative_permalinks_are_deprecated payload = site_payload + + Jekyll::Hooks.trigger self, :pre_render, payload + collections.each do |label, collection| collection.docs.each do |document| if regenerator.regenerate?(document) document.output = Jekyll::Renderer.new(self, document, payload).run + Jekyll::Hooks.trigger document, :post_render end end end - payload = site_payload [posts, pages].flatten.each do |page_or_post| if regenerator.regenerate?(page_or_post) page_or_post.render(layouts, payload) @@ -183,6 +189,7 @@ module Jekyll item.write(dest) if regenerator.regenerate?(item) } regenerator.write_metadata + Jekyll::Hooks.trigger self, :post_write end # Construct a Hash of Posts indexed by the specified Post attribute. diff --git a/site/_docs/plugins.md b/site/_docs/plugins.md index 03357c44..54830623 100644 --- a/site/_docs/plugins.md +++ b/site/_docs/plugins.md @@ -470,6 +470,220 @@ module Jekyll end {% endhighlight %} +## Hooks + +
+ In order to use this feature, + install the latest development version of Jekyll. +
+:site
, :page
,
+:post
, and :document
. In all cases, Jekyll calls your
+hooks with the container object as the first callback parameter. But in the
+case of :pre_render
, your hook will also receive a payload hash as
+a second parameter which allows you full control over the variables that are
+available while rendering.
+
+The complete list of available hooks is below:
+
+Container | +Event | +Called | +
---|---|---|
+
|
+
+
|
+
+ Just after site reset + |
+
+
|
+
+
|
+
+ Just before rendering the whole site + |
+
+
|
+
+
|
+
+ After rendering the whole site, but before writing any files + |
+
+
|
+
+
|
+
+ After writing the whole site to disk + |
+
+
|
+
+
|
+
+ Whenever a page is initialized + |
+
+
|
+
+
|
+
+ Just before rendering a page + |
+
+
|
+
+
|
+
+ After rendering a page, but before writing it to disk + |
+
+
|
+
+
|
+
+ After writing a page to disk + |
+
+
|
+
+
|
+
+ Whenever a post is initialized + |
+
+
|
+
+
|
+
+ Just before rendering a post + |
+
+
|
+
+
|
+
+ After rendering a post, but before writing it to disk + |
+
+
|
+
+
|
+
+ After writing a post to disk + |
+
+
|
+
+
|
+
+ Just before rendering a document + |
+
+
|
+
+
|
+
+ After rendering a document, but before writing it to disk + |
+
+
|
+
+
|
+
+ After writing a document to disk + |
+