presenting 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. data/LICENSE +20 -0
  2. data/README +10 -0
  3. data/Rakefile +22 -0
  4. data/app/assets/javascript/grid.js +0 -0
  5. data/app/assets/javascript/search.js +13 -0
  6. data/app/assets/stylesheets/details-color.css +7 -0
  7. data/app/assets/stylesheets/details.css +10 -0
  8. data/app/assets/stylesheets/form.css +1 -0
  9. data/app/assets/stylesheets/grid-color.css +71 -0
  10. data/app/assets/stylesheets/grid.css +64 -0
  11. data/app/assets/stylesheets/search-color.css +16 -0
  12. data/app/assets/stylesheets/search.css +45 -0
  13. data/app/controllers/presentation/assets_controller.rb +42 -0
  14. data/app/views/presentations/_details.erb +11 -0
  15. data/app/views/presentations/_field_search.erb +14 -0
  16. data/app/views/presentations/_form.erb +20 -0
  17. data/app/views/presentations/_grid.erb +70 -0
  18. data/app/views/presentations/_search.erb +7 -0
  19. data/config/routes.rb +3 -0
  20. data/lib/presentation/base.rb +48 -0
  21. data/lib/presentation/details.rb +19 -0
  22. data/lib/presentation/field_search.rb +65 -0
  23. data/lib/presentation/form.rb +149 -0
  24. data/lib/presentation/grid.rb +160 -0
  25. data/lib/presentation/search.rb +9 -0
  26. data/lib/presenting/attribute.rb +51 -0
  27. data/lib/presenting/configurable.rb +10 -0
  28. data/lib/presenting/defaults.rb +10 -0
  29. data/lib/presenting/field_set.rb +26 -0
  30. data/lib/presenting/form_helpers.rb +51 -0
  31. data/lib/presenting/helpers.rb +110 -0
  32. data/lib/presenting/sanitize.rb +19 -0
  33. data/lib/presenting/search.rb +185 -0
  34. data/lib/presenting/sorting.rb +87 -0
  35. data/lib/presenting/view.rb +3 -0
  36. data/lib/presenting.rb +9 -0
  37. data/rails/init.rb +12 -0
  38. data/test/assets_test.rb +58 -0
  39. data/test/attribute_test.rb +61 -0
  40. data/test/configurable_test.rb +20 -0
  41. data/test/details_test.rb +68 -0
  42. data/test/field_search_test.rb +102 -0
  43. data/test/field_set_test.rb +46 -0
  44. data/test/form_test.rb +287 -0
  45. data/test/grid_test.rb +219 -0
  46. data/test/helpers_test.rb +72 -0
  47. data/test/presenting_test.rb +15 -0
  48. data/test/rails/app/controllers/application_controller.rb +15 -0
  49. data/test/rails/app/controllers/users_controller.rb +36 -0
  50. data/test/rails/app/helpers/application_helper.rb +3 -0
  51. data/test/rails/app/helpers/users_helper.rb +2 -0
  52. data/test/rails/app/models/user.rb +2 -0
  53. data/test/rails/app/views/layouts/application.html.erb +15 -0
  54. data/test/rails/app/views/users/index.html.erb +10 -0
  55. data/test/rails/app/views/users/new.html.erb +2 -0
  56. data/test/rails/app/views/users/show.html.erb +1 -0
  57. data/test/rails/config/boot.rb +109 -0
  58. data/test/rails/config/database.yml +17 -0
  59. data/test/rails/config/environment.rb +13 -0
  60. data/test/rails/config/environments/development.rb +17 -0
  61. data/test/rails/config/environments/production.rb +24 -0
  62. data/test/rails/config/environments/test.rb +22 -0
  63. data/test/rails/config/locales/en.yml +5 -0
  64. data/test/rails/config/routes.rb +5 -0
  65. data/test/rails/db/development.sqlite3 +0 -0
  66. data/test/rails/db/migrate/20090213085444_create_users.rb +13 -0
  67. data/test/rails/db/migrate/20090213085607_populate_users.rb +13 -0
  68. data/test/rails/db/schema.rb +23 -0
  69. data/test/rails/db/test.sqlite3 +0 -0
  70. data/test/rails/log/development.log +858 -0
  71. data/test/rails/public/404.html +30 -0
  72. data/test/rails/public/422.html +30 -0
  73. data/test/rails/public/500.html +33 -0
  74. data/test/rails/public/javascripts/application.js +2 -0
  75. data/test/rails/public/javascripts/jquery.livequery.min.js +11 -0
  76. data/test/rails/public/javascripts/prototype.js +4320 -0
  77. data/test/rails/script/console +3 -0
  78. data/test/rails/script/dbconsole +3 -0
  79. data/test/rails/script/destroy +3 -0
  80. data/test/rails/script/generate +3 -0
  81. data/test/rails/script/plugin +3 -0
  82. data/test/rails/script/runner +3 -0
  83. data/test/rails/script/server +3 -0
  84. data/test/sanitize_test.rb +15 -0
  85. data/test/search_conditions_test.rb +137 -0
  86. data/test/search_test.rb +30 -0
  87. data/test/sorting_test.rb +63 -0
  88. data/test/test_helper.rb +66 -0
  89. metadata +217 -0
