├── .gitignore ├── .rspec ├── .travis.yml ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── lib ├── xml-sitemap.rb └── xml-sitemap │ ├── index.rb │ ├── item.rb │ ├── map.rb │ ├── options.rb │ ├── render_engine.rb │ └── version.rb ├── spec ├── fixtures │ ├── empty_index.xml │ ├── encoded_image_map.xml │ ├── encoded_map.xml │ ├── encoded_video_map.xml │ ├── group_index.xml │ ├── sample_index.xml │ ├── sample_index_secure.xml │ ├── sample_many_subdomains_index.xml │ ├── saved_map.xml │ ├── simple_map.xml │ └── xhtml_links_map.xml ├── index_spec.rb ├── item_spec.rb ├── map_spec.rb ├── spec_helper.rb └── xmlsitemap_spec.rb └── xml-sitemap.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | *.swp 4 | *.tmproj 5 | *~ 6 | .DS_Store 7 | .\#* 8 | .bundle 9 | .config 10 | .yardoc 11 | Gemfile.lock 12 | InstalledFiles 13 | \#* 14 | _yardoc 15 | coverage 16 | doc/ 17 | lib/bundler/man 18 | pkg 19 | rdoc 20 | spec/reports 21 | test/tmp 22 | test/version_tmp 23 | tmp 24 | tmtags 25 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format=nested 3 | --backtrace 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | rvm: 2 | - 1.9.2 3 | - 1.9.3 4 | - 2.0.0 -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010-2013 Dan Sosedoff 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # XmlSitemap [![Build Status](https://secure.travis-ci.org/sosedoff/xml-sitemap.png?branch=master)](http://travis-ci.org/sosedoff/xml-sitemap) [![Dependency Status](https://www.versioneye.com/ruby/xml-sitemap/badge.svg)](https://www.versioneye.com/ruby/xml-sitemap) 2 | 3 | XmlSitemap is a ruby library that provides an easy way to generate XML sitemaps and indexes. 4 | 5 | It does not have any web-framework dependencies and could be used in any ruby-based application. 6 | 7 | ## Installation 8 | 9 | Install via rubygems: 10 | 11 | ``` 12 | gem install xml-sitemap 13 | ``` 14 | 15 | Or using latest source code: 16 | 17 | ``` 18 | rake install 19 | ``` 20 | 21 | ## Configuration 22 | 23 | Add gem to your Gemfile and you're ready to go: 24 | 25 | ```ruby 26 | gem 'xml-sitemap' 27 | ``` 28 | 29 | ## Usage 30 | 31 | Simple usage: 32 | 33 | ```ruby 34 | map = XmlSitemap::Map.new('domain.com') do |m| 35 | # Adds a simple page 36 | m.add '/page1' 37 | 38 | # You can drop leading slash, it will be automatically added 39 | m.add 'page2' 40 | 41 | # Set the page priority 42 | m.add 'page3', :priority => 0.2 43 | 44 | # Specify last modification date and update frequiency 45 | m.add 'page4', :updated => Date.today, :period => :never 46 | end 47 | ``` 48 | 49 | Render map output: 50 | 51 | ```ruby 52 | # Render the sitemap XML 53 | map.render 54 | 55 | # Render and save XML to the output file 56 | map.render_to('/path/to/file.xml') 57 | 58 | # You can also use compression 59 | map.render_to('/path/to/file.xml.gz', :gzip => true) 60 | 61 | # If you didnt specify .gz extension to the filename, 62 | # XmlSitemap will automatically append it 63 | # => content will be saved to /path/to/file.xml.gz 64 | map.render_to('/path/to/file.xml', :gzip => true) 65 | ``` 66 | 67 | You can also create a map via shortcut: 68 | 69 | ```ruby 70 | map = XmlSitemap.new('foobar.com') 71 | map = XmlSitemap.map('foobar.com') 72 | ``` 73 | 74 | By default XmlSitemap creates a map with link to homepage of your domain. 75 | 76 | Homepage priority is `1.0`. 77 | 78 | List of available update periods: 79 | 80 | - `:none` 81 | - `:always` 82 | - `:hourly` 83 | - `:daily` 84 | - `:weekly` 85 | - `:monthly` 86 | - `:yearly` 87 | - `:never` 88 | 89 | ### Generating Maps 90 | 91 | When creating a new map object, you can specify a set of options. 92 | 93 | ```ruby 94 | map = XmlSitemap::Map.new('mydomain.com', options) 95 | ``` 96 | 97 | Available options: 98 | 99 | - `:secure` - Will add all sitemap items with https prefix. *(default: false)* 100 | - `:home` - Disable homepage autocreation, but you still can do that manually. *(default: true)* 101 | - `:root` - Force all links to fall under the main domain. You can add full urls (not paths) if set to false. *(default: true)* 102 | - `:time` - Provide a creation time for the sitemap. (default: current time) 103 | - `:group` - Group name for sitemap index. *(default: sitemap)* 104 | 105 | ### Render Engines 106 | 107 | XmlSitemap has a few different rendering engines. You can select one passing argument to `render` method. 108 | 109 | Available engines: 110 | 111 | - `:string` - Uses plain strings (for performance). Default. 112 | - `:builder` - Uses Builder::XmlMarkup. 113 | - `:nokogiri` - Uses Nokogiri library. Requires `nokogiri` gem. 114 | 115 | ### Sitemap Indexes 116 | 117 | Regular sitemap does not support more than 50k records, so if you're generating a huge sitemap you need to use XmlSitemap::Index. 118 | 119 | Index is just a collection of links to all the website's sitemaps. 120 | 121 | Usage: 122 | 123 | ```ruby 124 | map = XmlSitemap::Map.new('domain.com') 125 | map.add 'page' 126 | 127 | index = XmlSitemap::Index.new 128 | 129 | # or if you want the URLs to use HTTPS 130 | index = XmlSitemap::Index.new(:secure => true) 131 | 132 | # or via shortcut 133 | index = XmlSitemap.index 134 | 135 | # Add a map to the index 136 | index.add(map) 137 | 138 | # Render as XML 139 | index.render 140 | 141 | # Render XML to the output file 142 | index.render_to('/path/to/file.xml') 143 | ``` 144 | 145 | ## Testing 146 | 147 | To execute test suite run: 148 | 149 | ``` 150 | bundle exec rake test 151 | ``` 152 | 153 | ## Contributing 154 | 155 | 1. Fork it 156 | 2. Create your feature branch (`git checkout -b my-new-feature`) 157 | 3. Commit your changes (`git commit -am 'Add some feature'`) 158 | 4. Push to the branch (`git push origin my-new-feature`) 159 | 5. Create new Pull Request 160 | 161 | ## License 162 | 163 | Copyright (c) 2010-2013 Dan Sosedoff. 164 | 165 | Permission is hereby granted, free of charge, to any person obtaining a copy of 166 | this software and associated documentation files (the "Software"), to deal in 167 | the Software without restriction, including without limitation the rights to 168 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 169 | the Software, and to permit persons to whom the Software is furnished to do so, 170 | subject to the following conditions: 171 | 172 | The above copyright notice and this permission notice shall be included in all 173 | copies or substantial portions of the Software. 174 | 175 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 176 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 177 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 178 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 179 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 180 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 181 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | require 'bundler/gem_tasks' 3 | require "rspec/core/rake_task" 4 | 5 | RSpec::Core::RakeTask.new(:test) do |t| 6 | t.pattern = 'spec/*_spec.rb' 7 | t.verbose = false 8 | end 9 | 10 | task :default => :test -------------------------------------------------------------------------------- /lib/xml-sitemap.rb: -------------------------------------------------------------------------------- 1 | require 'time' 2 | require 'date' 3 | require 'zlib' 4 | require 'builder' 5 | require 'cgi' 6 | begin 7 | require 'nokogiri' 8 | rescue LoadError 9 | end 10 | 11 | require 'xml-sitemap/version' 12 | require 'xml-sitemap/options' 13 | require 'xml-sitemap/render_engine' 14 | require 'xml-sitemap/item' 15 | require 'xml-sitemap/map' 16 | require 'xml-sitemap/index' 17 | 18 | module XmlSitemap 19 | class << self 20 | # Shortcut to XmlSitemap::Map.new 21 | # 22 | # domain - Primary domain 23 | # options - Map options 24 | # 25 | def map(domain, options={}) 26 | XmlSitemap::Map.new(domain, options) 27 | end 28 | 29 | alias :new :map 30 | 31 | # Shortcut to XmlSitemap::Index.new 32 | # 33 | # options - Index options 34 | # 35 | def index(options={}) 36 | XmlSitemap::Index.new(options) 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/xml-sitemap/index.rb: -------------------------------------------------------------------------------- 1 | module XmlSitemap 2 | class Index 3 | attr_reader :maps 4 | 5 | # Initialize a new Index instance 6 | # 7 | # opts - Index options 8 | # 9 | # opts[:secure] - Force HTTPS for all items. (default: false) 10 | # 11 | def initialize(opts={}) 12 | @maps = [] 13 | @offsets = Hash.new(0) 14 | @secure = opts[:secure] || false 15 | 16 | yield self if block_given? 17 | end 18 | 19 | # Add map object to index 20 | # 21 | # map - XmlSitemap::Map instance 22 | # 23 | def add(map, use_offsets=true) 24 | raise ArgumentError, 'XmlSitemap::Map object required!' unless map.kind_of?(XmlSitemap::Map) 25 | raise ArgumentError, 'Map is empty!' if map.empty? 26 | 27 | @maps << { 28 | :loc => use_offsets ? map.index_url(@offsets[map.group], @secure) : map.plain_index_url(@secure), 29 | :lastmod => map.created_at.utc.iso8601 30 | } 31 | @offsets[map.group] += 1 32 | end 33 | 34 | # Generate sitemap XML index 35 | # 36 | def render 37 | xml = Builder::XmlMarkup.new(:indent => 2) 38 | xml.instruct!(:xml, :version => '1.0', :encoding => 'UTF-8') 39 | xml.sitemapindex(XmlSitemap::INDEX_SCHEMA_OPTIONS) { |s| 40 | @maps.each do |item| 41 | s.sitemap do |m| 42 | m.loc item[:loc] 43 | m.lastmod item[:lastmod] 44 | end 45 | end 46 | }.to_s 47 | end 48 | 49 | # Render XML sitemap index into the file 50 | # 51 | # path - Output filename 52 | # options - Options hash 53 | # 54 | # options[:ovewrite] - Overwrite file contents (default: true) 55 | # 56 | def render_to(path, options={}) 57 | overwrite = options[:overwrite] || true 58 | path = File.expand_path(path) 59 | 60 | if File.exists?(path) && !overwrite 61 | raise RuntimeError, "File already exists and not overwritable!" 62 | end 63 | 64 | File.open(path, 'w') { |f| f.write(self.render) } 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/xml-sitemap/item.rb: -------------------------------------------------------------------------------- 1 | module XmlSitemap 2 | class Item 3 | DEFAULT_PRIORITY = 0.5 4 | 5 | # ISO8601 regex from here: http://www.pelagodesign.com/blog/2009/05/20/iso-8601-date-validation-that-doesnt-suck/ 6 | ISO8601_REGEX = /^([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T\s]((([01]\d|2[0-3])((:?)[0-5]\d)?|24\:?00)([\.,]\d+(?!:))?)?(\17[0-5]\d([\.,]\d+)?)?([zZ]|([\+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?$/ 7 | 8 | attr_reader :target, :updated, :priority, :changefreq, :validate_time, :xhtml_links, :image_location, :image_caption, :image_geolocation, :image_title, :image_license, 9 | :video_thumbnail_location, :video_title, :video_description, :video_content_location, :video_player_location, 10 | :video_duration, :video_expiration_date, :video_rating, :video_view_count, :video_publication_date, :video_family_friendly, :video_category, 11 | :video_restriction, :video_gallery_location, :video_price, :video_requires_subscription, :video_uploader, :video_platform, :video_live 12 | 13 | def initialize(target, opts={}) 14 | @target = target.to_s.strip 15 | @updated = opts[:updated] || Time.now 16 | @priority = opts[:priority] 17 | @changefreq = opts[:period] 18 | @validate_time = (opts[:validate_time] != false) 19 | @xhtml_links = opts[:xhtml_links] || [] 20 | 21 | # Refer to http://support.google.com/webmasters/bin/answer.py?hl=en&answer=178636 for requirement to support images in sitemap 22 | @image_location = opts[:image_location] 23 | @image_caption = opts[:image_caption] 24 | @image_geolocation = opts[:image_geolocation] 25 | @image_title = opts[:image_title] 26 | @image_license = opts[:image_license] 27 | 28 | # Refer to http://support.google.com/webmasters/bin/answer.py?hl=en&answer=80472&topic=10079&ctx=topic#2 for requirement to support videos in sitemap 29 | @video_thumbnail_location = opts[:video_thumbnail_location] 30 | @video_title = opts[:video_title] 31 | @video_description = opts[:video_description] 32 | @video_content_location = opts[:video_content_location] 33 | @video_player_location = opts[:video_player_location] 34 | @video_duration = opts[:video_duration] 35 | @video_expiration_date = opts[:video_expiration_date] 36 | @video_rating = opts[:video_rating] 37 | @video_view_count = opts[:video_view_count] 38 | @video_publication_date = opts[:video_publication_date] 39 | @video_family_friendly = opts[:video_family_friendly] 40 | # tag 41 | @video_category = opts[:video_category] 42 | @video_restriction = opts[:video_restriction] 43 | @video_gallery_location = opts[:video_gallery_location] 44 | @video_price = opts[:video_price] 45 | @video_requires_subscription = opts[:video_requires_subscription] 46 | @video_uploader = opts[:video_uploader] 47 | @video_platform = opts[:video_platform] 48 | @video_live = opts[:video_live] 49 | 50 | if @changefreq 51 | @changefreq = @changefreq.to_sym 52 | unless XmlSitemap::PERIODS.include?(@changefreq) 53 | raise ArgumentError, "Invalid :period value '#{@changefreq}'" 54 | end 55 | end 56 | 57 | unless @updated.kind_of?(Time) || @updated.kind_of?(Date) || @updated.kind_of?(String) 58 | raise ArgumentError, "Time, Date, or ISO8601 String required for :updated!" 59 | end 60 | 61 | if @validate_time && @updated.kind_of?(String) && !(@updated =~ ISO8601_REGEX) 62 | raise ArgumentError, "String provided to :updated did not match ISO8601 standard!" 63 | end 64 | 65 | @updated = @updated.to_time if @updated.kind_of?(Date) 66 | 67 | ############################################################################################## 68 | ############################################################################################## 69 | 70 | unless @video_expiration_date.kind_of?(Time) || @video_expiration_date.kind_of?(Date) || @video_expiration_date.kind_of?(String) 71 | raise ArgumentError, "Time, Date, or ISO8601 String required for :video_expiration_date!" unless @video_expiration_date.nil? 72 | end 73 | 74 | if @validate_time && @video_expiration_date.kind_of?(String) && !(@video_expiration_date =~ ISO8601_REGEX) 75 | raise ArgumentError, "String provided to :video_expiration_date did not match ISO8601 standard!" 76 | end 77 | 78 | @video_expiration_date = @video_expiration_date.to_time if @video_expiration_date.kind_of?(Date) 79 | 80 | ############################################################################################## 81 | ############################################################################################## 82 | 83 | unless @video_publication_date.kind_of?(Time) || @video_publication_date.kind_of?(Date) || @video_publication_date.kind_of?(String) 84 | raise ArgumentError, "Time, Date, or ISO8601 String required for :video_publication_date!" unless @video_publication_date.nil? 85 | end 86 | 87 | if @validate_time && @video_publication_date.kind_of?(String) && !(@video_publication_date =~ ISO8601_REGEX) 88 | raise ArgumentError, "String provided to :video_publication_date did not match ISO8601 standard!" 89 | end 90 | 91 | @video_publication_date = @video_publication_date.to_time if @video_publication_date.kind_of?(Date) 92 | end 93 | 94 | # Returns the timestamp value of lastmod for renderer 95 | # 96 | def lastmod_value 97 | if @updated.kind_of?(Time) 98 | @updated.utc.iso8601 99 | else 100 | @updated.to_s 101 | end 102 | end 103 | 104 | # Returns the timestamp value of video:expiration_date for renderer 105 | # 106 | def video_expiration_date_value 107 | if @video_expiration_date.kind_of?(Time) 108 | @video_expiration_date.utc.iso8601 109 | else 110 | @video_expiration_date.to_s 111 | end 112 | end 113 | 114 | # Returns the timestamp value of video:publication_date for renderer 115 | # 116 | def video_publication_date_value 117 | if @video_publication_date.kind_of?(Time) 118 | @video_publication_date.utc.iso8601 119 | else 120 | @video_publication_date.to_s 121 | end 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /lib/xml-sitemap/map.rb: -------------------------------------------------------------------------------- 1 | module XmlSitemap 2 | class Map 3 | include XmlSitemap::RenderEngine 4 | 5 | attr_reader :domain, :items 6 | attr_reader :buffer 7 | attr_reader :created_at 8 | attr_reader :root 9 | attr_reader :group 10 | 11 | # Initializa a new Map instance 12 | # 13 | # domain - Primary domain for the map (required) 14 | # opts - Map options 15 | # 16 | # opts[:home] - Automatic homepage creation. To disable set to false. (default: true) 17 | # opts[:secure] - Force HTTPS for all items. (default: false) 18 | # opts[:time] - Set default lastmod timestamp for items (default: current time) 19 | # opts[:group] - Group name for sitemap index. (default: sitemap) 20 | # opts[:root] - Force all links to fall under the main domain. 21 | # You can add full urls (not paths) if set to false. (default: true) 22 | # 23 | def initialize(domain, opts={}) 24 | @domain = domain.to_s.strip 25 | raise ArgumentError, 'Domain required!' if @domain.empty? 26 | 27 | @created_at = opts[:time] || Time.now.utc 28 | @secure = opts[:secure] || false 29 | @home = opts.key?(:home) ? opts[:home] : true 30 | @root = opts.key?(:root) ? opts[:root] : true 31 | @group = opts[:group] || "sitemap" 32 | @items = [] 33 | 34 | self.add('/', :priority => 1.0) if @home === true 35 | 36 | yield self if block_given? 37 | end 38 | 39 | # Adds a new item to the map 40 | # 41 | # target - Path or url 42 | # opts - Item options 43 | # 44 | # opts[:updated] - Lastmod property of the item 45 | # opts[:period] - Update frequency. 46 | # opts[:priority] - Item priority. 47 | # opts[:validate_time] - Skip time validation if want to insert raw strings. 48 | # opts[:xhtml_links] - Array of xhtml:link. 49 | # 50 | def add(target, opts={}) 51 | raise RuntimeError, 'Only up to 50k records allowed!' if @items.size > 50000 52 | raise ArgumentError, 'Target required!' if target.nil? 53 | raise ArgumentError, 'Target is empty!' if target.to_s.strip.empty? 54 | 55 | url = process_target(target) 56 | 57 | if url.length > 2048 58 | raise ArgumentError, "Target can't be longer than 2,048 characters!" 59 | end 60 | 61 | opts[:updated] = @created_at unless opts.key?(:updated) 62 | item = XmlSitemap::Item.new(url, opts) 63 | @items << item 64 | item 65 | end 66 | 67 | # Get map items count 68 | # 69 | def size 70 | @items.size 71 | end 72 | 73 | # Returns true if sitemap does not have any items 74 | # 75 | def empty? 76 | @items.empty? 77 | end 78 | 79 | # Generate full url for path 80 | # 81 | def url(path='') 82 | "#{@secure ? 'https' : 'http'}://#{@domain}#{path}" 83 | end 84 | 85 | # Get full url for index 86 | # 87 | def index_url(offset, secure) 88 | "#{secure ? 'https' : 'http'}://#{@domain}/#{@group}-#{offset}.xml" 89 | end 90 | 91 | def plain_index_url(secure) 92 | "#{secure ? 'https' : 'http'}://#{@domain}/#{@group}.xml" 93 | end 94 | 95 | # Render XML 96 | # 97 | # method - Pick a render engine (:builder, :nokogiri, :string). 98 | # Default is :string 99 | # 100 | def render(method = :string) 101 | case method 102 | when :nokogiri 103 | render_nokogiri 104 | when :builder 105 | render_builder 106 | else 107 | render_string 108 | end 109 | end 110 | 111 | # Render XML sitemap into the file 112 | # 113 | # path - Output filename 114 | # options - Options hash 115 | # 116 | # options[:overwrite] - Overwrite the file contents (default: true) 117 | # options[:gzip] - Gzip file contents (default: false) 118 | # 119 | def render_to(path, options={}) 120 | overwrite = options[:overwrite] == true || true 121 | compress = options[:gzip] == true || false 122 | 123 | path = File.expand_path(path) 124 | path << ".gz" unless path =~ /\.gz\z/i if compress 125 | 126 | if File.exists?(path) && !overwrite 127 | raise RuntimeError, "File already exists and not overwritable!" 128 | end 129 | 130 | File.open(path, 'wb') do |f| 131 | unless compress 132 | f.write(self.render) 133 | else 134 | gz = Zlib::GzipWriter.new(f) 135 | gz.write(self.render) 136 | gz.close 137 | end 138 | end 139 | end 140 | 141 | protected 142 | 143 | # Process target path or url 144 | # 145 | def process_target(str) 146 | if @root == true 147 | url(str =~ /^\// ? str : "/#{str}") 148 | else 149 | str =~ /^(http|https)/i ? str : url(str =~ /^\// ? str : "/#{str}") 150 | end 151 | end 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /lib/xml-sitemap/options.rb: -------------------------------------------------------------------------------- 1 | module XmlSitemap 2 | PERIODS = [ 3 | :none, 4 | :always, 5 | :hourly, 6 | :daily, 7 | :weekly, 8 | :monthly, 9 | :yearly, 10 | :never 11 | ].freeze 12 | 13 | MAP_SCHEMA_OPTIONS = { 14 | 'xsi:schemaLocation' => "http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd", 15 | 'xmlns:xhtml' => "http://www.w3.org/1999/xhtml", 16 | 'xmlns:xsi' => "http://www.w3.org/2001/XMLSchema-instance", 17 | 'xmlns:image' => "http://www.google.com/schemas/sitemap-image/1.1", 18 | 'xmlns:video' => "http://www.google.com/schemas/sitemap-video/1.1", 19 | 'xmlns' => "http://www.sitemaps.org/schemas/sitemap/0.9" 20 | }.freeze 21 | 22 | INDEX_SCHEMA_OPTIONS = { 23 | 'xsi:schemaLocation' => "http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/siteindex.xsd", 24 | 'xmlns:xsi' => "http://www.w3.org/2001/XMLSchema-instance", 25 | 'xmlns' => "http://www.sitemaps.org/schemas/sitemap/0.9" 26 | }.freeze 27 | end 28 | -------------------------------------------------------------------------------- /lib/xml-sitemap/render_engine.rb: -------------------------------------------------------------------------------- 1 | module XmlSitemap 2 | module RenderEngine 3 | private 4 | 5 | # Render with Nokogiri gem 6 | # 7 | def render_nokogiri 8 | unless defined? Nokogiri 9 | raise ArgumentError, "Nokogiri not found!" 10 | end 11 | 12 | builder = Nokogiri::XML::Builder.new(:encoding => "UTF-8") do |xml| 13 | xml.urlset(XmlSitemap::MAP_SCHEMA_OPTIONS) { |s| 14 | @items.each do |item| 15 | s.url do |u| 16 | u.loc item.target 17 | 18 | # Format and image tag specifications found at http://support.google.com/webmasters/bin/answer.py?hl=en&answer=178636 19 | if item.image_location 20 | u["image"].image do |a| 21 | a["image"].loc item.image_location 22 | a["image"].caption item.image_caption if item.image_caption 23 | a["image"].title item.image_title if item.image_title 24 | a["image"].license item.image_license if item.image_license 25 | a["image"].geo_location item.image_geolocation if item.image_geolocation 26 | end 27 | end 28 | 29 | # Format and video tag specifications found at http://support.google.com/webmasters/bin/answer.py?hl=en&answer=80472&topic=10079&ctx=topic#2 30 | if item.video_thumbnail_location && item.video_title && item.video_description && (item.video_content_location || item.video_player_location) 31 | u["video"].video do |a| 32 | a["video"].thumbnail_loc item.video_thumbnail_location 33 | a["video"].title item.video_title 34 | a["video"].description item.video_description 35 | a["video"].content_loc item.video_content_location if item.video_content_location 36 | a["video"].player_loc item.video_player_location if item.video_player_location 37 | a["video"].duration item.video_duration.to_s if item.video_duration 38 | a["video"].expiration_date item.video_expiration_date_value if item.video_expiration_date 39 | a["video"].rating item.video_rating.to_s if item.video_rating 40 | a["video"].view_count item.video_view_count.to_s if item.video_view_count 41 | a["video"].publication_date item.video_publication_date_value if item.video_publication_date 42 | a["video"].family_friendly item.video_family_friendly if item.video_family_friendly 43 | a["video"].category item.video_category if item.video_category 44 | a["video"].restriction item.video_restriction, :relationship => "allow" if item.video_restriction 45 | a["video"].gallery_loc item.video_gallery_location if item.video_gallery_location 46 | a["video"].price item.video_price.to_s, :currency => "USD" if item.video_price 47 | a["video"].requires_subscription item.video_requires_subscription if item.video_requires_subscription 48 | a["video"].uploader item.video_uploader if item.video_uploader 49 | a["video"].platform item.video_platform, :relationship => "allow" if item.video_platform 50 | a["video"].live item.video_live if item.video_live 51 | end 52 | end 53 | 54 | u.lastmod item.lastmod_value 55 | u.changefreq item.changefreq.to_s if item.changefreq 56 | u.priority item.priority.to_s if item.priority 57 | end 58 | end 59 | } 60 | end 61 | builder.to_xml 62 | end 63 | 64 | # Render with Builder gem 65 | # 66 | def render_builder 67 | xml = Builder::XmlMarkup.new(:indent => 2) 68 | xml.instruct!(:xml, :version => '1.0', :encoding => 'UTF-8') 69 | 70 | xml.urlset(XmlSitemap::MAP_SCHEMA_OPTIONS) { |s| 71 | @items.each do |item| 72 | s.url do |u| 73 | u.loc item.target 74 | 75 | # Format and image tag specifications found at http://support.google.com/webmasters/bin/answer.py?hl=en&answer=178636 76 | if item.image_location 77 | u.image :image do |a| 78 | a.tag! "image:loc", CGI::escapeHTML(item.image_location) 79 | a.tag! "image:caption", CGI::escapeHTML(item.image_caption) if item.image_caption 80 | a.tag! "image:title", CGI::escapeHTML(item.image_title) if item.image_title 81 | a.tag! "image:license", CGI::escapeHTML(item.image_license) if item.image_license 82 | a.tag! "image:geo_location", CGI::escapeHTML(item.image_geolocation) if item.image_geolocation 83 | end 84 | end 85 | 86 | # Format and video tag specifications found at http://support.google.com/webmasters/bin/answer.py?hl=en&answer=80472&topic=10079&ctx=topic#2 87 | if item.video_thumbnail_location && item.video_title && item.video_description && (item.video_content_location || item.video_player_location) 88 | u.video :video do |a| 89 | a.tag! "video:thumbnail_loc", CGI::escapeHTML(item.video_thumbnail_location) 90 | a.tag! "video:title", CGI::escapeHTML(item.video_title) 91 | a.tag! "video:description", CGI::escapeHTML(item.video_description) 92 | a.tag! "video:content_loc", CGI::escapeHTML(item.video_content_location) if item.video_content_location 93 | a.tag! "video:player_loc", CGI::escapeHTML(item.video_player_location) if item.video_player_location 94 | a.tag! "video:duration", CGI::escapeHTML(item.video_duration.to_s) if item.video_duration 95 | a.tag! "video:expiration_date", CGI::escapeHTML(item.video_expiration_date_value) if item.video_expiration_date 96 | a.tag! "video:rating", CGI::escapeHTML(item.video_rating.to_s) if item.video_rating 97 | a.tag! "video:view_count", CGI::escapeHTML(item.video_view_count.to_s) if item.video_view_count 98 | a.tag! "video:publication_date", CGI::escapeHTML(item.video_publication_date_value) if item.video_publication_date 99 | a.tag! "video:family_friendly", CGI::escapeHTML(item.video_family_friendly) if item.video_family_friendly 100 | a.tag! "video:category", CGI::escapeHTML(item.video_category) if item.video_category 101 | a.tag! "video:restriction", CGI::escapeHTML(item.video_restriction), :relationship => "allow" if item.video_restriction 102 | a.tag! "video:gallery_loc", CGI::escapeHTML(item.video_gallery_location) if item.video_gallery_location 103 | a.tag! "video:price", CGI::escapeHTML(item.video_price.to_s), :currency => "USD" if item.video_price 104 | a.tag! "video:requires_subscription", CGI::escapeHTML(item.video_requires_subscription) if item.video_requires_subscription 105 | a.tag! "video:uploader", CGI::escapeHTML(item.video_uploader) if item.video_uploader 106 | a.tag! "video:platform", CGI::escapeHTML(item.video_platform), :relationship => "allow" if item.video_platform 107 | a.tag! "video:live", CGI::escapeHTML(item.video_live) if item.video_live 108 | end 109 | end 110 | 111 | u.lastmod item.lastmod_value 112 | u.changefreq item.changefreq.to_s if item.changefreq 113 | u.priority item.priority.to_s if item.priority 114 | end 115 | end 116 | }.to_s 117 | end 118 | 119 | # Render with plain strings 120 | # 121 | def render_string 122 | result = '' + "\n\n" 129 | 130 | item_results = [] 131 | @items.each do |item| 132 | item_string = " \n" 133 | item_string << " #{CGI::escapeHTML(item.target)}\n" 134 | 135 | # Format and image tag specifications found at http://support.google.com/webmasters/bin/answer.py?hl=en&answer=178636 136 | if item.image_location 137 | item_string << " \n" 138 | item_string << " #{CGI::escapeHTML(item.image_location)}\n" 139 | item_string << " #{CGI::escapeHTML(item.image_caption)}\n" if item.image_caption 140 | item_string << " #{CGI::escapeHTML(item.image_title)}\n" if item.image_title 141 | item_string << " #{CGI::escapeHTML(item.image_license)}\n" if item.image_license 142 | item_string << " #{CGI::escapeHTML(item.image_geolocation)}\n" if item.image_geolocation 143 | item_string << " \n" 144 | end 145 | 146 | # Format and video tag specifications found at http://support.google.com/webmasters/bin/answer.py?hl=en&answer=80472&topic=10079&ctx=topic#2 147 | if item.video_thumbnail_location && item.video_title && item.video_description && (item.video_content_location || item.video_player_location) 148 | item_string << " \n" 149 | item_string << " #{CGI::escapeHTML(item.video_thumbnail_location)}\n" 150 | item_string << " #{CGI::escapeHTML(item.video_title)}\n" 151 | item_string << " #{CGI::escapeHTML(item.video_description)}\n" 152 | item_string << " #{CGI::escapeHTML(item.video_content_location)}\n" if item.video_content_location 153 | item_string << " #{CGI::escapeHTML(item.video_player_location)}\n" if item.video_player_location 154 | item_string << " #{CGI::escapeHTML(item.video_duration.to_s)}\n" if item.video_duration 155 | item_string << " #{item.video_expiration_date_value}\n" if item.video_expiration_date 156 | item_string << " #{CGI::escapeHTML(item.video_rating.to_s)}\n" if item.video_rating 157 | item_string << " #{CGI::escapeHTML(item.video_view_count.to_s)}\n" if item.video_view_count 158 | item_string << " #{item.video_publication_date_value}\n" if item.video_publication_date 159 | item_string << " #{CGI::escapeHTML(item.video_family_friendly)}\n" if item.video_family_friendly 160 | item_string << " #{CGI::escapeHTML(item.video_category)}\n" if item.video_category 161 | item_string << " #{CGI::escapeHTML(item.video_restriction)}\n" if item.video_restriction 162 | item_string << " #{CGI::escapeHTML(item.video_gallery_location)}\n" if item.video_gallery_location 163 | item_string << " #{CGI::escapeHTML(item.video_price.to_s)}\n" if item.video_price 164 | item_string << " #{CGI::escapeHTML(item.video_requires_subscription)}\n" if item.video_requires_subscription 165 | item_string << " #{CGI::escapeHTML(item.video_uploader)}\n" if item.video_uploader 166 | item_string << " #{CGI::escapeHTML(item.video_platform)}\n" if item.video_platform 167 | item_string << " #{CGI::escapeHTML(item.video_live)}\n" if item.video_live 168 | item_string << " \n" 169 | end 170 | 171 | item_string << " #{item.lastmod_value}\n" 172 | item_string << " #{item.changefreq}\n" if item.changefreq 173 | item_string << " #{item.priority}\n" if item.priority 174 | 175 | item.xhtml_links.each do |xhl| 176 | item_string << " \n" 177 | end 178 | 179 | item_string << " \n" 180 | 181 | item_results << item_string 182 | end 183 | 184 | result << item_results.join("") 185 | result << "\n" 186 | 187 | result 188 | end 189 | end 190 | end 191 | -------------------------------------------------------------------------------- /lib/xml-sitemap/version.rb: -------------------------------------------------------------------------------- 1 | module XmlSitemap 2 | unless defined?(::XmlSitemap::VERSION) 3 | VERSION = "1.3.3" 4 | end 5 | 6 | def self.version 7 | XmlSitemap::VERSION 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/fixtures/empty_index.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /spec/fixtures/encoded_image_map.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | http://foobar.com/ 5 | 2011-06-01T00:00:01Z 6 | 1.0 7 | 8 | 9 | http://foobar.com/path?a=b&c=d&e=image support string 10 | 11 | http://foobar.com/foo.gif 12 | 13 | 2011-06-01T00:00:01Z 14 | 15 | 16 | http://foobar.com/path?a=b&c=d&e=image support string 17 | 18 | http://foobar.com/foo.gif 19 | Image Title 20 | 21 | 2011-06-01T00:00:01Z 22 | 23 | 24 | http://foobar.com/path?a=b&c=d&e=image support string 25 | 26 | http://foobar.com/foo.gif 27 | Image Caption 28 | 29 | 2011-06-01T00:00:01Z 30 | 31 | 32 | http://foobar.com/path?a=b&c=d&e=image support string 33 | 34 | http://foobar.com/foo.gif 35 | Image License 36 | 37 | 2011-06-01T00:00:01Z 38 | 39 | 40 | http://foobar.com/path?a=b&c=d&e=image support string 41 | 42 | http://foobar.com/foo.gif 43 | Image GeoLocation 44 | 45 | 2011-06-01T00:00:01Z 46 | 47 | 48 | http://foobar.com/path?a=b&c=d&e=image support string 49 | 50 | http://foobar.com/foo.gif 51 | Image Caption 52 | Image Title 53 | Image License 54 | Image GeoLocation 55 | 56 | 2011-06-01T00:00:01Z 57 | 58 | 59 | -------------------------------------------------------------------------------- /spec/fixtures/encoded_map.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | http://foobar.com/ 5 | 2011-06-01T00:00:01Z 6 | 1.0 7 | 8 | 9 | http://foobar.com/path?a=b&c=d&e=sample string 10 | 2011-06-01T00:00:01Z 11 | 12 | 13 | -------------------------------------------------------------------------------- /spec/fixtures/encoded_video_map.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | http://foobar.com/ 5 | 2011-06-01T00:00:01Z 6 | 1.0 7 | 8 | 9 | http://foobar.com/path?a=b&c=d&e=video 10 | 11 | http://foobar.com/foo.jpg 12 | Video Title 13 | Video Description 14 | http://foobar.com/foo.mp4 15 | 16 | 2011-06-01T00:00:01Z 17 | 18 | 19 | http://foobar.com/path?a=b&c=d&e=video 20 | 21 | http://foobar.com/foo.jpg 22 | Video Title 23 | Video Description 24 | http://foobar.com/foo.swf 25 | 26 | 2011-06-01T00:00:01Z 27 | 28 | 29 | http://foobar.com/path?a=b&c=d&e=video 30 | 31 | http://foobar.com/foo.jpg 32 | Video Title 33 | Video Description 34 | http://foobar.com/foo.mp4 35 | http://foobar.com/foo.swf 36 | 180 37 | 2012-06-01T00:00:01Z 38 | 3.5 39 | 2500 40 | 2011-06-01T00:00:01Z 41 | no 42 | Video Category 43 | IT 44 | http://foobar.com/foo.mpu 45 | 20 46 | no 47 | Video Uploader 48 | web 49 | no 50 | 51 | 2011-06-01T00:00:01Z 52 | 53 | 54 | -------------------------------------------------------------------------------- /spec/fixtures/group_index.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | http://foobar.com/first-0.xml 5 | 2011-06-01T00:00:01Z 6 | 7 | 8 | http://foobar.com/second-0.xml 9 | 2011-06-01T00:00:01Z 10 | 11 | 12 | http://foobar.com/second-1.xml 13 | 2011-06-01T00:00:01Z 14 | 15 | 16 | http://foobar.com/third-0.xml 17 | 2011-06-01T00:00:01Z 18 | 19 | 20 | -------------------------------------------------------------------------------- /spec/fixtures/sample_index.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | http://foobar.com/sitemap-0.xml 5 | 2011-06-01T00:00:01Z 6 | 7 | 8 | http://foobar.com/sitemap-1.xml 9 | 2011-06-01T00:00:01Z 10 | 11 | 12 | -------------------------------------------------------------------------------- /spec/fixtures/sample_index_secure.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://foobar.com/sitemap-0.xml 5 | 2011-06-01T00:00:01Z 6 | 7 | 8 | https://foobar.com/sitemap-1.xml 9 | 2011-06-01T00:00:01Z 10 | 11 | 12 | -------------------------------------------------------------------------------- /spec/fixtures/sample_many_subdomains_index.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | http://one.foobar.com/sitemap.xml 5 | 2011-06-01T00:00:01Z 6 | 7 | 8 | http://two.foobar.com/sitemap.xml 9 | 2011-06-01T00:00:01Z 10 | 11 | 12 | -------------------------------------------------------------------------------- /spec/fixtures/saved_map.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | http://foobar.com/ 5 | 2011-06-01T00:00:01Z 6 | 1.0 7 | 8 | 9 | http://foobar.com/about 10 | 2011-06-01T00:00:01Z 11 | 12 | 13 | http://foobar.com/terms 14 | 2011-06-01T00:00:01Z 15 | 16 | 17 | http://foobar.com/privacy 18 | 2011-06-01T00:00:01Z 19 | 20 | 21 | -------------------------------------------------------------------------------- /spec/fixtures/simple_map.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | http://www.example.com/ 5 | 2005-01-01 6 | monthly 7 | 0.8 8 | 9 | 10 | -------------------------------------------------------------------------------- /spec/fixtures/xhtml_links_map.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | http://foobar.com/path 5 | 2011-06-01T00:00:01Z 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /spec/index_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe XmlSitemap::Index do 4 | let(:base_time) { Time.gm(2011, 6, 1, 0, 0, 1) } 5 | 6 | describe '#new' do 7 | it 'should be valid if no sitemaps were supplied' do 8 | index = XmlSitemap::Index.new 9 | index.render.split("\n")[2..-1].join("\n").should == fixture('empty_index.xml').split("\n")[2..-1].join("\n") 10 | end 11 | 12 | it 'should raise error if passing a wrong object' do 13 | index = XmlSitemap::Index.new 14 | expect { index.add(nil) }.to raise_error ArgumentError, 'XmlSitemap::Map object required!' 15 | end 16 | 17 | it 'should raise error if passing an empty sitemap' do 18 | map = XmlSitemap::Map.new('foobar.com', :home => false) 19 | index = XmlSitemap::Index.new 20 | expect { index.add(map) }.to raise_error ArgumentError, 'Map is empty!' 21 | end 22 | end 23 | 24 | describe '#render' do 25 | it 'renders a proper index' do 26 | m1 = XmlSitemap::Map.new('foobar.com', :time => base_time) { |m| m.add('about') } 27 | m2 = XmlSitemap::Map.new('foobar.com', :time => base_time) { |m| m.add('about') } 28 | 29 | index = XmlSitemap::Index.new do |i| 30 | i.add(m1) 31 | i.add(m2) 32 | end 33 | 34 | index.render.split("\n")[2..-1].join("\n").should == fixture('sample_index.xml').split("\n")[2..-1].join("\n") 35 | end 36 | 37 | it 'renders a proper index with the secure option' do 38 | m1 = XmlSitemap::Map.new('foobar.com', :time => base_time) { |m| m.add('about') } 39 | m2 = XmlSitemap::Map.new('foobar.com', :time => base_time) { |m| m.add('about') } 40 | 41 | index = XmlSitemap::Index.new(:secure => true) do |i| 42 | i.add(m1) 43 | i.add(m2) 44 | end 45 | 46 | index.render.split("\n")[2..-1].join("\n").should == fixture('sample_index_secure.xml').split("\n")[2..-1].join("\n") 47 | end 48 | 49 | it 'renders a proper index for multiple subdomains' do 50 | m1 = XmlSitemap::Map.new('one.foobar.com', :time => base_time) { |m| m.add('about') } 51 | m2 = XmlSitemap::Map.new('two.foobar.com', :time => base_time) { |m| m.add('about') } 52 | 53 | index = XmlSitemap::Index.new do |i| 54 | i.add(m1, false) 55 | i.add(m2, false) 56 | end 57 | 58 | index.render.split("\n")[2..-1].join("\n").should == fixture('sample_many_subdomains_index.xml').split("\n")[2..-1].join("\n") 59 | end 60 | end 61 | 62 | describe '#render_to' do 63 | let(:index_path) { "/tmp/xml_index.xml" } 64 | 65 | after :all do 66 | File.delete_if_exists(index_path) 67 | end 68 | 69 | it 'saves index contents to the filesystem' do 70 | m1 = XmlSitemap::Map.new('foobar.com', :time => base_time) { |m| m.add('about') } 71 | m2 = XmlSitemap::Map.new('foobar.com', :time => base_time) { |m| m.add('about') } 72 | 73 | index = XmlSitemap::Index.new do |i| 74 | i.add(m1) 75 | i.add(m2) 76 | end 77 | 78 | index.render_to(index_path) 79 | File.read(index_path).split("\n")[2..-1].join("\n").should eq(fixture('sample_index.xml').split("\n")[2..-1].join("\n")) 80 | end 81 | 82 | it 'should have separate running offsets for different map groups' do 83 | maps = %w(first second second third).map do |name| 84 | XmlSitemap::Map.new('foobar.com', :time => base_time, :group => name) { |m| m.add('about') } 85 | end 86 | 87 | index = XmlSitemap::Index.new do |i| 88 | maps.each { |m| i.add(m) } 89 | end 90 | 91 | index.render_to(index_path) 92 | File.read(index_path).split("\n")[2..-1].join("\n").should eq(fixture('group_index.xml').split("\n")[2..-1].join("\n")) 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /spec/item_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'XmlSitemap::Item' do 4 | describe '#new' do 5 | it 'raises ArgumentError if invalid :period value was passed' do 6 | proc { XmlSitemap::Item.new('hello', :period => :foobar) }. 7 | should raise_error ArgumentError, "Invalid :period value 'foobar'" 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/map_spec.rb: -------------------------------------------------------------------------------- 1 | require 'benchmark' 2 | require 'spec_helper' 3 | 4 | describe XmlSitemap::Map do 5 | let(:base_time) { Time.gm(2011, 6, 1, 0, 0, 1) } 6 | let(:extra_time) { Time.gm(2011, 7, 1, 0, 0, 1) } 7 | 8 | describe '#new' do 9 | it 'should not allow empty domains' do 10 | expect { XmlSitemap::Map.new(nil) }.to raise_error ArgumentError 11 | expect { XmlSitemap::Map.new('') }.to raise_error ArgumentError 12 | expect { XmlSitemap::Map.new(' ') }.to raise_error ArgumentError 13 | end 14 | 15 | it 'should not allow empty urls' do 16 | map = XmlSitemap::Map.new('foobar.com') 17 | 18 | expect { map.add(nil) }.to raise_error ArgumentError 19 | expect { map.add('') }.to raise_error ArgumentError 20 | expect { map.add(' ') }.to raise_error ArgumentError 21 | end 22 | 23 | it 'should have a default home path' do 24 | map = XmlSitemap::Map.new('foobar.com') 25 | map.should_not be_empty 26 | map.items.first.target.should eq('http://foobar.com/') 27 | end 28 | 29 | context 'with :home => false' do 30 | it 'should have no home path' do 31 | map = XmlSitemap::Map.new('foobar.com', :home => false) 32 | map.should be_empty 33 | end 34 | end 35 | end 36 | 37 | describe '#add' do 38 | it 'should autocomplete path with no starting slash' do 39 | map = XmlSitemap::Map.new('foobar.com') 40 | map.add('about').target.should eq('http://foobar.com/about') 41 | end 42 | 43 | it 'should allow full urls in items' do 44 | map = XmlSitemap::Map.new('foobar.com', :root => false) 45 | map.add('https://foobar.com/path').target.should eq('https://foobar.com/path') 46 | map.add('path2').target.should eq('http://foobar.com/path2') 47 | end 48 | 49 | it 'should render urls in https mode' do 50 | map = XmlSitemap::Map.new('foobar.com', :secure => true) 51 | map.add('path').target.should eq('https://foobar.com/path') 52 | end 53 | 54 | it 'should set entry time' do 55 | map = XmlSitemap::Map.new('foobar.com', :time => base_time) 56 | map.add('hello').updated.should eq(base_time) 57 | map.add('world', :updated => extra_time).updated.should eq(Time.gm(2011, 7, 1, 0, 0, 1)) 58 | end 59 | 60 | it 'should raise Argument error if no time or date were provided' do 61 | map = XmlSitemap::Map.new('foobar.com', :time => base_time) 62 | expect { map.add('hello', :updated => 5) }. 63 | to raise_error ArgumentError, "Time, Date, or ISO8601 String required for :updated!" 64 | end 65 | 66 | it 'should not raise Argument error if a iso8601 string is provided' do 67 | map = XmlSitemap::Map.new('foobar.com', :time => base_time) 68 | expect { map.add('hello', :updated => "2011-09-12T23:18:49Z") }.not_to raise_error 69 | map.add('world', :updated => extra_time.utc.iso8601).updated.should eq(Time.gm(2011, 7, 1, 0, 0, 1).utc.iso8601) 70 | end 71 | 72 | it 'should not raise Argument error if a string is provided with :validate_time => false' do 73 | map = XmlSitemap::Map.new('foobar.com', :time => base_time) 74 | expect { map.add('hello', :validate_time => false, :updated => 'invalid data') }.not_to raise_error 75 | end 76 | 77 | it 'should raise Argument error if an invalid string is provided' do 78 | map = XmlSitemap::Map.new('foobar.com', :time => base_time) 79 | expect { map.add('hello', :updated => 'invalid data') }. 80 | to raise_error ArgumentError, "String provided to :updated did not match ISO8601 standard!" 81 | end 82 | 83 | it 'should not allow more than 50k records' do 84 | map = XmlSitemap::Map.new('foobar.com') 85 | expect { 86 | 1.upto(50001) { |i| map.add("url#{i}") } 87 | }.to raise_error RuntimeError, 'Only up to 50k records allowed!' 88 | end 89 | 90 | it 'should not allow urls longer than 2048 characters' do 91 | long_string = (1..2049).to_a.map { |i| "a" }.join 92 | 93 | map = XmlSitemap::Map.new('foobar.com') 94 | expect { 95 | map.add(long_string) 96 | }.to raise_error ArgumentError, "Target can't be longer than 2,048 characters!" 97 | end 98 | end 99 | 100 | describe '#render' do 101 | 102 | before do 103 | opts1 = { :image_location => "http://foobar.com/foo.gif" } 104 | opts2 = { :image_location => "http://foobar.com/foo.gif", :image_title => "Image Title" } 105 | opts3 = { :image_location => "http://foobar.com/foo.gif", :image_caption => "Image Caption" } 106 | opts4 = { :image_location => "http://foobar.com/foo.gif", :image_license => "Image License" } 107 | opts5 = { :image_location => "http://foobar.com/foo.gif", :image_geolocation => "Image GeoLocation" } 108 | opts6 = { :image_location => "http://foobar.com/foo.gif", 109 | :image_title => "Image Title", 110 | :image_caption => "Image Caption", 111 | :image_license => "Image License", 112 | :image_geolocation => "Image GeoLocation" } 113 | opts7 = { :video_thumbnail_location => "http://foobar.com/foo.jpg", 114 | :video_title => "Video Title", 115 | :video_description => "Video Description", 116 | :video_content_location => "http://foobar.com/foo.mp4" } 117 | opts8 = { :video_thumbnail_location => "http://foobar.com/foo.jpg", 118 | :video_title => "Video Title", 119 | :video_description => "Video Description", 120 | :video_player_location => "http://foobar.com/foo.swf" } 121 | opts9 = { :video_thumbnail_location => "http://foobar.com/foo.jpg", 122 | :video_title => "Video Title", 123 | :video_description => "Video Description", 124 | :video_content_location => "http://foobar.com/foo.mp4", 125 | :video_player_location => "http://foobar.com/foo.swf", 126 | :video_duration => 180, 127 | :video_expiration_date => Time.gm(2012, 6, 1, 0, 0, 1), 128 | :video_rating => 3.5, 129 | :video_view_count => 2500, 130 | :video_publication_date => base_time, 131 | :video_family_friendly => "no", 132 | :video_category => "Video Category", 133 | :video_restriction => "IT", 134 | :video_gallery_location => "http://foobar.com/foo.mpu", 135 | :video_price => 20, 136 | :video_requires_subscription => "no", 137 | :video_uploader => "Video Uploader", 138 | :video_platform => "web", 139 | :video_live => "no" } 140 | 141 | @image_map = XmlSitemap::Map.new('foobar.com', :time => base_time) 142 | @image_map.add('/path?a=b&c=d&e=image support string', opts1) 143 | @image_map.add('/path?a=b&c=d&e=image support string', opts2) 144 | @image_map.add('/path?a=b&c=d&e=image support string', opts3) 145 | @image_map.add('/path?a=b&c=d&e=image support string', opts4) 146 | @image_map.add('/path?a=b&c=d&e=image support string', opts5) 147 | @image_map.add('/path?a=b&c=d&e=image support string', opts6) 148 | 149 | @video_map = XmlSitemap::Map.new('foobar.com', :time => base_time) 150 | @video_map.add('/path?a=b&c=d&e=video', opts7) 151 | @video_map.add('/path?a=b&c=d&e=video', opts8) 152 | @video_map.add('/path?a=b&c=d&e=video', opts9) 153 | end 154 | 155 | it 'should have properly encoded entities' do 156 | map = XmlSitemap::Map.new('foobar.com', :time => base_time) 157 | map.add('/path?a=b&c=d&e=sample string') 158 | map.render.split("\n")[2..-1].join("\n").should == fixture('encoded_map.xml').split("\n")[2..-1].join("\n") 159 | end 160 | 161 | it 'should render xhtml links' do 162 | map = XmlSitemap::Map.new('foobar.com', :home => false, :time => base_time) 163 | map.add('/path', xhtml_links: [ 164 | { hreflang: 'fr', href: 'http://foobar.com/path?lang=fr' }, 165 | { hreflang: 'it', href: 'http://foobar.com/path?lang=it' } 166 | ]) 167 | map.render.split("\n")[2..-1].join("\n").should == fixture('xhtml_links_map.xml').split("\n")[2..-1].join("\n") 168 | end 169 | 170 | context 'with builder engine' do 171 | it 'should have properly encoded entities' do 172 | map = XmlSitemap::Map.new('foobar.com', :time => base_time) 173 | map.add('/path?a=b&c=d&e=sample string') 174 | s = map.render(:builder) 175 | # ignore ordering of urlset attributes by dropping first two lines 176 | s.split("\n")[2..-1].join("\n").should == fixture('encoded_map.xml').split("\n")[2..-1].join("\n") 177 | end 178 | 179 | it 'should have properly encoded entities with image support' do 180 | s = @image_map.render(:builder) 181 | s.split("\n")[2..-1].join("\n").should == fixture('encoded_image_map.xml').split("\n")[2..-1].join("\n") 182 | end 183 | 184 | it 'should have properly encoded entities with video support' do 185 | s = @video_map.render(:builder) 186 | s.split("\n")[2..-1].join("\n").should == fixture('encoded_video_map.xml').split("\n")[2..-1].join("\n") 187 | end 188 | end 189 | 190 | context 'with nokogiri engine' do 191 | it 'should have properly encoded entities' do 192 | map = XmlSitemap::Map.new('foobar.com', :time => base_time) 193 | map.add('/path?a=b&c=d&e=sample string') 194 | s = map.render(:nokogiri) 195 | # ignore ordering of urlset attributes by dropping first two lines 196 | s.split("\n")[2..-1].join("\n").should == fixture('encoded_map.xml').split("\n")[2..-1].join("\n") 197 | end 198 | 199 | it 'should have properly encoded entities with image support' do 200 | s = @image_map.render(:nokogiri) 201 | s.split("\n")[2..-1].join("\n").should == fixture('encoded_image_map.xml').split("\n")[2..-1].join("\n") 202 | end 203 | 204 | it 'should have properly encoded entities with video support' do 205 | s = @video_map.render(:nokogiri) 206 | s.split("\n")[2..-1].join("\n").should == fixture('encoded_video_map.xml').split("\n")[2..-1].join("\n") 207 | end 208 | end 209 | 210 | context 'with string engine' do 211 | it 'should have properly encoded entities' do 212 | map = XmlSitemap::Map.new('foobar.com', :time => base_time) 213 | map.add('/path?a=b&c=d&e=sample string') 214 | s = map.render(:string) 215 | # ignore ordering of urlset attributes by dropping first two lines 216 | s.split("\n")[2..-1].join("\n").should == fixture('encoded_map.xml').split("\n")[2..-1].join("\n") 217 | end 218 | 219 | it 'should have properly encoded entities with image support' do 220 | s = @image_map.render(:string) 221 | s.split("\n")[2..-1].join("\n").should == fixture('encoded_image_map.xml').split("\n")[2..-1].join("\n") 222 | end 223 | 224 | it 'should have properly encoded entities with video support' do 225 | s = @video_map.render(:string) 226 | s.split("\n")[2..-1].join("\n").should == fixture('encoded_video_map.xml').split("\n")[2..-1].join("\n") 227 | end 228 | end 229 | end 230 | 231 | describe '#render_to' do 232 | it 'should save contents to the filesystem' do 233 | path = "/tmp/sitemap_#{Time.now.to_i}.xml" 234 | map = XmlSitemap::Map.new('foobar.com', :time => base_time) do |m| 235 | m.add('about') 236 | m.add('terms') 237 | m.add('privacy') 238 | end 239 | 240 | map.render_to(path) 241 | 242 | File.read(path).split("\n")[2..-1].join("\n").should eq(fixture('saved_map.xml').split("\n")[2..-1].join("\n")) 243 | File.delete(path) if File.exists?(path) 244 | end 245 | 246 | context 'with :gzip => true' do 247 | it 'should save gzip contents to the filesystem' do 248 | map = XmlSitemap::Map.new('foobar.com', :time => base_time) 249 | 250 | path = "/tmp/sitemap.xml" 251 | path_gzip = path + ".gz" 252 | 253 | map.render_to(path) 254 | map.render_to(path_gzip, :gzip => true) 255 | 256 | checksum(File.read(path)).should eq(checksum(gunzip(path_gzip))) 257 | 258 | File.delete(path) if File.exists?(path) 259 | File.delete(path_gzip) if File.exists?(path_gzip) 260 | end 261 | 262 | it 'should add .gz extension if comression is enabled' do 263 | map = XmlSitemap::Map.new('foobar.com', :time => base_time) 264 | path = '/tmp/sitemap.xml' 265 | path_gzip = path + ".gz" 266 | 267 | map.render_to(path) 268 | map.render_to(path, :gzip => true) 269 | 270 | checksum(File.read(path)).should eq(checksum(gunzip(path_gzip))) 271 | 272 | File.delete(path) if File.exists?(path) 273 | File.delete(path_gzip) if File.exists?(path_gzip) 274 | end 275 | end 276 | end 277 | 278 | describe 'performance' do 279 | it 'should test rendering time' do 280 | pending "comment this line to run benchmarks, takes roughly 30 seconds" 281 | map = XmlSitemap::Map.new('foobar.com', :time => base_time) 282 | 283 | 50000.times do |i| 284 | map.add("hello#{i}") 285 | end 286 | 287 | Benchmark.bm do |x| 288 | x.report("render(:builder)") { map.render(:builder) } 289 | x.report("render(:nokogiri)") { map.render(:nokogiri) } 290 | x.report("render(:string)") { map.render(:string) } 291 | end 292 | end 293 | end 294 | end 295 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $:.unshift File.expand_path("../..", __FILE__) 2 | 3 | require 'simplecov' 4 | SimpleCov.start do 5 | add_group 'XmlSitemap', 'lib/xml-sitemap' 6 | end 7 | 8 | require 'digest' 9 | require 'xml-sitemap' 10 | 11 | def fixture_path 12 | File.expand_path("../fixtures", __FILE__) 13 | end 14 | 15 | def fixture(file) 16 | File.read(File.join(fixture_path, file)) 17 | end 18 | 19 | def checksum(content) 20 | Digest::SHA1.hexdigest(content) 21 | end 22 | 23 | def gunzip(path) 24 | contents = nil 25 | File.open(path) do |f| 26 | gz = Zlib::GzipReader.new(f) 27 | contents = gz.read 28 | gz.close 29 | end 30 | contents 31 | end 32 | 33 | module FileHelper 34 | def delete_if_exists(path) 35 | File.delete(path) if File.exists?(path) 36 | end 37 | end 38 | 39 | class File 40 | extend FileHelper 41 | end -------------------------------------------------------------------------------- /spec/xmlsitemap_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'XmlSitemap' do 4 | describe '#new' do 5 | it 'returns a new map instance' do 6 | XmlSitemap.new('foo.com').should be_a XmlSitemap::Map 7 | end 8 | end 9 | 10 | describe '#map' do 11 | it 'returns a new map instance' do 12 | XmlSitemap.map('foo.com').should be_a XmlSitemap::Map 13 | end 14 | end 15 | 16 | describe '#index' do 17 | it 'returns a new index instancet' do 18 | XmlSitemap.index.should be_a XmlSitemap::Index 19 | end 20 | end 21 | 22 | describe '#version' do 23 | it 'returns current version string' do 24 | XmlSitemap.version.should eq(XmlSitemap::VERSION) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /xml-sitemap.gemspec: -------------------------------------------------------------------------------- 1 | require File.expand_path('../lib/xml-sitemap/version', __FILE__) 2 | 3 | Gem::Specification.new do |s| 4 | s.name = "xml-sitemap" 5 | s.version = XmlSitemap::VERSION 6 | s.summary = "Simple XML sitemap generator for Ruby/Rails applications." 7 | s.description = "Provides a wrapper to generate XML sitemaps and sitemap indexes." 8 | s.homepage = "http://github.com/sosedoff/xml-sitemap" 9 | s.authors = ["Dan Sosedoff"] 10 | s.email = ["dan.sosedoff@gmail.com"] 11 | s.license = "MIT" 12 | 13 | s.add_development_dependency 'rake', '~> 10.0' 14 | s.add_development_dependency 'rspec', '~> 2.13' 15 | s.add_development_dependency 'simplecov', '~> 0.7' 16 | s.add_development_dependency 'nokogiri', '~> 1.5' 17 | 18 | s.add_runtime_dependency 'builder', '>= 2.0' 19 | 20 | s.files = `git ls-files`.split("\n") 21 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 22 | s.executables = `git ls-files -- bin/*`.split("\n").map{|f| File.basename(f)} 23 | s.require_paths = ["lib"] 24 | end 25 | --------------------------------------------------------------------------------