├── .gitignore ├── .rspec ├── spec ├── spec_helper.rb └── post_spec.rb ├── Gemfile ├── config.ru ├── app.json ├── LICENSE ├── post.rb ├── README.md ├── server.rb └── Gemfile.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle 2 | .env 3 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 2 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..')) 3 | 4 | require 'post' 5 | require 'rspec' 6 | 7 | ENV['RACK_ENV'] = 'test' 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | ruby '2.5.3' 4 | 5 | gem 'sinatra' 6 | gem 'rack-contrib' 7 | gem 'octokit' 8 | gem 'webmention' 9 | gem 'jekyll' 10 | 11 | group :development do 12 | gem 'shotgun' 13 | gem 'dotenv' 14 | end 15 | 16 | group :test do 17 | gem 'rspec' 18 | end -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 2 | 3 | env = ENV['RACK_ENV'].to_sym 4 | 5 | require "bundler/setup" 6 | Bundler.require(:default, env) 7 | 8 | Dotenv.load if env == :development 9 | 10 | # automatically parse json in the body 11 | use Rack::PostBodyContentTypeParser 12 | 13 | require_relative 'post' 14 | require_relative 'server' 15 | run Server 16 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Webhook Mentions", 3 | "description": "Send webmentions when you add new posts to your GitHub Pages site", 4 | "repository": "https://github.com/barryf/webhook-mentions", 5 | "env": { 6 | "ROOT_URL": { 7 | "description": "The root URL of your GitHub Pages site, e.g. https://barryf.github.io", 8 | "value": "" 9 | }, 10 | "GITHUB_ACCESS_TOKEN": { 11 | "description": "A personal access token for your GitHub Pages site. You will need to grant it the public_repo scope.", 12 | "value": "" 13 | }, 14 | "GITHUB_USER": { 15 | "description": "Your GitHub username, e.g. barryf", 16 | "value": "" 17 | }, 18 | "GITHUB_REPO": { 19 | "description": "Your GitHub Pages site repository name, e.g. barryf.github.io", 20 | "value": "" 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Barry Frost 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /spec/post_spec.rb: -------------------------------------------------------------------------------- 1 | describe Post do 2 | 3 | before do 4 | filename = "_posts/2016-08-24-an-example-post.md" 5 | front_matter_full = <<-EOS 6 | --- 7 | name: A new post 8 | date: #{Time.now.to_s} 9 | slug: my-custom-slug 10 | categories: 11 | - one 12 | - two 13 | --- 14 | Full post. 15 | EOS 16 | @post_full = Post.new(filename, front_matter_full) 17 | front_matter_simple = <<-EOS 18 | --- 19 | name: A simple post 20 | --- 21 | Simple post. 22 | EOS 23 | @post_simple = Post.new(filename, front_matter_simple) 24 | end 25 | 26 | describe "#initialize" do 27 | context "given content with front matter" do 28 | it "should parse the front matter into a hash" do 29 | expect(@post_full.front_matter).to be_a(Hash) 30 | end 31 | end 32 | end 33 | 34 | describe "#categories" do 35 | context "given front matter with two categories" do 36 | it "should return a slash separated list of those categories" do 37 | expect(@post_full.categories).to eql("one/two") 38 | end 39 | end 40 | end 41 | 42 | describe "#slug" do 43 | context "given a custom slug" do 44 | it "should respect that slug" do 45 | expect(@post_full.slug).to eql("my-custom-slug") 46 | end 47 | end 48 | context "with no slug" do 49 | it "should derive a slug from the filename" do 50 | expect(@post_simple.slug).to eql("an-example-post") 51 | end 52 | end 53 | end 54 | 55 | describe "#post_date" do 56 | context "with no date specified" do 57 | it "should parse the date from the filename" do 58 | expect(@post_simple.post_date).to eql(Date.parse("2016-08-24")) 59 | end 60 | end 61 | end 62 | 63 | 64 | end -------------------------------------------------------------------------------- /post.rb: -------------------------------------------------------------------------------- 1 | require 'date' 2 | require 'yaml' 3 | 4 | class Post 5 | 6 | attr_reader :slug, :date, :year, :month, :i_month, :day, :i_day, :short_year, 7 | :hour, :minute, :second, :title, :slug, :categories 8 | 9 | FILENAME_SLUG_REGEX = /^_posts\/[0-9]{4}\-[0-9]{2}\-[0-9]{2}\-([A-Za-z0-9\-\s]*)(\.[a-z]*|\/)?$/ 10 | 11 | def initialize(filename, contents) 12 | @filename = filename 13 | @data = parse_contents(contents) 14 | end 15 | 16 | def parse_contents(contents) 17 | front = contents.split(/---\s*\n/)[1] 18 | YAML.load(front) 19 | end 20 | 21 | def categories 22 | category_set = Set.new 23 | Array(@data["categories"] || []).each do |category| 24 | category_set << category.to_s.downcase 25 | end 26 | category_set.to_a.join("/") 27 | end 28 | 29 | def front_matter 30 | @data 31 | end 32 | 33 | def permalink 34 | @data['permalink'] 35 | end 36 | 37 | def slug 38 | @data['slug'] || FILENAME_SLUG_REGEX.match(@filename)[1] 39 | end 40 | 41 | def title 42 | slug 43 | end 44 | 45 | def post_date 46 | Date.parse( @data['date'] || @filename ) 47 | end 48 | 49 | def date 50 | post_date.strftime("%Y-%m-%d") 51 | end 52 | 53 | def year 54 | post_date.strftime("%Y") 55 | end 56 | 57 | def month 58 | post_date.strftime("%m") 59 | end 60 | 61 | def day 62 | post_date.strftime("%d") 63 | end 64 | 65 | def hour 66 | post_date.strftime("%H") 67 | end 68 | 69 | def minute 70 | post_date.strftime("%M") 71 | end 72 | 73 | def second 74 | post_date.strftime("%S") 75 | end 76 | 77 | def i_day 78 | post_date.strftime("%-d") 79 | end 80 | 81 | def i_month 82 | post_date.strftime("%-m") 83 | end 84 | 85 | def short_month 86 | post_date.strftime("%b") 87 | end 88 | 89 | def short_year 90 | post_date.strftime("%y") 91 | end 92 | 93 | def y_day 94 | post_date.strftime("%j") 95 | end 96 | 97 | end -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Webhook Mentions 2 | 3 | This is a small web app that sends [Webmentions](http://webmention.net) to any links in new/updated posts in a [Jekyll](https://jekyllrb.com)-powered [GitHub Pages](https://pages.github.com) site marked up with [Microformats 2 h-entry markup](http://microformats.org/wiki/microformats2#h-entry). 4 | 5 | Deploy the app and then set up a webhook from your GitHub Pages repository to it and whenever a post is successfully pushed webmentions will be sent to any links. 6 | 7 | Webmention is a technology developed by the [IndieWeb](https://indieweb.org) community. 8 | 9 | ## Deploy 10 | 11 | I recommend running this on Heroku using the _Deploy to Heroku_ button. You can host it yourself but will need to define the environment variables below. 12 | 13 | [![Deploy to Heroku](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy??template=https://github.com/barryf/webhook-mentions) 14 | 15 | Choose a name for your copy of the app, e.g. `barryf-webhook-mentions`. Make a note of this for later. You'll next need to define the following configuration: 16 | 17 | - `ROOT_URL` - the root URL of your GitHub Pages site, e.g. `https://barryf.github.io`. 18 | - `GITHUB_ACCESS_TOKEN` - a personal access token for your GitHub Pages site (see below for help). 19 | - `GITHUB_USER`: your GitHub username, e.g. `barryf`. 20 | - `GITHUB_REPO`: your GitHub Pages site repository name, e.g. `barryf.github.io`. 21 | 22 | #### Personal access token 23 | 24 | 1. Generate a [new personal access token](https://github.com/settings/tokens/new) for your GitHub account. 25 | 2. Give it a description, e.g. `Webhook Mentions`. 26 | 3. Select the `public_repo` scope. 27 | 4. Click the green _Generate token_ button. 28 | 5. Copy the token on the following page and keep this value safe. When the page is closed you cannot view this token again. 29 | 30 | ## Set up a webhook 31 | 32 | 1. Visit your GitHub Pages repository settings page and select _Webhooks & services_. 33 | 2. Create a new webhook by clicking the _Add webhook_ button. 34 | 3. Complete the following fields: 35 | - **Payload URL:** the root of your endpoint on Heroku e.g. `https://barryf-webhook-mentions.herokuapp.com` 36 | - **Content type:** `application/json` 37 | - **Secret:** _Leave blank._ 38 | - **Which events...?** select _Let me select individual events_ and then select _Page build_. 39 | 4. Finish by clicking the green _Add webhook_ button. 40 | 41 | ## Write a new post 42 | 43 | Test the integration out by writing a test post with a link to my blog post at `https://barryfrost.com/2016/07/introducing-webhook-mentions` and pushing it to your GitHub Pages site. A few seconds later you should see the webmention appear below my post. 44 | 45 | Don't forget your post layout will need [Microformats 2 h-entry markup](http://microformats.org/wiki/microformats2#h-entry) or the app will not find the link to my blog post. 46 | 47 | If you have any problems please log an issue and I'll try to help debug. 48 | -------------------------------------------------------------------------------- /server.rb: -------------------------------------------------------------------------------- 1 | require 'base64' 2 | 3 | class Server < Sinatra::Application 4 | 5 | configure do 6 | %w( root_url github_access_token github_user github_repo ).each do |v| 7 | set v.to_sym, ENV.fetch(v.to_s.upcase) 8 | end 9 | end 10 | 11 | get '/' do 12 | 'Webhook Mentions endpoint. See https://github.com/barryf/webhook-mentions for further information.' 13 | end 14 | 15 | post '/' do 16 | require_built_build 17 | commit = get_commit(params['build']['commit']) 18 | commit['files'].each do |file| 19 | next unless file_is_post?(file) 20 | process_file(file) 21 | end 22 | status 200 23 | end 24 | 25 | def require_built_build 26 | unless params.has_key?('build') && params['build'].has_key?('status') && 27 | params['build']['status'] == 'built' 28 | halt "Request must contain successful GitHub Pages build payload." 29 | end 30 | end 31 | 32 | def process_file(file) 33 | logger.info "Processing file #{file['filename']} (#{file['status']})" 34 | url = absolute_url(post_url(file['filename'])) 35 | headers 'Location' => url 36 | logger.info "URL for #{file['filename']} is #{url}" 37 | Webmention::Client.new(url).send_mentions 38 | logger.info "Sent webmention(s) from #{url}" 39 | end 40 | 41 | def file_is_post?(file) 42 | file['filename'].start_with?('_posts/') 43 | end 44 | 45 | def get_commit(sha) 46 | octokit.commit(github_full_repo, sha) 47 | end 48 | 49 | def get_file_contents(filename) 50 | base64_contents = octokit.contents(github_full_repo, { path: filename }).content 51 | Base64.decode64(base64_contents) 52 | end 53 | 54 | def style_to_template(style) 55 | case style.to_sym 56 | when :pretty 57 | "/:categories/:year/:month/:day/:title/" 58 | when :none 59 | "/:categories/:title.html" 60 | when :date 61 | "/:categories/:year/:month/:day/:title.html" 62 | when :ordinal 63 | "/:categories/:year/:y_day/:title.html" 64 | else 65 | style.to_s 66 | end 67 | end 68 | 69 | def get_config_permalink_style 70 | config_yaml = get_file_contents('_config.yml') 71 | config = YAML.load(config_yaml) 72 | style = config['permalink'] || 'date' 73 | permalink_style = style_to_template(style) 74 | logger.info "Permalink style is #{permalink_style}" 75 | permalink_style 76 | end 77 | 78 | def octokit 79 | @octokit ||= Octokit::Client.new(access_token: settings.github_access_token) 80 | end 81 | 82 | def github_full_repo 83 | "#{settings.github_user}/#{settings.github_repo}" 84 | end 85 | 86 | def permalink_style 87 | @permalink_style ||= get_config_permalink_style 88 | end 89 | 90 | def post_url(filename) 91 | contents = get_file_contents(filename) 92 | post = Post.new(filename, contents) 93 | unless post.permalink.nil? 94 | return post.permalink 95 | end 96 | placeholders = {} 97 | [ :slug, :date, :year, :month, :i_month, :day, :i_day, :short_year, 98 | :hour, :minute, :second, :title, :slug, :categories ].each do |ph| 99 | placeholders[ph] = post.send(ph) 100 | end 101 | permalink = Jekyll::URL.new({ 102 | template: permalink_style, 103 | placeholders: placeholders 104 | }).to_s 105 | end 106 | 107 | def absolute_url(relative_url) 108 | slash = relative_url.start_with?("/") ? "" : "/" 109 | settings.root_url + slash + relative_url 110 | end 111 | 112 | end 113 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | absolutely (5.1.0) 5 | addressable (~> 2.7) 6 | addressable (2.8.0) 7 | public_suffix (>= 2.0.2, < 5.0) 8 | colorator (1.1.0) 9 | concurrent-ruby (1.1.9) 10 | diff-lcs (1.4.4) 11 | domain_name (0.5.20190701) 12 | unf (>= 0.0.5, < 1.0.0) 13 | dotenv (2.7.6) 14 | em-websocket (0.5.2) 15 | eventmachine (>= 0.12.9) 16 | http_parser.rb (~> 0.6.0) 17 | eventmachine (1.2.7) 18 | faraday (1.7.1) 19 | faraday-em_http (~> 1.0) 20 | faraday-em_synchrony (~> 1.0) 21 | faraday-excon (~> 1.1) 22 | faraday-httpclient (~> 1.0.1) 23 | faraday-net_http (~> 1.0) 24 | faraday-net_http_persistent (~> 1.1) 25 | faraday-patron (~> 1.0) 26 | faraday-rack (~> 1.0) 27 | multipart-post (>= 1.2, < 3) 28 | ruby2_keywords (>= 0.0.4) 29 | faraday-em_http (1.0.0) 30 | faraday-em_synchrony (1.0.0) 31 | faraday-excon (1.1.0) 32 | faraday-httpclient (1.0.1) 33 | faraday-net_http (1.0.1) 34 | faraday-net_http_persistent (1.2.0) 35 | faraday-patron (1.0.0) 36 | faraday-rack (1.0.0) 37 | ffi (1.15.4) 38 | ffi-compiler (1.0.1) 39 | ffi (>= 1.0.0) 40 | rake 41 | forwardable-extended (2.6.0) 42 | http (4.4.1) 43 | addressable (~> 2.3) 44 | http-cookie (~> 1.0) 45 | http-form_data (~> 2.2) 46 | http-parser (~> 1.2.0) 47 | http-cookie (1.0.4) 48 | domain_name (~> 0.5) 49 | http-form_data (2.3.0) 50 | http-parser (1.2.3) 51 | ffi-compiler (>= 1.0, < 2.0) 52 | http_parser.rb (0.6.0) 53 | i18n (1.8.10) 54 | concurrent-ruby (~> 1.0) 55 | indieweb-endpoints (5.0.0) 56 | absolutely (~> 5.0) 57 | addressable (~> 2.7) 58 | http (~> 4.4) 59 | link-header-parser (~> 2.1) 60 | nokogiri (~> 1.10) 61 | jekyll (4.2.0) 62 | addressable (~> 2.4) 63 | colorator (~> 1.0) 64 | em-websocket (~> 0.5) 65 | i18n (~> 1.0) 66 | jekyll-sass-converter (~> 2.0) 67 | jekyll-watch (~> 2.0) 68 | kramdown (~> 2.3) 69 | kramdown-parser-gfm (~> 1.0) 70 | liquid (~> 4.0) 71 | mercenary (~> 0.4.0) 72 | pathutil (~> 0.9) 73 | rouge (~> 3.0) 74 | safe_yaml (~> 1.0) 75 | terminal-table (~> 2.0) 76 | jekyll-sass-converter (2.1.0) 77 | sassc (> 2.0.1, < 3.0) 78 | jekyll-watch (2.2.1) 79 | listen (~> 3.0) 80 | kramdown (2.3.1) 81 | rexml 82 | kramdown-parser-gfm (1.1.0) 83 | kramdown (~> 2.0) 84 | link-header-parser (2.2.0) 85 | absolutely (~> 5.1) 86 | liquid (4.0.3) 87 | listen (3.7.0) 88 | rb-fsevent (~> 0.10, >= 0.10.3) 89 | rb-inotify (~> 0.9, >= 0.9.10) 90 | mercenary (0.4.0) 91 | mini_portile2 (2.6.1) 92 | multipart-post (2.1.1) 93 | mustermann (1.1.1) 94 | ruby2_keywords (~> 0.0.1) 95 | nokogiri (1.12.5) 96 | mini_portile2 (~> 2.6.1) 97 | racc (~> 1.4) 98 | octokit (4.21.0) 99 | faraday (>= 0.9) 100 | sawyer (~> 0.8.0, >= 0.5.3) 101 | pathutil (0.16.2) 102 | forwardable-extended (~> 2.6) 103 | public_suffix (4.0.6) 104 | racc (1.5.2) 105 | rack (2.2.3.1) 106 | rack-contrib (2.3.0) 107 | rack (~> 2.0) 108 | rack-protection (2.2.0) 109 | rack 110 | rake (13.0.6) 111 | rb-fsevent (0.11.0) 112 | rb-inotify (0.10.1) 113 | ffi (~> 1.0) 114 | rexml (3.2.5) 115 | rouge (3.26.0) 116 | rspec (3.10.0) 117 | rspec-core (~> 3.10.0) 118 | rspec-expectations (~> 3.10.0) 119 | rspec-mocks (~> 3.10.0) 120 | rspec-core (3.10.1) 121 | rspec-support (~> 3.10.0) 122 | rspec-expectations (3.10.1) 123 | diff-lcs (>= 1.2.0, < 2.0) 124 | rspec-support (~> 3.10.0) 125 | rspec-mocks (3.10.2) 126 | diff-lcs (>= 1.2.0, < 2.0) 127 | rspec-support (~> 3.10.0) 128 | rspec-support (3.10.2) 129 | ruby2_keywords (0.0.5) 130 | safe_yaml (1.0.5) 131 | sassc (2.4.0) 132 | ffi (~> 1.9) 133 | sawyer (0.8.2) 134 | addressable (>= 2.3.5) 135 | faraday (> 0.8, < 2.0) 136 | shotgun (0.9.2) 137 | rack (>= 1.0) 138 | sinatra (2.2.0) 139 | mustermann (~> 1.0) 140 | rack (~> 2.2) 141 | rack-protection (= 2.2.0) 142 | tilt (~> 2.0) 143 | terminal-table (2.0.0) 144 | unicode-display_width (~> 1.1, >= 1.1.1) 145 | tilt (2.0.10) 146 | unf (0.1.4) 147 | unf_ext 148 | unf_ext (0.0.7.7) 149 | unicode-display_width (1.7.0) 150 | webmention (5.0.0) 151 | absolutely (~> 5.0) 152 | addressable (~> 2.7) 153 | http (~> 4.4) 154 | indieweb-endpoints (~> 5.0) 155 | nokogiri (~> 1.10) 156 | 157 | PLATFORMS 158 | ruby 159 | 160 | DEPENDENCIES 161 | dotenv 162 | jekyll 163 | octokit 164 | rack-contrib 165 | rspec 166 | shotgun 167 | sinatra 168 | webmention 169 | 170 | RUBY VERSION 171 | ruby 2.5.3p105 172 | 173 | BUNDLED WITH 174 | 2.1.4 175 | --------------------------------------------------------------------------------