@@ -0,0 +1,149 @@
1
+ module Presentation
2
+ class Form < Base
3
+ # TODO
4
+ # field type extra details?
5
+ # * text
6
+ # * text_area
7
+ # * password
8
+ # * check_box checked/unchecked values
9
+ # * radio (= dropdown) options
10
+ # * dropdown (= radio) options
11
+ # * multi-select options
12
+ # * recordselect url?
13
+ # * calendar constraints
14
+ # * time constraints
15
+ # * date
16
+ # * datetime
17
+ #
18
+ # other
19
+ # - example / description / help text
20
+ # - nested fields
21
+
22
+ # Fields may be grouped. Groups may or may not have names. Here's how:
23
+ #
24
+ # Presentation::Form.new(:groups => [
25
+ # [:a, :b], # creates a nameless group with fields :a and :b
26
+ # {"foo" => [:c, :d]} # creates a group named "foo" with fields :c and :d
27
+ # ])
28
+ #
29
+ # Note that if you don't need groups it'll be simpler to just use fields= instead.
30
+ def groups
31
+ @groups ||= GroupSet.new
32
+ end
33
+ def groups=(args)
34
+ args.each do |group|
35
+ groups << group
36
+ end
37
+ end
38
+
39
+ class GroupSet < Array
40
+ def <<(val)
41
+ if val.is_a? Hash
42
+ opts = {:name => val.keys.first, :fields => val.values.first}
43
+ else
44
+ opts = {:fields => val}
45
+ end
46
+ super Group.new(opts)
47
+ end
48
+ end
49
+
50
+ class Group
51
+ include Presenting::Configurable
52
+
53
+ # a completely optional group name
54
+ attr_accessor :name
55
+
56
+ # the fields in the group
57
+ def fields
58
+ @fields ||= Presenting::FieldSet.new(Field, :name, :type)
59
+ end
60
+ def fields=(args)
61
+ args.each do |field|
62
+ fields << field
63
+ end
64
+ end
65
+ end
66
+
67
+ # Used to define fields in a group-less form.
68
+ def fields
69
+ if groups.empty?
70
+ groups << []
71
+ end
72
+ groups.first.fields
73
+ end
74
+ def fields=(args)
75
+ args.each do |field|
76
+ fields << field
77
+ end
78
+ end
79
+
80
+ # The url where the form posts. May be anything that url_for accepts, including
81
+ # a set of records.
82
+ def url
83
+ @url ||= presentable
84
+ end
85
+ attr_writer :url
86
+
87
+ # What method the form should use to post. Should default intelligently enough from
88
+ # the presentable. Not sure what use case would require it being set manually.
89
+ def method
90
+ @method ||= presentable.new_record? ? :post : :put
91
+ end
92
+ attr_writer :method
93
+
94
+ # the text on the submit button
95
+ def button
96
+ @button ||= presentable.new_record? ? 'Create' : 'Update'
97
+ end
98
+ attr_writer :button
99
+
100
+ # a passthrough for form_for's html. useful for classifying a form for ajax behavior (e.g. :html => {:class => 'ajax'})
101
+ attr_accessor :html
102
+
103
+ class Field
104
+ include Presenting::Configurable
105
+
106
+ # the display label of the field
107
+ def label
108
+ @label ||= name.to_s.titleize
109
+ end
110
+ attr_writer :label
111
+
112
+ # the parameter name of the field
113
+ attr_accessor :name
114
+
115
+ # where the value for this field comes from.
116
+ # - String: a fixed value
117
+ # - Symbol: a method on the record (no arguments)
118
+ # - Proc: a custom block that accepts the record as an argument
119
+ def value
120
+ @value ||= name.to_sym
121
+ end
122
+ attr_writer :value
123
+
124
+ def value_from(obj) #:nodoc:
125
+ v = case value
126
+ when Symbol: obj.send(value)
127
+ when String: value
128
+ when Proc: value.call(obj)
129
+ end
130
+ end
131
+
132
+ # the widget type for the field. use type_options to pass arguments to the widget.
133
+ def type
134
+ @type ||= :string
135
+ end
136
+ attr_writer :type
137
+
138
+ # unrestricted options storage for the widget type. this could be a list of options for a select, or extra configuration for a calendar widget.
139
+ attr_accessor :type_options
140
+ end
141
+
142
+ def iname; :form end
143
+
144
+ delegate :request_forgery_protection_token, :allow_forgery_protection, :to => :controller
145
+ def protect_against_forgery? #:nodoc:
146
+ allow_forgery_protection && request_forgery_protection_token
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,160 @@
1
+ module Presentation
2
+ # TODO: ability to render a hash
3
+ # TODO: custom css classes for rows and/or cells
4
+ # TODO: document or complain for required options -- id and fields
5
+ # TODO: make fields= accept an ActiveRecord::Base.columns array for a default field set
6
+ class Grid < Base
7
+ # The id for this presentation. Required.
8
+ attr_accessor :id
9
+
10
+ # The display title for this presentation. Will default based on the id.
11
+ attr_writer :title
12
+ def title
13
+ @title ||= self.id.titleize
14
+ end
15
+
16
+ # Paradigm Example:
17
+ # Grid.new(:fields => [
18
+ # :email,
19
+ # {"Full Name" => proc{|r| [r.first_name, r.last_name].join(' ')}},
20
+ # {"Roles" => {:value => :roles, :type => :collection}}
21
+ # ])
22
+ #
23
+ # Is equivalent to:
24
+ # g = Grid.new
25
+ # g.fields << :email
26
+ # g.fields << {"Full Name" => proc{|r| [r.first_name, r.last_name].join(' ')},
27
+ # g.fields << {"Roles" => {:value => :roles, :type => :collection}}
28
+ def fields=(args)
29
+ args.each do |field|
30
+ self.fields << field
31
+ end
32
+ end
33
+
34
+ def fields
35
+ @fields ||= Presenting::FieldSet.new(Field, :name, :value)
36
+ end
37
+
38
+ def colspan
39
+ @colspan ||= fields.size + (record_links.empty? ? 0 : 1)
40
+ end
41
+
42
+ def iname; :grid end
43
+
44
+ class Field < Presenting::Attribute
45
+ # Defines how this field sorts. This means two things:
46
+ # 1. whether it sorts
47
+ # 2. what name it uses to sort
48
+ #
49
+ # Examples:
50
+ #
51
+ # # The field is sortable and assumes the sort_name of "first_name".
52
+ # # This is the default.
53
+ # Field.new(:sortable => true, :name => "First Name")
54
+ #
55
+ # # The field is sortable and assumes the sort_name of "first".
56
+ # Field.new(:sortable => 'first', :name => 'First Name')
57
+ #
58
+ # # The field is unsortable.
59
+ # Field.new(:sortable => false)
60
+ def sortable=(val)
61
+ @sort_name = case val
62
+ when TrueClass: self.id
63
+ when FalseClass, NilClass: nil
64
+ else val.to_s
65
+ end
66
+ end
67
+
68
+ # if the field is sortable at all
69
+ def sortable?
70
+ self.sortable = Presenting::Defaults.grid_is_sortable unless defined? @sort_name
71
+ !@sort_name.blank?
72
+ end
73
+
74
+ attr_reader :sort_name
75
+
76
+ # is this field sorted in the given request?
77
+ def is_sorted?(request)
78
+ @is_sorted ||= if sortable? and sorting = request.query_parameters["sort"] and sorting[sort_name]
79
+ sorting[sort_name].to_s.match(/desc/i) ? 'desc' : 'asc'
80
+ else
81
+ false
82
+ end
83
+ end
84
+
85
+ # for the view -- modifies the current request such that it would sort this field.
86
+ def sorted_url(request)
87
+ if current_direction = is_sorted?(request)
88
+ next_direction = current_direction == 'desc' ? 'asc' : 'desc'
89
+ else
90
+ next_direction = 'desc'
91
+ end
92
+ request.path + '?' + request.query_parameters.merge("sort" => {sort_name => next_direction}).to_param
93
+ end
94
+
95
+ ##
96
+ ## Planned
97
+ ##
98
+
99
+ # TODO: discover "type" from data class (ActiveRecord) if available
100
+ # TODO: decorate a Hash object so type is specifiable there as well
101
+ # PLAN: type should determine how a field renders. custom types for custom renders. this should be the second option to present().
102
+ # attr_accessor :type
103
+
104
+ # PLAN: a field's description would appear in the header column, perhaps only visibly in a tooltip
105
+ # attr_accessor :description
106
+
107
+ # PLAN: any field may be linked. this would happen after :value and :type.
108
+ # attr_accessor :link
109
+ end
110
+
111
+ # Links are an area where I almost made the mistake of too much configuration. Presentations are configured in the view,
112
+ # and all of the view helpers are available. When I looked at the (simple) configuration I was building and realized that
113
+ # I could just as easily take the result of link_to, well, I felt a little silly.
114
+ #
115
+ # Compare:
116
+ #
117
+ # @grid.links = [
118
+ # {:name => 'Foo', :url => foo_path, :class => 'foo'}
119
+ # ]
120
+ #
121
+ # vs:
122
+ #
123
+ # @grid.links = [
124
+ # link_to('Foo', foo_path, :class => 'foo')
125
+ # ]
126
+ #
127
+ # Not only is the second example (the supported example, by the way) shorter and cleaner, it encourages the developer
128
+ # to stay in touch with the Rails internals and therefore discourages a configuration-heavy mindset.
129
+ def links=(set)
130
+ set.compact.each do |link|
131
+ raise ArgumentError, "Links must be strings, such as the output of link_to()." unless link.is_a?(String)
132
+ links << link
133
+ end
134
+ end
135
+ def links
136
+ @links ||= []
137
+ end
138
+
139
+ # Like links, except the link will appear for each record. This means that the link must be a block that accepts the
140
+ # record as its argument. For example:
141
+ #
142
+ # @grid.record_links = [
143
+ # proc{|record| link_to("Foo", foo_path(record), :class => 'foo') }
144
+ # ]
145
+ #
146
+ def record_links=(set)
147
+ set.compact.each do |link|
148
+ raise ArgumentError, "Record links must be blocks that accept the record as an argument." unless link.respond_to?(:call) and link.arity == 1
149
+ record_links << link
150
+ end
151
+ end
152
+ def record_links
153
+ @record_links ||= []
154
+ end
155
+
156
+ def paginate?
157
+ defined? WillPaginate and presentable.is_a?(WillPaginate::Collection)
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,9 @@
1
+ module Presentation
2
+ class Search < Base
3
+ def iname; :search end
4
+
5
+ def url
6
+ request.path + '?' + request.query_parameters.except("search").to_param
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,51 @@
1
+ module Presenting
2
+ # represents an attribute meant to be read from a record
3
+ # used for things like Grid and Details.
4
+ # not intended for things like Form or FieldSearch
5
+ class Attribute
6
+ include Presenting::Configurable
7
+
8
+ def name=(val)
9
+ self.value ||= val # don't lazy define :value, because we're about to typecast here
10
+ if val.is_a? Symbol
11
+ @name = val.to_s.titleize
12
+ else
13
+ @name = val.to_s
14
+ end
15
+ end
16
+ attr_reader :name
17
+
18
+ # The short programmatic name for this field. Can be used as a CSS class, sorting name, etc.
19
+ def id=(val)
20
+ @id = val.to_s
21
+ end
22
+
23
+ def id
24
+ @id ||= name.to_s.underscore.gsub(/[^a-z0-9]/i, '_').gsub(/__+/, '_').sub(/_$/, '')
25
+ end
26
+
27
+ # Where a field's value comes from. Depends heavily on the data type you provide.
28
+ # - String: fixed value (as provided)
29
+ # - Symbol: a method on the record (no arguments)
30
+ # - Proc: a custom block that accepts the record as an argument
31
+ attr_accessor :value
32
+
33
+ def value_from(obj) #:nodoc:
34
+ case value
35
+ when Symbol: obj.is_a?(Hash) ? obj[value] : obj.send(value)
36
+ when String: value
37
+ when Proc: value.call(obj)
38
+ end
39
+ end
40
+
41
+ # whether html should be sanitize. right now this actually means html escaping.
42
+ # consider: by default, do not sanitize if value is a String?
43
+ attr_writer :sanitize
44
+ def sanitize?
45
+ unless defined? @sanitize
46
+ @sanitize = Presenting::Defaults.sanitize_fields
47
+ end
48
+ @sanitize
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,10 @@
1
+ module Presenting
2
+ module Configurable
3
+ def initialize(options = {}, &block)
4
+ options.each do |k, v|
5
+ self.send("#{k}=", v)
6
+ end
7
+ yield self if block_given?
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ module Presenting::Defaults
2
+ # Whether the columns in the Grid should be sortable by default
3
+ mattr_accessor :grid_is_sortable
4
+ self.grid_is_sortable = true
5
+
6
+ # Whether fields should be sanitized by default
7
+ mattr_accessor :sanitize_fields
8
+ self.sanitize_fields = true
9
+ end
10
+
@@ -0,0 +1,26 @@
1
+ class Presenting::FieldSet < Array
2
+ def initialize(field_class, primary, secondary)
3
+ @klass = field_class
4
+ @primary_attribute = primary
5
+ @secondary_attribute = secondary
6
+ end
7
+
8
+ def <<(field)
9
+ if field.is_a? Hash
10
+ k, v = *field.to_a.first
11
+ opts = v.is_a?(Hash) ? v : {@secondary_attribute => v}
12
+ opts[@primary_attribute] = k
13
+ else
14
+ opts = {@primary_attribute => field}
15
+ end
16
+ super @klass.new(opts)
17
+ end
18
+
19
+ def [](key)
20
+ detect{|i| i.send(@primary_attribute) == key}
21
+ end
22
+
23
+ def []=(key, val)
24
+ self << {key => val}
25
+ end
26
+ end
@@ -0,0 +1,51 @@
1
+ module Presenting::FormHelpers
2
+ def present(field)
3
+ send("present_#{field.type}_input", field)
4
+ end
5
+
6
+ def present_readonly_input(field)
7
+ text_field field.name, :disabled => true, :value => field.value_from(object)
8
+ end
9
+
10
+ def present_string_input(field)
11
+ text_field field.name, :value => field.value_from(object)
12
+ end
13
+
14
+ def present_hidden_input(field)
15
+ hidden_field field.name, :value => field.value_from(object)
16
+ end
17
+
18
+ def present_text_input(field)
19
+ text_area field.name, :value => field.value_from(object)
20
+ end
21
+
22
+ def present_password_input(field)
23
+ password_field field.name
24
+ end
25
+
26
+ def present_boolean_input(field)
27
+ check_box field.name
28
+ end
29
+
30
+ def present_dropdown_input(field)
31
+ view.select_tag "#{object_name}[#{field.name}]", view.options_for_select(field.type_options, object.send(field.name))
32
+ end
33
+ alias_method :present_select_input, :present_dropdown_input
34
+
35
+ def present_multi_select_input(field)
36
+ view.select_tag "#{object_name}[#{field.name}][]", view.options_for_select(field.type_options, object.send(field.name)), :multiple => true
37
+ end
38
+
39
+ def present_radios_input(field)
40
+ field.type_options.collect do |(display, value)|
41
+ label("#{field.name}_#{value}", display) +
42
+ radio_button(field.name, value)
43
+ end.join
44
+ end
45
+
46
+ private
47
+
48
+ def view
49
+ @template
50
+ end
51
+ end
@@ -0,0 +1,110 @@
1
+ module Presenting
2
+ module Helpers
3
+ def presentation_stylesheets(*args)
4
+ stylesheet_link_tag presentation_stylesheet_path(args.sort.join(','))
5
+ end
6
+
7
+ def presentation_javascript(*args)
8
+ javascript_include_tag presentation_javascript_path(args.sort.join(','))
9
+ end
10
+
11
+ def present(*args, &block)
12
+ options = args.length > 1 ? args.extract_options! : {}
13
+
14
+ if args.first.is_a? Symbol
15
+ object, presentation = nil, args.first
16
+ else
17
+ object, presentation = args.first, args.second
18
+ end
19
+
20
+ if presentation
21
+ klass = "Presentation::#{presentation.to_s.camelcase}".constantize rescue nil
22
+ if klass
23
+ instance = klass.new(options, &block)
24
+ instance.presentable = object
25
+ instance.controller = controller
26
+ instance.render
27
+ elsif respond_to?(method_name = "present_#{presentation}")
28
+ send(method_name, object, options)
29
+ else
30
+ raise ArgumentError, "unknown presentation `#{presentation}'"
31
+ end
32
+ elsif object.respond_to?(:loaded?) # AssociationProxy
33
+ present_association(object, options)
34
+ else
35
+ present_by_class(object, options)
36
+ end
37
+ end
38
+
39
+ # presents a text search widget for the given field (a Presentation::FieldSearch::Field, probably)
40
+ def present_text_search(field, options = {})
41
+ current_value = (params[:search][field.param][:value] rescue nil)
42
+ text_field_tag options[:name], h(current_value)
43
+ end
44
+
45
+ # presents a checkbox search widget for the given field (a Presentation::FieldSearch::Field, probably)
46
+ def present_checkbox_search(field, options = {})
47
+ current_value = (params[:search][field.param][:value] rescue nil)
48
+ check_box_tag options[:name], '1', current_value.to_s == '1'
49
+ end
50
+
51
+ # presents a dropdown/select search widget for the given field (a Presentation::FieldSearch::Field, probably)
52
+ def present_dropdown_search(field, options = {})
53
+ current_value = (params[:search][field.param][:value] rescue nil)
54
+ select_tag options[:name], options_for_select(normalize_dropdown_options_to_strings(field.options), current_value)
55
+ end
56
+
57
+ protected
58
+
59
+ # We want to normalize the value elements of the dropdown options to strings so that
60
+ # they will match against params[:search].
61
+ #
62
+ # Need to handle the three different dropdown options formats:
63
+ # * array of strings
64
+ # * array of arrays
65
+ # * hash
66
+ def normalize_dropdown_options_to_strings(options)
67
+ options.to_a.map do |element|
68
+ if element.is_a? String
69
+ [element, element]
70
+ else
71
+ [element.first, element.last.to_s]
72
+ end
73
+ end
74
+ end
75
+
76
+ # TODO: special handling for associations (displaying activerecords)
77
+ def present_association(object, options = {})
78
+ present_by_class(object, options)
79
+ end
80
+
81
+ def present_by_class(object, options = {})
82
+ case object
83
+ when Array
84
+ content_tag "ol" do
85
+ object.collect do |i|
86
+ content_tag "li", present(i, options)
87
+ end.join.html_safe
88
+ end
89
+
90
+ when Hash
91
+ # sort by keys
92
+ content_tag "dl" do
93
+ object.keys.sort.collect do |k|
94
+ content_tag("dt", k) +
95
+ content_tag("dd", present(object[k], options))
96
+ end.join.html_safe
97
+ end
98
+
99
+ when TrueClass, FalseClass
100
+ object ? "True" : "False"
101
+
102
+ when Date, Time, DateTime
103
+ l(object, :format => :default)
104
+
105
+ else
106
+ options[:h] ? h(object.to_s) : object.to_s
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,19 @@
1
+ module Presenting::Sanitize
2
+ class << self
3
+ include ERB::Util
4
+
5
+ # escape but preserve Arrays and Hashes
6
+ def h(val)
7
+ case val
8
+ when Array
9
+ val.map{|i| h(i)}
10
+
11
+ when Hash
12
+ val.clone.each{|k, v| val[h(k)] = h(v)}
13
+
14
+ else
15
+ html_escape(val)
16
+ end
17
+ end
18
+ end
19
+ end