rsolr 0.9.6 → 0.9.7.1

Sign up to get free protection for your applications and to get access to all the features.
data/CHANGES.txt CHANGED
@@ -1,3 +1,18 @@
1
+ 0.9.7.1 - November 5, 2009
2
+ added support for multibyte url characters in RSolr::Connection::Utils
3
+ - this is because Ruby 1.9's String size method is different than 1.8
4
+
5
+ 0.9.7 - October 23, 2009
6
+ Removed XML message builders - using pure Ruby generator instead
7
+ - benchmarks show generation speed is a little faster than libxml
8
+ - minimal xml escaping so binary posting to Solr should no longer be a problem.
9
+ Changed response.adapter_resonse to response.raw
10
+ Removed HTTP adapters - sticking with NetHTTP
11
+ Removed builder dependency in gemspec
12
+ Removed Adapter and HTTPClient modules
13
+ Moved HTTPCLient::Util to Connection::Utils
14
+ Updated all tests
15
+
1
16
  0.9.6 - September 9, 2009
2
17
  Added ability to create direct connections from existing Java::OrgApacheSolrCore::SolrCore
3
18
  Added ability to send queries using POST
data/README.rdoc CHANGED
@@ -3,8 +3,8 @@
3
3
  A Ruby client for Apache Solr. RSolr has been developed to be simple and extendable. It features transparent JRuby DirectSolrConnection support and a simple Hash-in, Hash-out architecture.
4
4
 
5
5
  == Installation:
6
- gem sources -a http://gems.github.com
7
- sudo gem install mwmitchell-rsolr
6
+ gem sources -a http://gemcutter.org
7
+ sudo gem install rsolr
8
8
 
9
9
  ==Related Resources & Projects
