commit 0c2ca4e07a5255c5e9f1a56b9ec9d0f6e741eda5 Author: Ben Balter Date: Sun Oct 25 17:21:46 2015 -0400 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0cb6eeb --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/.bundle/ +/.yardoc +/Gemfile.lock +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..8c18f1a --- /dev/null +++ b/.rspec @@ -0,0 +1,2 @@ +--format documentation +--color diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..2552c27 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,7 @@ +rvm: + - 2.2.3 +before_install: gem install bundler +langauage: ruby +script: script/cibuild +sudo: false +cache: bundler diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..1475b55 --- /dev/null +++ b/Gemfile @@ -0,0 +1,6 @@ +source 'https://rubygems.org' + +# Specify your gem's dependencies in jekyll_seo_tags.gemspec +gemspec + +gem 'github-pages' diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..eb3260f --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Ben Balter + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a628422 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# Jekyll SEO Tag + +A Jekyll plugin to add metadata tags for search engines and social networks to better index and display your site's content. + +## What it does + +Jekyll SEO Tag adds the following meta tags to your site: + +* Pages title (with site title appended when available) +* Page description +* Canonical URL +* Next and previous URLs for posts +* [JSON-LD Site and post metadata](https://developers.google.com/structured-data/) for richer indexing +* [Open graph](http://ogp.me/) title, description, site title, and URL (for Facebook, LinkedIn, etc.) +* [Twitter summary card](https://dev.twitter.com/cards/overview) metadata + +## What it doesn't do + +Jekyll SEO tag is designed to output machine-readable metadata for search engines and social networks to index and display. If you're looking for something to analyze your Jekyll site's structure and content (e.g., more traditional SEO optimization), take a look at [The Jekyll SEO Gem](https://github.com/pmarsceill/jekyll-seo-gem). + +## Installation + +1. Add the following to your site's `Gemfile`: + + ```ruby + gem 'jekyll-seo-tag' + ``` + +2. Add the following to your site's `_config.yml`: + + ```yml + gems: + - jekyll-seo-tag + ``` + +3. Add the following right before `` in your site's template(s): + + ```liquid + {% seo %} + ``` + +## Usage + +The SEO tag will respect any of the following if included in your site's `_config.yml` (and simply not include them if they're not defined): + +* `title` - Your site's title (e.g., Ben's awesome site, The GitHub Blog, etc.) +* `description` - A short description (e.g., A blog dedicated to reviewing cat gifs) +* `url` - The full URL to your site. Note: `site.github.url` will be used by default. +* `twitter:username` - The site's Twitter handle. You'll want to describe it like so: + ```yml + twitter: + username: benbalter + ``` +* `logo` - Relative URL to a site-wide logo (e.g., `assets/your-company-logo.png`) +* `social` - For [specifying social profiles](https://developers.google.com/structured-data/customize/social-profiles). The following properties are available: + * `type` - Either `person` or `organization` + * `name` - If the user or organization name differs from the site's name + * `links` - An array of links to social media profiles. + +The SEO tag will respect the following YAML front matter if included in a post, page, or document: + +* `title` - The title of the post, page, or document +* `description` - A short description of the page's content +* `image` - The absolute URL to an image that should be associated with the post, page, or document +* `author` - The username of the post, page, or document author diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..b7e9ed5 --- /dev/null +++ b/Rakefile @@ -0,0 +1,6 @@ +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:spec) + +task :default => :spec diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..a02c8ed --- /dev/null +++ b/bin/console @@ -0,0 +1,14 @@ +#!/usr/bin/env ruby + +require "bundler/setup" +require "jekyll_seo_tags" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +# (If you use this, don't forget to add pry to your Gemfile!) +# require "pry" +# Pry.start + +require "irb" +IRB.start diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..b65ed50 --- /dev/null +++ b/bin/setup @@ -0,0 +1,7 @@ +#!/bin/bash +set -euo pipefail +IFS=$'\n\t' + +bundle install + +# Do any other automated setup that you need to do here diff --git a/jekyll-seo-tag.gemspec b/jekyll-seo-tag.gemspec new file mode 100644 index 0000000..c22c9da --- /dev/null +++ b/jekyll-seo-tag.gemspec @@ -0,0 +1,31 @@ +# coding: utf-8 +lib = File.expand_path('../lib', __FILE__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) + +Gem::Specification.new do |spec| + spec.name = "jekyll-seo-tag" + spec.version = "0.1.0" + spec.authors = ["Ben Balter"] + spec.email = ["ben.balter@github.com"] + spec.summary = %q{A Jekyll plugin to add metadata tags for search engines and social networks to better index and display your site's content.} + spec.homepage = "https://github.com/benbalter/jekyll-seo-tag" + spec.license = "MIT" + + # Prevent pushing this gem to RubyGems.org by setting 'allowed_push_host', or + # delete this section to allow pushing this gem to any host. + if spec.respond_to?(:metadata) + spec.metadata['allowed_push_host'] = "TODO: Set to 'http://mygemserver.com'" + else + raise "RubyGems 2.0 or newer is required to protect against public gem pushes." + end + + spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + + spec.add_dependency "jekyll", ">= 2.4" + spec.add_development_dependency "bundler", "~> 1.10" + spec.add_development_dependency "rake", "~> 10.0" + spec.add_development_dependency "rspec" +end diff --git a/lib/jekyll-seo-tag.rb b/lib/jekyll-seo-tag.rb new file mode 100644 index 0000000..8b09c1b --- /dev/null +++ b/lib/jekyll-seo-tag.rb @@ -0,0 +1,37 @@ +module Jekyll + class SeoTag < Liquid::Tag + + attr_accessor :context + + def render(context) + @context = context + Liquid::Template.parse(template_contents).render!(payload, info).gsub(/[\n\s]{2,}/, "\n") + end + + private + + def payload + { + "page" => context.registers[:page], + "site" => context.registers[:site].site_payload["site"] + } + end + + def info + { + :registers => context.registers, + :filters => [Jekyll::Filters] + } + end + + def template_contents + @template_contents ||= File.read(template_path) + end + + def template_path + @template_path ||= File.expand_path "./template.html", File.dirname(__FILE__) + end + end +end + +Liquid::Template.register_tag('seo', Jekyll::SeoTag) diff --git a/lib/template.html b/lib/template.html new file mode 100644 index 0000000..66e4750 --- /dev/null +++ b/lib/template.html @@ -0,0 +1,117 @@ + + +{% if site.url %} + {% assign seo_url = site.url %} +{% elsif site.github.url %} + {% assign seo_url = site.github.url %} +{% endif %} + +{% if page.title %} + {% assign seo_title = page.title %} + {% if site.title %} + {% assign seo_title = seo_title | append:" - " | append: site.title %} + {% endif %} +{% elsif site.title %} + {% assign seo_title = site.title %} +{% endif %} +{% if seo_title %} + {% assign seo_title = seo_title | escape | markdownify | strip_html | strip_newlines %} +{% endif %} + +{% if page.description %} + {% assign seo_description = page.description %} +{% elsif site.description %} + {% assign seo_description = site.description %} +{% endif %} +{% if seo_description %} + {% assign seo_description = seo_description | escape | markdownify | strip_html | strip_newlines %} +{% endif %} + +{% if seo_title %} + {{ seo_title }} + +{% endif %} + +{% if seo_description %} + + +{% endif %} + +{% if seo_url %} + + +{% endif %} + +{% if site.title %} + + +{% endif %} + +{% if page.image %} + +{% endif %} + +{% if page.date %} + + {% if page.next.url %} + + {% endif %} + {% if page.previous.url %} + + {% endif %} + +{% endif %} + +{% if site.twitter %} + + + + + {% if page.image %} + + {% endif %} + {% if page.author %} + + {% endif %} +{% endif %} + +{% if site.logo %} + +{% endif %} + +{% if site.social %} + +{% endif %} + + diff --git a/script/bootstrap b/script/bootstrap new file mode 100755 index 0000000..654265e --- /dev/null +++ b/script/bootstrap @@ -0,0 +1,5 @@ +#!/bin/sh + +set -ex + +bundle install diff --git a/script/cibuild b/script/cibuild new file mode 100755 index 0000000..861a7c7 --- /dev/null +++ b/script/cibuild @@ -0,0 +1,6 @@ +#!/bin/sh + +set -ex + +bundle exec rake spec +bundle exec gem build jekyll-seo-tag.gemspec diff --git a/script/release b/script/release new file mode 100755 index 0000000..1458842 --- /dev/null +++ b/script/release @@ -0,0 +1,38 @@ +#!/bin/sh +# Tag and push a release. + +set -e + +# Make sure we're in the project root. + +cd $(dirname "$0")/.. + +# Build a new gem archive. + +rm -rf jekyll-seo-tag-*.gem +gem build -q jekyll-seo-tag.gemspec + +# Make sure we're on the master branch. + +(git branch | grep -q '* master') || { + echo "Only release from the master branch." + exit 1 +} + +# Figure out what version we're releasing. + +tag=v`ls jekyll-seo-tag-*.gem | sed 's/^jekyll-seo-tag-\(.*\)\.gem$/\1/'` + +# Make sure we haven't released this version before. + +git fetch -t origin + +(git tag -l | grep -q "$tag") && { + echo "Whoops, there's already a '${tag}' tag." + exit 1 +} + +# Tag it and bag it. + +gem push jekyll-seo-tag-*.gem && git tag "$tag" && + git push origin master && git push origin "$tag" diff --git a/spec/fixtures/_posts/2015-01-01-post.md b/spec/fixtures/_posts/2015-01-01-post.md new file mode 100644 index 0000000..e69de29 diff --git a/spec/fixtures/_posts/2015-01-02-other-post.md b/spec/fixtures/_posts/2015-01-02-other-post.md new file mode 100644 index 0000000..e69de29 diff --git a/spec/fixtures/page.md b/spec/fixtures/page.md new file mode 100644 index 0000000..ef355dc --- /dev/null +++ b/spec/fixtures/page.md @@ -0,0 +1,4 @@ +--- +--- + +# Test diff --git a/spec/jekyll_seo_tag_spec.rb b/spec/jekyll_seo_tag_spec.rb new file mode 100644 index 0000000..a4c5e7f --- /dev/null +++ b/spec/jekyll_seo_tag_spec.rb @@ -0,0 +1,138 @@ +require 'spec_helper' + +describe Jekyll::SeoTag do + + subject { Jekyll::SeoTag.new("seo", nil, nil) } + + it "builds" do + expect(subject.render(context)).to match(/Jekyll SEO tag/i) + end + + it "builds the title with a page title only" do + page = page({"title" => "foo"}) + context = context({ :page => page }) + expect(subject.render(context)).to match(/foo<\/title>/) + expect(subject.render(context)).to match(/<meta property="og:title" content="foo" \/>/) + end + + it "builds the title with a page title and site title" do + page = page({"title" => "foo"}) + site = site({"title" => "bar"}) + context = context({ :page => page, :site => site }) + expect(subject.render(context)).to match(/<title>foo - bar<\/title>/) + end + + it "builds the title with only a site title" do + site = site({"title" => "foo"}) + context = context({ :site => site }) + expect(subject.render(context)).to match(/<title>foo<\/title>/) + end + + it "escapes titles" do + site = site({"title" => "Jekyll & Hyde"}) + context = context({ :site => site }) + expect(subject.render(context)).to match(/<title>Jekyll & Hyde<\/title>/) + end + + it "uses the page description" do + page = page({"description" => "foo"}) + context = context({ :page => page }) + expect(subject.render(context)).to match(/<meta name="description" content="foo" \/>/) + expect(subject.render(context)).to match(/<meta property='og:description' content="foo" \/>/) + end + + it "uses the site description when no page description exists" do + site = site({"description" => "foo"}) + context = context({ :site => site }) + expect(subject.render(context)).to match(/<meta name="description" content="foo" \/>/) + expect(subject.render(context)).to match(/<meta property='og:description' content="foo" \/>/) + end + + it "uses the site url to build the seo url" do + site = site({"url" => "http://example.invalid"}) + context = context({ :site => site }) + expected = /<link rel="canonical" href="http:\/\/example.invalid\/page.html" itemprop="url" \/>/ + expect(subject.render(context)).to match(expected) + expected = /<meta property='og:url' content='http:\/\/example.invalid\/page.html' \/>/ + expect(subject.render(context)).to match(expected) + end + + it "uses site.github.url to build the seo url" do + site = site({"github" => { "url" => "http://example.invalid" }} ) + context = context({ :site => site }) + expected = /<link rel="canonical" href="http:\/\/example.invalid\/page.html" itemprop="url" \/>/ + expect(subject.render(context)).to match(expected) + expected = /<meta property='og:url' content='http:\/\/example.invalid\/page.html' \/>/ + expect(subject.render(context)).to match(expected) + end + + it "outputs the site title meta" do + site = site({"title" => "Foo", "url" => "http://example.invalid"}) + context = context({ :site => site }) + output = subject.render(context) + + expect(output).to match(/<meta property="og:site_name" content="Foo" \/>/) + data = output.match(/<script type=\"application\/ld\+json\">(.*)<\/script>/m)[1] + + data = JSON.parse(data) + expect(data["name"]).to eql("Foo") + expect(data["url"]).to eql("http://example.invalid") + end + + it "outputs post meta" do + post = post({"title" => "post", "description" => "description", "image" => "/img.png" }) + context = context({ :page => post }) + output = subject.render(context) + expected = /<meta property="og:type" content="article" \/>/ + expect(output).to match(expected) + data = output.match(/<script type=\"application\/ld\+json\">(.*)<\/script>/m)[1] + data = JSON.parse(data) + + expect(data["headline"]).to eql("post") + expect(data["description"]).to eql("description") + expect(data["image"]).to eql("/img.png") + end + + it "outputs twitter card meta" do + site = site({"twitter" => { "username" => "jekyllrb" }}) + page = page({"author" => "benbalter"}) + context = context({ :site => site, :page => page }) + + expected = /<meta name="twitter:site" content="@jekyllrb" \/>/ + expect(subject.render(context)).to match(expected) + + expected = /<meta name="twitter:creator" content="@benbalter" \/>/ + expect(subject.render(context)).to match(expected) + end + + it "outputs social meta" do + links = ["http://foo.invalid", "http://bar.invalid"] + site = site({"social" => { "name" => "Ben", "links" => links }}) + context = context({ :site => site }) + output = subject.render(context) + data = output.match(/<script type=\"application\/ld\+json\">(.*)<\/script>/m)[1] + data = JSON.parse(data) + + expect(data["@type"]).to eql("person") + expect(data["name"]).to eql("Ben") + expect(data["sameAs"]).to eql(links) + end + + it "outputs the logo" do + site = site({"logo" => "logo.png", "url" => "http://example.invalid" }) + context = context({ :site => site }) + output = subject.render(context) + data = output.match(/<script type=\"application\/ld\+json\">(.*)<\/script>/m)[1] + data = JSON.parse(data) + + expect(data["logo"]).to eql("http://example.invalid/logo.png") + expect(data["url"]).to eql("http://example.invalid") + end + + it "outputs the image" do + page = page({"image" => "http://foo.invalid/foo.png"}) + context = context({ :page => page }) + expected = /<meta property="og:image" content="http:\/\/foo.invalid\/foo.png" \/>/ + expect(subject.render(context)).to match(expected) + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..a9faba9 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,30 @@ +$LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) +require 'jekyll' +require 'jekyll-seo-tag' + +CONFIG_DEFAULTS = { + "source" => File.expand_path("./fixtures", File.dirname(__FILE__)), + "destination" => File.expand_path("../tmp/dest", File.dirname(__FILE__)), + "gems" => ["jekyll-seo-tag"] +} + +def page(options={}) + page = Jekyll::Page.new site, CONFIG_DEFAULTS["source"], "", "page.md" + page.data = options + page +end + +def post(options={}) + page = Jekyll::Post.new site, CONFIG_DEFAULTS["source"], "", "2015-01-01-post.md" + page.data = options + page +end + +def site(options={}) + config = Jekyll.configuration CONFIG_DEFAULTS.merge(options) + Jekyll::Site.new(config) +end + +def context(registers={}) + Liquid::Context.new({}, {}, { :site => site, :page => page }.merge(registers)) +end