visionmedia-dm-forms 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
data/History.rdoc ADDED
@@ -0,0 +1,4 @@
1
+
2
+ === 0.0.1 / 2009-01-13
3
+
4
+ * Initial release
data/Manifest ADDED
@@ -0,0 +1,27 @@
1
+ History.rdoc
2
+ Manifest
3
+ README.rdoc
4
+ Rakefile
5
+ Todo.rdoc
6
+ examples/benchmarks.rb
7
+ examples/datamapper.rb
8
+ examples/elements.rb
9
+ examples/haml.rb
10
+ examples/login.haml
11
+ lib/dm-forms.rb
12
+ lib/dm-forms/core_ext.rb
13
+ lib/dm-forms/elements.rb
14
+ lib/dm-forms/model_elements.rb
15
+ lib/dm-forms/tag.rb
16
+ lib/dm-forms/version.rb
17
+ spec/functional/core_ext_spec.rb
18
+ spec/functional/elements_spec.rb
19
+ spec/functional/tag_spec.rb
20
+ spec/integration/datamapper_spec.rb
21
+ spec/integration/haml_spec.rb
22
+ spec/spec_helper.rb
23
+ tasks/benchmarks.rake
24
+ tasks/docs.rake
25
+ tasks/gemspec.rake
26
+ tasks/spec.rake
27
+
data/README.rdoc ADDED
@@ -0,0 +1,62 @@
1
+
2
+ = DataMapper Forms
3
+
4
+ DataMapper model form generation.
5
+
6
+ == Features:
7
+
8
+ * Integrates with DataMapper
9
+ * Fast; over 1000 elements in 0.1 seconds (rake benchmark)
10
+ * Error reporting
11
+ * Handles restful HTTP verbs
12
+ * Provides low-level form elements uncoupled from DataMapper
13
+
14
+ == Examples:
15
+
16
+ Pretend in a magical world we have a User model, and only the email is valid while we are updating.
17
+ Based on the situation above, the form would render similar to the markup beneath the
18
+ example.
19
+
20
+ errors_for @user
21
+ form_for @user, :action => '/user' do |f|
22
+ f.textfield :name, :label => 'Name'
23
+ f.textfield :email, :label => 'Email'
24
+ f.submit :op, :value => 'Update'
25
+ end
26
+
27
+ <ul class="messages error">
28
+ <li>Name has an invalid format</li>
29
+ </ul>
30
+ <form action="/user" method="post" id="form-user">
31
+ <input type="hidden" name="_method" value="put" />
32
+ <label for="name">Name:</label>
33
+ <input type="textfield" class="error form-textfield form-name" name="name" />
34
+ <label for="email">Email:</label>
35
+ <input type="textfield" class="form-textfield form-email" name="email" />
36
+ <input type="submit" class="form-submit form-op" value="Update" name="op" />
37
+ </form>
38
+
39
+ == License:
40
+
41
+ (The MIT License)
42
+
43
+ Copyright (c) 2008 TJ Holowaychuk
44
+
45
+ Permission is hereby granted, free of charge, to any person obtaining
46
+ a copy of this software and associated documentation files (the
47
+ 'Software'), to deal in the Software without restriction, including
48
+ without limitation the rights to use, copy, modify, merge, publish,
49
+ distribute, sublicense, an d/or sell copies of the Software, and to
50
+ permit persons to whom the Software is furnished to do so, subject to
51
+ the following conditions:
52
+
53
+ The above copyright notice and this permission notice shall be
54
+ included in all copies or substantial portions of the Software.
55
+
56
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
57
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
58
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
59
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
60
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
61
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
62
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1,16 @@
1
+
2
+ require 'rubygems'
3
+ require 'rake'
4
+ require 'echoe'
5
+ require './lib/dm-forms.rb'
6
+
7
+ Echoe.new("dm-forms", DataMapper::Form::VERSION::STRING) do |p|
8
+ p.author = "TJ Holowaychuk"
9
+ p.email = "tj@vision-media.ca"
10
+ p.summary = "DataMapper model form generation"
11
+ p.url = "http://github.com/visionmedia/dm-forms"
12
+ p.runtime_dependencies = ['dm-core']
13
+ p.development_dependencies = ['rspec_hpricot_matchers']
14
+ end
15
+
16
+ Dir['tasks/**/*.rake'].sort.each { |lib| load lib }
data/Todo.rdoc ADDED
@@ -0,0 +1,27 @@
1
+
2
+ == Major:
3
+
4
+ * match messages markup to jquery ui if reasonable
5
+ * <optgroup label
6
+ * assign :required based on model?
7
+ * clean up examples
8
+ * use have_tag matcher
9
+ * add enctype automatically
10
+ * Fix select option ordering ... poor unordered hashes :(
11
+ * Add nesting to select options, and select groups
12
+ * escape xml in attrs
13
+ * Support both instance_eval and block
14
+ * wrap all in div class="form-TYPE" and adjust spec...
15
+ * XHTML ... validate
16
+ * perform some indenting (or all but make it optional)
17
+ * increase performance
18
+ * preview rdoc / finish documenting ...
19
+
20
+ == Minor:
21
+
22
+ * Aggregate / faux elements ...
23
+ * date_field :bday, :format => 'YYYY-MM-DD', :value => '1987-05-25'
24
+
25
+ == Brainstorming:
26
+
27
+ * Nothing
data/dm-forms.gemspec ADDED
@@ -0,0 +1,37 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = %q{dm-forms}
5
+ s.version = "0.0.2"
6
+
7
+ s.required_rubygems_version = Gem::Requirement.new(">= 1.2") if s.respond_to? :required_rubygems_version=
8
+ s.authors = ["TJ Holowaychuk"]
9
+ s.date = %q{2009-01-15}
10
+ s.description = %q{DataMapper model form generation}
11
+ s.email = %q{tj@vision-media.ca}
12
+ s.extra_rdoc_files = ["README.rdoc", "lib/dm-forms.rb", "lib/dm-forms/core_ext.rb", "lib/dm-forms/elements.rb", "lib/dm-forms/model_elements.rb", "lib/dm-forms/tag.rb", "lib/dm-forms/version.rb", "tasks/benchmarks.rake", "tasks/docs.rake", "tasks/gemspec.rake", "tasks/spec.rake"]
13
+ s.files = ["History.rdoc", "Manifest", "README.rdoc", "Rakefile", "Todo.rdoc", "examples/benchmarks.rb", "examples/datamapper.rb", "examples/elements.rb", "examples/haml.rb", "examples/login.haml", "lib/dm-forms.rb", "lib/dm-forms/core_ext.rb", "lib/dm-forms/elements.rb", "lib/dm-forms/model_elements.rb", "lib/dm-forms/tag.rb", "lib/dm-forms/version.rb", "spec/functional/core_ext_spec.rb", "spec/functional/elements_spec.rb", "spec/functional/tag_spec.rb", "spec/integration/datamapper_spec.rb", "spec/integration/haml_spec.rb", "spec/spec_helper.rb", "tasks/benchmarks.rake", "tasks/docs.rake", "tasks/gemspec.rake", "tasks/spec.rake", "dm-forms.gemspec"]
14
+ s.has_rdoc = true
15
+ s.homepage = %q{http://github.com/visionmedia/dm-forms}
16
+ s.rdoc_options = ["--line-numbers", "--inline-source", "--title", "Dm-forms", "--main", "README.rdoc"]
17
+ s.require_paths = ["lib"]
18
+ s.rubyforge_project = %q{dm-forms}
19
+ s.rubygems_version = %q{1.3.1}
20
+ s.summary = %q{DataMapper model form generation}
21
+
22
+ if s.respond_to? :specification_version then
23
+ current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
24
+ s.specification_version = 2
25
+
26
+ if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
27
+ s.add_runtime_dependency(%q<dm-core>, [">= 0"])
28
+ s.add_development_dependency(%q<rspec_hpricot_matchers>, [">= 0"])
29
+ else
30
+ s.add_dependency(%q<dm-core>, [">= 0"])
31
+ s.add_dependency(%q<rspec_hpricot_matchers>, [">= 0"])
32
+ end
33
+ else
34
+ s.add_dependency(%q<dm-core>, [">= 0"])
35
+ s.add_dependency(%q<rspec_hpricot_matchers>, [">= 0"])
36
+ end
37
+ end
@@ -0,0 +1,118 @@
1
+
2
+ $:.unshift File.expand_path(File.dirname(__FILE__) + '/../lib')
3
+ require 'dm-forms'
4
+ require 'benchmark'
5
+ require 'dm-core'
6
+ require 'dm-validations'
7
+ DataMapper.setup :default, 'sqlite3::memory:'
8
+ include DataMapper::Form::ModelElements
9
+
10
+ #--
11
+ # Models
12
+ #++
13
+
14
+ class User
15
+ include DataMapper::Resource
16
+ property :id, Serial
17
+ property :name, String, :format => /^[\w]+$/
18
+ property :email, String, :format => :email_address
19
+ end
20
+
21
+ DataMapper.auto_migrate!
22
+
23
+ $user = User.new :name => 'tj', :email => 'invalid email@lame.com'
24
+
25
+ #--
26
+ # Benchmarks
27
+ #++
28
+
29
+ puts
30
+ puts 'Single element'
31
+ Benchmark.bm(25) do |x|
32
+ x.report("10 elements") {
33
+ 10.times do
34
+ textarea :comments, :value => 'Enter your comments here', :label => 'Comments:', :required => true
35
+ end
36
+ }
37
+ x.report("100 elements") {
38
+ 100.times do
39
+ textarea :comments, :value => 'Enter your comments here', :label => 'Comments:', :required => true
40
+ end
41
+ }
42
+ x.report("1000 elements") {
43
+ 1000.times do
44
+ textarea :comments, :value => 'Enter your comments here', :label => 'Comments:', :required => true
45
+ end
46
+ }
47
+ end
48
+
49
+ puts
50
+ puts 'Capture elements within a fieldset'
51
+ Benchmark.bm(25) do |x|
52
+ x.report("10 elements") {
53
+ 5.times do
54
+ fieldset :comments do |f|
55
+ f.textarea :comments, :value => 'Enter your comments here', :label => 'Comments:', :required => true
56
+ end
57
+ end
58
+ }
59
+ x.report("100 elements") {
60
+ 50.times do
61
+ fieldset :comments do |f|
62
+ f.textarea :comments, :value => 'Enter your comments here', :label => 'Comments:', :required => true
63
+ end
64
+ end
65
+ }
66
+ x.report("1000 elements") {
67
+ 500.times do
68
+ fieldset :comments do |f|
69
+ f.textarea :comments, :value => 'Enter your comments here', :label => 'Comments:', :required => true
70
+ end
71
+ end
72
+ }
73
+ end
74
+
75
+ # Not real-world examples ... just for benchmarking purposes
76
+ puts
77
+ puts 'Entire forms'
78
+ Benchmark.bm(25) do |x|
79
+ x.report("Login") {
80
+ form :login do |f|
81
+ f.textfield :name, :label => 'Username', :required => true
82
+ f.textfield :email, :label => 'Email', :required => true
83
+ f.textfield :pass, :label => 'Password', :required => true
84
+ f.submit :op, :value => 'Login'
85
+ end
86
+ }
87
+
88
+ x.report("Register") {
89
+ form :register do |f|
90
+ f.fieldset :general do |f|
91
+ f.textfield :name, :label => 'Username', :required => true
92
+ f.textfield :email, :label => 'Email', :required => true
93
+ f.textfield :pass, :label => 'Password', :required => true
94
+ f.password :pass_confirm
95
+ end
96
+ f.fieldset :details do |f|
97
+ f.textfield :city, :label => 'City'
98
+ f.textfield :zip, :label => 'Postal Code'
99
+ end
100
+ f.fieldset :forums do |f|
101
+ f.textarea :signature, :label => 'Signature', :description => 'Enter a signature which will appear below your forum posts.'
102
+ end
103
+ f.submit :op, :value => 'Register'
104
+ end
105
+ }
106
+ end
107
+
108
+ puts
109
+ puts 'Entires form with #form_for'
110
+ Benchmark.bm(25) do |x|
111
+ x.report("User") {
112
+ form_for $user do |f|
113
+ f.textfield :name, :label => 'Username', :required => true
114
+ f.textfield :email, :label => 'Email', :required => true
115
+ f.submit :op, :value => 'Login'
116
+ end
117
+ }
118
+ end
@@ -0,0 +1,37 @@
1
+
2
+ $:.unshift File.expand_path(File.dirname(__FILE__) + '/../lib')
3
+ require 'dm-forms'
4
+ require 'dm-core'
5
+ require 'dm-validations'
6
+
7
+ include DataMapper::Form::ModelElements
8
+ DataMapper.setup :default, 'sqlite3::memory:'
9
+
10
+ class User
11
+ include DataMapper::Resource
12
+ property :id, Serial
13
+ property :name, String, :format => /^[\w]+$/
14
+ property :email, String, :format => :email_address
15
+ end
16
+
17
+ DataMapper.auto_migrate!
18
+ user = User.new :name => 'tj', :email => 'invalid email@lame.com'
19
+
20
+ s = errors_for(user)
21
+ s << form_for(user, :action => '/user') do |f|
22
+ f.textarea :name
23
+ f.textarea :email
24
+ f.submit :op, :value => 'Add'
25
+ end
26
+ puts s
27
+
28
+ user.email = 'tj@vision-media.ca'
29
+ user.save
30
+
31
+ s = errors_for(user)
32
+ s << form_for(user, :action => '/user') do |f|
33
+ f.textarea :name, :label => 'Name'
34
+ f.textarea :email, :label => 'Email'
35
+ f.submit :op, :value => 'Update'
36
+ end
37
+ puts s
@@ -0,0 +1,31 @@
1
+
2
+ $:.unshift File.expand_path(File.dirname(__FILE__) + '/../lib')
3
+ require 'dm-forms'
4
+
5
+ include DataMapper::Form::Elements
6
+
7
+ def example before, &block
8
+ puts before << "\n\n"
9
+ puts yield.gsub!(/^/, ' ')
10
+ puts
11
+ end
12
+
13
+ example %(textarea :comments) do
14
+ textarea :comments
15
+ end
16
+
17
+ example %(textarea :comments, :label => 'Comments', :description => 'Tell us what you think') do
18
+ textarea :comments, :label => 'Comments', :description => 'Tell us what you think'
19
+ end
20
+
21
+ example %(textfield :email, :label => 'Email', :required => true) do
22
+ textfield :email, :label => 'Email', :required => true
23
+ end
24
+
25
+ example 'Login' do
26
+ form :login do |f|
27
+ f.textfield :name, :label => 'Username'
28
+ f.textfield :pass, :label => 'Password'
29
+ f.submit :op, :value => 'Login'
30
+ end
31
+ end
data/examples/haml.rb ADDED
@@ -0,0 +1,21 @@
1
+
2
+ $:.unshift File.expand_path(File.dirname(__FILE__) + '/../lib')
3
+ require 'rubygems'
4
+ require 'haml'
5
+ require 'dm-forms'
6
+
7
+ include DataMapper::Form::ModelElements
8
+
9
+ # Haml does not play very nice with nested Ruby, but then again
10
+ # there is relatively nothing that cannot be altered via styling
11
+ # so placing forms directly within a Haml view is not entirely
12
+ # necessary.
13
+ def login_form
14
+ form :login, :action => '/user' do |f|
15
+ f.textarea :name, :label => 'Name'
16
+ f.textarea :email, :label => 'Email'
17
+ f.submit :op, :value => 'Login'
18
+ end
19
+ end
20
+
21
+ puts Haml::Engine.new(File.read(File.dirname(__FILE__) + '/login.haml')).render
@@ -0,0 +1,4 @@
1
+ #primary
2
+ .content
3
+ %h1 Login
4
+ = login_form
@@ -0,0 +1,39 @@
1
+
2
+ class NilClass
3
+ def to_xml_attributes #:nodoc:
4
+ ''
5
+ end
6
+ alias :to_html_attributes :to_xml_attributes
7
+ end
8
+
9
+ class String
10
+
11
+ ##
12
+ # Convert to a human readable string.
13
+ #
14
+ # === Examples:
15
+ #
16
+ # 'im_a_simple.String'.humanize # => 'im a simple String'
17
+ #
18
+
19
+ def humanize
20
+ gsub(/[^a-zA-Z\d]/, ' ') || self
21
+ end
22
+
23
+ ##
24
+ # Indent a string with pseudo +tabs+ (spaces). Defaults to a single tab.
25
+
26
+ def indent tabs = 1
27
+ gsub /^/, ' ' * tabs
28
+ end
29
+ end
30
+
31
+ class Symbol
32
+
33
+ ##
34
+ # Convert to a human readable string. See String#humanize
35
+
36
+ def humanize
37
+ to_s.humanize
38
+ end
39
+ end
@@ -0,0 +1,210 @@
1
+
2
+ module DataMapper
3
+ module Form
4
+ module Elements
5
+
6
+ ##
7
+ # Proxy object for capturing elements. See Elements#capture_elements.
8
+
9
+ class Proxy
10
+ def initialize model = nil
11
+ @model = model
12
+ end
13
+
14
+ def method_missing meth, *args, &block
15
+ add_error_class_to args if has_a_model_with_errors_on? args.first
16
+ (@elements ||= []) << Elements.send(meth, *args, &block)
17
+ end
18
+
19
+ def add_error_class_to args
20
+ ((args[1] ||= {})[:class] ||= '') << ' error'
21
+ end
22
+
23
+ def has_a_model_with_errors_on? meth
24
+ @model and !@model.valid? and @model.errors.on meth
25
+ end
26
+ end
27
+
28
+ module_function
29
+
30
+ ##
31
+ # Generates a generic HTML +name+ tag. Although this method is
32
+ # generally used internally by dm-forms, you may utilize it directly
33
+ # passing any of the following +options+.
34
+ #
35
+ # === Options:
36
+ #
37
+ # :self_closing Wither or not the element should self-close (<br />)
38
+ # :attributes Hash of attributes such as :type => :textfield
39
+ #
40
+
41
+ def tag name, options = {}, &block
42
+ Tag.new(name, options, &block).render
43
+ end
44
+
45
+ ##
46
+ # Generates a label.
47
+
48
+ def label value, options = {}
49
+ value << ':'
50
+ value << '<em>*</em>' if options.delete :required
51
+ %(<label for="#{options[:for]}">#{value}</label>\n)
52
+ end
53
+
54
+ ##
55
+ # Generates a legend.
56
+
57
+ def legend value
58
+ %(<legend>#{value}</legend>)
59
+ end
60
+
61
+ ##
62
+ # Generates a description.
63
+
64
+ def desc text
65
+ %(\n<p class="description">#{text}</p>) unless text.blank?
66
+ end
67
+
68
+ ##
69
+ # Generates an option.
70
+
71
+ def option value, title
72
+ %(<option value="#{value}">#{title}</option>\n)
73
+ end
74
+
75
+ ##
76
+ # Generates a form.
77
+
78
+ def form name, options = {}, &block
79
+ options = { :method => :post, :id => "form-#{name}" }.merge options
80
+ unless valid_http_verb? options
81
+ old_value = options[:value] || ''
82
+ options[:value] = hidden_method(options[:method]) << old_value
83
+ options[:method] = :post
84
+ end
85
+ tag :form, :attributes => options, &block
86
+ end
87
+
88
+ ##
89
+ # Generates a fieldset.
90
+
91
+ def fieldset name, options = {}, &block
92
+ legend_value = options.has_key?(:legend) ? options.delete(:legend) : name.humanize.capitalize
93
+ options = { :class => "fieldset-#{name}" }.merge options
94
+ options[:value] = "\n" << legend(legend_value) << (options.delete(:value) || '')
95
+ tag :fieldset, :attributes => options, &block
96
+ end
97
+
98
+ ##
99
+ # Generates a textfield.
100
+
101
+ def textfield name, options = {}
102
+ options = { :type => :textfield, :name => name }.merge options
103
+ tag :input, :self_closing => true, :attributes => options
104
+ end
105
+
106
+ ##
107
+ # Generates a password field.
108
+
109
+ def password name, options = {}
110
+ options = { :type => :password, :name => name }.merge options
111
+ tag :input, :self_closing => true, :attributes => options
112
+ end
113
+
114
+ ##
115
+ # Generates a checkbox.
116
+
117
+ def checkbox name, options = {}
118
+ options = { :type => :checkbox, :name => name }.merge options
119
+ tag :input, :self_closing => true, :attributes => options
120
+ end
121
+
122
+ ##
123
+ # Generates a hidden field.
124
+
125
+ def hidden name, options = {}
126
+ options = { :type => :hidden, :name => name }.merge options
127
+ tag :input, :self_closing => true, :attributes => options
128
+ end
129
+
130
+ ##
131
+ # Creates hidden _method, with value of +method+.
132
+
133
+ def hidden_method method
134
+ hidden :_method, :value => method
135
+ end
136
+
137
+ ##
138
+ # Generates a radio button.
139
+
140
+ def radio name, options = {}
141
+ options = { :type => :radio, :name => name }.merge options
142
+ tag :input, :self_closing => true, :attributes => options
143
+ end
144
+
145
+ ##
146
+ # Generates a file field.
147
+
148
+ def file name, options = {}
149
+ options = { :type => :file, :name => name }.merge options
150
+ tag :input, :self_closing => true, :attributes => options
151
+ end
152
+
153
+ ##
154
+ # Generates a select field.
155
+
156
+ def select name, options = {}, &block
157
+ options = { :name => name, :value => "\n" }.merge options
158
+ options[:value] << capture_elements(&block) if block_given?
159
+ options[:value] << select_options(options) if options.include? :options
160
+ tag :select, :attributes => options
161
+ end
162
+
163
+ ##
164
+ # Generates a textarea.
165
+
166
+ def textarea name, options = {}, &block
167
+ options = { :name => name }.merge options
168
+ tag :textarea, :attributes => options, &block
169
+ end
170
+
171
+ ##
172
+ # Generates a submit button.
173
+
174
+ def submit name, options = {}
175
+ options = { :type => :submit, :name => name }.merge options
176
+ tag :input, :self_closing => true, :attributes => options
177
+ end
178
+
179
+ ##
180
+ # Generates a button.
181
+
182
+ def button name, options = {}
183
+ type = options.has_key?(:src) ? :image : :button
184
+ options = { :type => type, :name => name }.merge options
185
+ tag :input, :self_closing => true, :attributes => options
186
+ end
187
+
188
+ ##
189
+ # Capture results of elements called within +block+. Optionally
190
+ # a DataMapper model may be passed, at which point error classes
191
+ # will be applied to invalid elements.
192
+
193
+ def capture_elements model = nil, &block
194
+ elements = yield Proxy.new(model)
195
+ elements.join
196
+ end
197
+
198
+ private
199
+
200
+ def select_options options #:nodoc:
201
+ options.delete(:options).collect { |value, title| option(value, title) }.join
202
+ end
203
+
204
+ def valid_http_verb? options #:nodoc:
205
+ [:get, :post].include? options[:method]
206
+ end
207
+
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,33 @@
1
+
2
+ module DataMapper
3
+ module Form
4
+ module ModelElements
5
+
6
+ include Elements
7
+
8
+ ##
9
+ # Generates a form.
10
+
11
+ def form_for model, options = {}, &block
12
+ id = model.class.to_s.downcase
13
+ method = model.new_record? ? :post : :put
14
+ options = { :model => model, :method => method }.merge options
15
+ form id, options, &block
16
+ end
17
+
18
+ ##
19
+ # Return markup for errors on +model+.
20
+
21
+ def errors_for model
22
+ if not model.all_valid?
23
+ s = %(<ul class="messages error">\n)
24
+ s << model.errors.collect { |error| "<li>#{error.first}</li>" }.join("\n")
25
+ s << "\n</ul>\n"
26
+ else
27
+ ''
28
+ end
29
+ end
30
+
31
+ end
32
+ end
33
+ end