10
10
  * {Solr}[http://lucene.apache.org/solr/]
@@ -100,12 +100,6 @@ Commit & optimize shortcuts
100
100
  solr.commit
101
101
  solr.optimize
102
102
 
103
- ===XML Builders for RSolr
104
- As of version 0.9.1, RSolr can use LibXml to create the update messages sent to solr. To switch from Builder to LibXml, set the RSolr::Message.builder like:
105
- solr = RSolr.connect
106
- solr.message.adapter = RSolr::Message::Adapter::Libxml.new
107
-
108
-
109
103
  == Response Formats
110
104
  The default response format is Ruby. When the :wt param is set to :ruby, the response is eval'd resulting in a Hash. You can get a raw response by setting the :wt to "ruby" - notice, the string -- not a symbol. RSolr will eval the Ruby string ONLY if the :wt value is :ruby. All other response formats are available as expected, :wt=>'xml' etc..
111
105
 
@@ -119,30 +113,10 @@ The default response format is Ruby. When the :wt param is set to :ruby, the res
119
113
  ===JSON:
120
114
  solr.select(:wt=>:json)
121
115
 
122
- You can access the original request context (path, params, url etc.) by calling the #adapter_response method:
116
+ You can access the original request context (path, params, url etc.) by calling the #raw method:
123
117
  response = solr.select :q=>'*:*'
124
- response.adapter_response[:status_code]
125
- response.adapter_response[:body]
126
- response.adapter_response[:url]
127
-
128
- The adapter_response is a hash that contains the generated params, url, path, post data, headers etc., very useful for debugging and testing.
129
-
130
-
131
- == HTTP Client Adapter
132
- You can specify the http client adapter:
133
- :net_http uses the standard Net::HTTP library
134
- :curb uses the C based "curl" library
118
+ response.raw[:status_code]
119
+ response.raw[:body]
120
+ response.raw[:url]
135
121
 
136
- NOTE: The Net::Http is the default adapter.
137
-
138
- Example:
139
-
140
- RSolr.connect(:adapter => :curb)
141
- RSolr.connect(:adapter => :net_http)
142
-
143
- Intereseting read about Ruby's Net::HTTP library:
144
- http://apocryph.org/2008/11/09/more_indepth_analysis_ruby_http_client_performance
145
-
146
- NOTE: You can't use the :curb adapter under jRuby. To install curb:
147
-
148
- sudo gem install curb
122
+ The raw is a hash that contains the generated params, url, path, post data, headers etc., very useful for debugging and testing.
data/Rakefile CHANGED
@@ -1,76 +1,14 @@
1
1
  require 'rake'
2
2
  require 'rake/testtask'
3
3
  require 'rake/rdoctask'
4
-
5
- namespace :rsolr do
6
-
7
- desc "Starts the HTTP server used for running HTTP connection tests"
8
- task :start_test_server do
9
- system "cd apache-solr/example; java -jar start.jar"
10
- end
11
-
12
- end
13
-
14
- task :default => [:test_units]
15
-
16
- # rake package
17
-
18
- require 'rubygems'
19
4
  require 'rake/gempackagetask'
20
- raw_spec = File.read 'rsolr.gemspec'
21
- spec = eval(raw_spec)
22
- Rake::GemPackageTask.new(spec) do |pkg|
23
- pkg.need_tar = true
24
- end
25
-
26
- desc "Run basic tests"
27
- Rake::TestTask.new("test_units") { |t|
28
- t.pattern = 'test/**/*_test.rb'
29
- t.verbose = true
30
- t.warning = true
31
- t.libs << "test"
32
- }
33
-
34
- require 'spec/rake/spectask'
35
-
36
- desc "Run specs"
37
- Spec::Rake::SpecTask.new('spec') do |t|
38
- t.spec_files = FileList['spec/**/*_spec.rb']
39
- t.libs += ["lib", "spec"]
40
- end
41
-
42
- desc 'Run specs' # this task runs each test in its own process
43
- task :specs do
44
- require 'rubygems'
45
- require 'facets/more/filelist' unless defined?(FileList)
46
- files = FileList["**/*_spec.rb"]
47
- p files.to_a
48
- files.each do |filename|
49
- system "cd #{File.dirname(filename)} && ruby #{File.basename(filename)}"
50
- end
51
- end
52
-
53
- desc "Run specs"
54
- Rake::TestTask.new("specs") { |t|
55
- t.pattern = 'spec/**/*_spec.rb'
56
- t.verbose = true
57
- t.warning = true
58
- t.libs += ["lib", "spec"]
59
- }
60
5
 
61
- # Clean house
62
- desc 'Clean up tmp files.'
63
- task :clean do |t|
64
- FileUtils.rm_rf "doc"
65
- FileUtils.rm_rf "pkg"
6
+ ENV['RUBYOPT'] = '-W1'
7
+
8
+ task :environment do
9
+ require File.dirname(__FILE__) + '/lib/rsolr'
66
10
  end
11
+
12
+ Dir['tasks/**/*.rake'].each { |t| load t }
67
13
 
68
- # Rdoc
69
- desc 'Generate documentation for the rsolr gem.'
70
- Rake::RDocTask.new(:doc) do |rdoc|
71
- rdoc.rdoc_dir = 'doc'
72
- rdoc.title = 'RSolr'
73
- rdoc.options << '--line-numbers' << '--inline-source'
74
- rdoc.rdoc_files.include('README.rdoc')
75
- rdoc.rdoc_files.include('lib/**/*.rb')
76
- end
14
+ task :default => ['spec:api']
data/examples/http.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  require File.join(File.dirname(__FILE__), '..', 'lib', 'rsolr')
2
2
 
3
- # switch out the http adapter from net_http to curb
4
- solr = RSolr.connect :adapter=>:curb
3
+ solr = RSolr.connect
5
4
 
6
5
  Dir['../apache-solr/example/exampledocs/*.xml'].each do |xml_file|
7
6
  puts "Updating with #{xml_file}"
@@ -14,7 +13,7 @@ puts
14
13
 
15
14
  response = solr.select(:q=>'ipod', :fq=>['price:[0 TO 50]'], :rows=>2, :start=>0)
16
15
 
17
- puts "URL : #{response.adapter_response[:url]} -> #{response.adapter_response[:status_code]}"
16
+ puts "URL : #{response.raw[:url]} -> #{response.raw[:status_code]}"
18
17
 
19
18
  puts
20
19
 
@@ -5,9 +5,9 @@ require 'java'
5
5
  #
6
6
  # Connection for JRuby + DirectSolrConnection
7
7
  #
8
- class RSolr::Connection::Adapter::Direct
8
+ class RSolr::Connection::Direct
9
9
 
10
- include RSolr::HTTPClient::Util
10
+ include RSolr::Connection::Utils
11
11
 
12
12
  attr_accessor :opts
13
13
 
@@ -0,0 +1,79 @@
1
+ require 'net/http'
2
+
3
+ #
4
+ # Connection for standard HTTP Solr server
5
+ #
6
+ class RSolr::Connection::NetHttp
7
+
8
+ include RSolr::Connection::Utils
9
+
10
+ attr_reader :opts, :uri
11
+
12
+ # opts can have:
13
+ # :url => 'http://localhost:8080/solr'
14
+ def initialize opts={}
15
+ opts[:url] ||= 'http://127.0.0.1:8983/solr'
16
+ @opts = opts
17
+ @uri = URI.parse opts[:url]
18
+ end
19
+
20
+ # send a request to the connection
21
+ # request '/update', :wt=>:xml, '</commit>'
22
+ def request path, params={}, *extra
23
+ opts = extra[-1].kind_of?(Hash) ? extra.pop : {}
24
+ data = extra[0]
25
+ # force a POST, use the query string as the POST body
26
+ if opts[:method] == :post and data.to_s.empty?
27
+ http_context = self.post(path, hash_to_query(params), {}, {'Content-Type' => 'application/x-www-form-urlencoded'})
28
+ else
29
+ if data
30
+ # standard POST, using "data" as the POST body
31
+ http_context = self.post(path, data, params, {"Content-Type" => 'text/xml; charset=utf-8'})
32
+ else
33
+ # standard GET
34
+ http_context = self.get(path, params)
35
+ end
36
+ end
37
+ raise RSolr::RequestError.new(http_context[:body]) unless http_context[:status_code] == 200
38
+ http_context
39
+ end
40
+
41
+ protected
42
+
43
+ def connection
44
+ @connection ||= Net::HTTP.new(@uri.host, @uri.port)
45
+ end
46
+
47
+ def get path, params={}
48
+ url = self.build_url path, params
49
+ net_http_response = self.connection.get url
50
+ create_http_context net_http_response, url, path, params
51
+ end
52
+
53
+ def post path, data, params={}, headers={}
54
+ url = self.build_url path, params
55
+ net_http_response = self.connection.post url, data, headers
56
+ create_http_context net_http_response, url, path, params, data, headers
57
+ end
58
+
59
+ def create_http_context net_http_response, url, path, params, data=nil, headers={}
60
+ full_url = "#{@uri.scheme}://#{@uri.host}"
61
+ full_url += @uri.port ? ":#{@uri.port}" : ''
62
+ full_url += url
63
+ {
64
+ :status_code=>net_http_response.code.to_i,
65
+ :url=>full_url,
66
+ :body=>net_http_response.body,
67
+ :path=>path,
68
+ :params=>params,
69
+ :data=>data,
70
+ :headers=>headers
71
+ }
72
+ end
73
+
74
+ def build_url path, params={}
75
+ full_path = @uri.path + path
76
+ super full_path, params, @uri.query
77
+ end
78
+
79
+ end
@@ -1,124 +1,72 @@
1
1
  module RSolr::Connection
2
2
 
3
- module Adapter
4
- autoload :Direct, 'rsolr/connection/adapter/direct'
5
- autoload :HTTP, 'rsolr/connection/adapter/http'
6
- end
7
-
8
- class Base
9
-
10
- attr_reader :adapter
11
-
12
- # "adapter" is instance of:
13
- # RSolr::Adapter::HTTP
14
- # RSolr::Adapter::Direct (jRuby only)
15
- # or any other class that uses the connection "interface"
16
- def initialize(adapter)
17
- @adapter = adapter
18
- end
3
+ autoload :Direct, 'rsolr/connection/direct'
4
+ autoload :NetHttp, 'rsolr/connection/net_http'
19
5
 
20
- # Send a request to a request handler using the method name.
21
- def method_missing(method_name, *args, &blk)
22
- request("/#{method_name}", *args, &blk)
6
+ # Helpful utility methods for building queries to a Solr server
7
+ module Utils
8
+
9
+ # Performs URI escaping so that you can construct proper
10
+ # query strings faster. Use this rather than the cgi.rb
11
+ # version since it's faster. (Stolen from Rack).
12
+ def escape(s)
13
+ s.to_s.gsub(/([^ a-zA-Z0-9_.-]+)/n) {
14
+ #'%'+$1.unpack('H2'*$1.size).join('%').upcase
15
+ '%'+$1.unpack('H2'*bytesize($1)).join('%').upcase
16
+ }.tr(' ', '+')
23
17
  end
24
18
 
25
- # sends data to the update handler
26
- # data can be a string of xml, or an object that returns xml from its #to_xml method
27
- def update(data, params={})
28
- request '/update', params, data
19
+ # Return the bytesize of String; uses String#length under Ruby 1.8 and
20
+ # String#bytesize under 1.9.
21
+ if ''.respond_to?(:bytesize)
22
+ def bytesize(string)
23
+ string.bytesize
24
+ end
25
+ else
26
+ def bytesize(string)
27
+ string.size
28
+ end
29
29
  end
30
30
 
31
- # send request solr
32
- # params is hash with valid solr request params (:q, :fl, :qf etc..)
33
- # if params[:wt] is not set, the default is :ruby
34
- # if :wt is something other than :ruby, the raw response body is used
35
- # otherwise, a simple Hash is returned
36
- # NOTE: to get raw ruby, use :wt=>'ruby' <- a string, not a symbol like :ruby
37
- #
38
- #
39
- def request(path, params={}, *extra)
40
- response = @adapter.request(path, map_params(params), *extra)
41
- adapt_response(response)
31
+ # creates and returns a url as a string
32
+ # "url" is the base url
33
+ # "params" is an optional hash of GET style query params
34
+ # "string_query" is an extra query string that will be appended to the
35
+ # result of "url" and "params".
36
+ def build_url url='', params={}, string_query=''
37
+ queries = [string_query, hash_to_query(params)]
38
+ queries.delete_if{|i| i.to_s.empty?}
39
+ url += "?#{queries.join('&')}" unless queries.empty?
40
+ url
42
41
  end
43
-
44
- #
45
- # single record:
46
- # solr.update(:id=>1, :name=>'one')
42
+
43
+ # converts a key value pair to an escaped string:
44
+ # Example:
45
+ # build_param(:id, 1) == "id=1"
46
+ def build_param(k,v)
47
+ "#{escape(k)}=#{escape(v)}"
48
+ end
49
+
47
50
  #
48
- # update using an array
49
- # solr.update([{:id=>1, :name=>'one'}, {:id=>2, :name=>'two'}])
51
+ # converts hash into URL query string, keys get an alpha sort
52
+ # if a value is an array, the array values get mapped to the same key:
53
+ # hash_to_query(:q=>'blah', :fq=>['blah', 'blah'], :facet=>{:field=>['location_facet', 'format_facet']})
54
+ # returns:
55
+ # ?q=blah&fq=blah&fq=blah&facet.field=location_facet&facet.field=format.facet
50
56
  #
51
- def add(doc, &block)
52
- update message.add(doc, &block)
53
- end
54
-
55
- # send </commit>
56
- def commit
57
- update message.commit
58
- end
59
-
60
- # send </optimize>
61
- def optimize
62
- update message.optimize
63
- end
64
-
65
- # send </rollback>
66
- # NOTE: solr 1.4 only
67
- def rollback
68
- update message.rollback
69
- end
70
-
71
- # Delete one or many documents by id
72
- # solr.delete_by_id 10
73
- # solr.delete_by_id([12, 41, 199])
74
- def delete_by_id(id)
75
- update message.delete_by_id(id)
76
- end
77
-
78
- # delete one or many documents by query
79
- # solr.delete_by_query 'available:0'
80
- # solr.delete_by_query ['quantity:0', 'manu:"FQ"']
81
- def delete_by_query(query)
82
- update message.delete_by_query(query)
83
- end
84
-
85
- # shortcut to RSolr::Message::Builder
86
- def message
87
- @message ||= RSolr::Message::Builder.new
88
- end
89
-
90
- protected
91
-
92
- # sets default params etc.. - could be used as a mapping hook
93
- # type of request should be passed in here? -> map_params(:query, {})
94
- def map_params(params)
95
- params||={}
96
- {:wt=>:ruby}.merge(params)
97
- end
98
-
99
- # "adapter_response" must be a hash with the following keys:
100
- # :params - a sub hash of standard solr params
101
- # : body - the raw response body from the solr server
102
- # This method will evaluate the :body value if the params[:wt] == :ruby
103
- # otherwise, the body is returned
104
- # The return object has a special method attached called #adapter_response
105
- # This method gives you access to the original response from the adapter,
106
- # so you can access things like the actual :url sent to solr,
107
- # the raw :body, original :params and original :data
108
- def adapt_response(adapter_response)
109
- data = adapter_response[:body]
110
- # if the wt is :ruby, evaluate the ruby string response
111
- if adapter_response[:params][:wt] == :ruby
112
- data = Kernel.eval(data)
113
- end
114
- # attach a method called #adapter_response that returns the original adapter response value
115
- def data.adapter_response
116
- @adapter_response
57
+ # if a value is empty/nil etc., it is not added
58
+ def hash_to_query(params)
59
+ mapped = params.map do |k, v|
60
+ next if v.to_s.empty?
61
+ if v.class == Array
62
+ hash_to_query(v.map { |x| [k, x] })
63
+ else
64
+ build_param k, v
65
+ end
117
66
  end
118
- data.send(:instance_variable_set, '@adapter_response', adapter_response)
119
- data
67
+ mapped.compact.join("&")
120
68
  end
121
-
69
+
122
70
  end
123
71
 
124
72
  end
data/lib/rsolr/message.rb CHANGED
@@ -2,11 +2,6 @@
2
2
 
3
3
  module RSolr::Message
4
4
 
5
- module Adapter
6
- autoload :Builder, 'rsolr/message/adapter/builder'
7
- autoload :Libxml, 'rsolr/message/adapter/libxml'
8
- end
9
-
10
5
  # A class that represents a "doc" xml element for a solr update
11
6
  class Document
12
7
 
@@ -25,7 +20,7 @@ module RSolr::Message
25
20
  values = [values] unless values.is_a?(Array)
26
21
  values.each do |v|
27
22
  next if v.to_s.empty?
28
- @fields << Field.new({:name=>field}, v)
23
+ @fields << Field.new({:name=>field}, v.to_s)
29
24
  end
30
25
  end
31
26
  @attrs={}
@@ -79,15 +74,6 @@ module RSolr::Message
79
74
 
80
75
  class Builder
81
76
 
82
- attr_writer :adapter
83
-
84
- # b = Builder.new
85
- # b.adapter = RSolr::Message::Adapter::LibXML.new
86
- # b.optimize == '<optimize/>'
87
- def adapter
88
- @adapter ||= RSolr::Message::Adapter::Builder.new
89
- end
90
-
91
77
  # generates "add" xml for updating solr
92
78
  # "data" can be a hash or an array of hashes.
93
79
  # - each hash should be a simple key=>value pair representing a solr doc.
@@ -115,39 +101,54 @@ module RSolr::Message
115
101
  #
116
102
  def add(data, add_attrs={})
117
103
  data = [data] unless data.is_a?(Array)
118
- documents = data.map do |doc|
104
+ add = Xout.new :add, add_attrs
105
+ data.each do |doc|
119
106
  doc = Document.new(doc) if doc.respond_to?(:each_pair)
120
107
  yield doc if block_given?
121
- doc
108
+ add.child :doc, doc.attrs do |doc_node|
109
+ doc.fields.each do |field_obj|
110
+ doc_node.child :field, field_obj.value, field_obj.attrs
111
+ end
112
+ end
122
113
  end
123
- adapter.add(documents, add_attrs)
114
+ add.to_xml
124
115
  end
125
116
 
126
117
  # generates a <commit/> message
127
118
  def commit(opts={})
128
- adapter.commit(opts)
119
+ Xout.new(:commit, opts).to_xml
129
120
  end
130
121
 
131
122
  # generates a <optimize/> message
132
123
  def optimize(opts={})
133
- adapter.optimize(opts)
124
+ Xout.new(:optimize, opts).to_xml
134
125
  end
135
126
 
136
127
  # generates a <rollback/> message
137
128
  def rollback
138
- adapter.rollback
129
+ Xout.new(:rollback).to_xml
139
130
  end
140
131
 
141
132
  # generates a <delete><id>ID</id></delete> message
142
133
  # "ids" can be a single value or array of values
143
134
  def delete_by_id(ids)
144
- adapter.delete_by_id(ids)
135
+ ids = [ids] unless ids.is_a?(Array)
136
+ delete_node = Xout.new(:delete) do |xml|
137
+ ids.each { |id| xml.child :id, id }
138
+ end
139
+ delete_node.to_xml
145
140
  end
146
141
 
147
142
  # generates a <delete><query>ID</query></delete> message
148
143
  # "queries" can be a single value or an array of values
149
144
  def delete_by_query(queries)
150
- adapter.delete_by_query(queries)
145
+ queries = [queries] unless queries.is_a?(Array)
146
+ delete_node = Xout.new(:delete) do |xml|
147
+ queries.each { |query| xml.child :query, query }
148
+ end
149
+ delete_node.to_xml
151
150
  end
151
+
152
152
  end
153
- end
153
+
154
+ end
data/lib/rsolr.rb CHANGED
@@ -4,13 +4,15 @@ require 'rubygems'
4
4
 
5
5
  $: << File.dirname(__FILE__) unless $:.include?(File.dirname(__FILE__))
6
6
 
7
+ require 'xout'
8
+
7
9
  module RSolr
8
10
 
9
- VERSION = '0.9.5'
11
+ VERSION = '0.9.7'
10
12
 
11
13
  autoload :Message, 'rsolr/message'
14
+ autoload :Client, 'rsolr/client'
12
15
  autoload :Connection, 'rsolr/connection'
13
- autoload :HTTPClient, 'rsolr/http_client'
14
16
 
15
17
  # Factory for creating connections.
16
18
  # 2 modes of argument operations:
@@ -28,16 +30,16 @@ module RSolr
28
30
  type = args.first.is_a?(Symbol) ? args.shift : :http
29
31
  opts = args
30
32
  type_class = case type
31
- when :http,nil
32
- 'HTTP'
33
+ when :net_http,:http,nil
34
+ 'NetHttp'
33
35
  when :direct
34
36
  'Direct'
35
37
  else
36
38
  raise "Invalid connection type: #{type} - use :http, :direct or leave nil for :http/default"
37
39
  end
38
- adapter_class = RSolr::Connection::Adapter.const_get(type_class)
40
+ adapter_class = RSolr::Connection.const_get(type_class)
39
41
  adapter = adapter_class.new(*opts)
40
- RSolr::Connection::Base.new(adapter)
42
+ RSolr::Client.new(adapter)
41
43
  end
42
44
 
43
45
  # A module that contains string related methods
@@ -55,7 +57,7 @@ module RSolr
55
57
  # send the escape method into the Connection class ->
56
58
  # solr = RSolr.connect
57
59
  # solr.escape('asdf')
58
- RSolr::Connection::Base.send(:include, Char)
60
+ RSolr::Client.send(:include, Char)
59
61
 
60
62
  # bring escape into this module (RSolr) -> RSolr.escape('asdf')
61
63
  extend Char
data/lib/xout.rb ADDED
@@ -0,0 +1,65 @@
1
+ class Xout
2
+
3
+ attr_reader :name, :text, :attrs, :children
4
+
5
+ def initialize node_name, *args, &block
6
+ @children = []
7
+ attrs = args.last.is_a?(Hash) ? args.pop : {}
8
+ text = args.empty? ? '' : args.pop.to_s
9
+ @name, @text, @attrs = node_name, text, attrs
10
+ yield self if block_given?
11
+ end
12
+
13
+ def child name, *args, &block
14
+ add_child self.class.new(name, *args, &block)
15
+ end
16
+
17
+ def add_child node
18
+ children << node
19
+ end
20
+
21
+ def to_xml
22
+ xml = ["<#{name}#{create_attrs(attrs)}"]
23
+ if not text.empty? or not children.empty?
24
+ xml << ">#{escape_text text.to_s}"
25
+ xml += children.map{|child|child.to_xml}
26
+ xml << "</#{name}>"
27
+ else
28
+ xml << '/>'
29
+ end
30
+ xml.join
31
+ end
32
+
33
+ alias :to_s :to_xml
34
+
35
+ def to_xml_doc
36
+ '<?xml version="1.0" encoding="UTF-8"?>' + to_xml
37
+ end
38
+
39
+ protected
40
+
41
+ # builds an XML attribute string.
42
+ # escapes each attribute value by running it through #escape_attr
43
+ def create_attrs hash
44
+ r = hash.map { |k,v| "#{k}=\"#{escape_attr v.to_s}\"" }.join(' ')
45
+ " #{r}" unless r.empty?
46
+ end
47
+
48
+ # minimal escaping for attribute values
49
+ def escape_attr input
50
+ escape input, '&'=>'&amp;', '<'=>'&lt;', '>'=>'&gt;', "'"=>'&apos;', '"'=>'&quote;'
51
+ end
52
+
53
+ # minimal escaping for text
54
+ def escape_text input
55
+ escape input, '&'=>'&amp;', '<'=>'&lt;', '>'=>'&gt;'
56
+ end
57
+
58
+ # accepts a string input and a hash mapping of characters => replacement values:
59
+ # Example:
60
+ # escape 'My <string>cat</strong>', '<'=>'&gt;', '>'=>'&lt;'
61
+ def escape input, map
62
+ input.gsub(/[#{map.keys.join}]+/) { | char | map[char] || char }
63
+ end
64
+
65
+ end