nesta 0.9.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 (53) hide show
  1. data/.gitignore +13 -0
  2. data/Gemfile +6 -0
  3. data/Gemfile.lock +58 -0
  4. data/LICENSE +19 -0
  5. data/README.md +45 -0
  6. data/Rakefile +12 -0
  7. data/bin/nesta +67 -0
  8. data/config.ru +9 -0
  9. data/config/config.yml.sample +73 -0
  10. data/config/deploy.rb.sample +62 -0
  11. data/lib/nesta/app.rb +199 -0
  12. data/lib/nesta/cache.rb +139 -0
  13. data/lib/nesta/commands.rb +135 -0
  14. data/lib/nesta/config.rb +87 -0
  15. data/lib/nesta/models.rb +313 -0
  16. data/lib/nesta/nesta.rb +0 -0
  17. data/lib/nesta/overrides.rb +59 -0
  18. data/lib/nesta/path.rb +11 -0
  19. data/lib/nesta/plugins.rb +15 -0
  20. data/lib/nesta/version.rb +3 -0
  21. data/nesta.gemspec +49 -0
  22. data/scripts/import-from-mephisto +207 -0
  23. data/spec/atom_spec.rb +138 -0
  24. data/spec/commands_spec.rb +220 -0
  25. data/spec/config_spec.rb +69 -0
  26. data/spec/model_factory.rb +94 -0
  27. data/spec/models_spec.rb +445 -0
  28. data/spec/overrides_spec.rb +113 -0
  29. data/spec/page_spec.rb +428 -0
  30. data/spec/path_spec.rb +28 -0
  31. data/spec/sitemap_spec.rb +102 -0
  32. data/spec/spec.opts +1 -0
  33. data/spec/spec_helper.rb +72 -0
  34. data/templates/Gemfile +8 -0
  35. data/templates/Rakefile +35 -0
  36. data/templates/config.ru +9 -0
  37. data/templates/config/config.yml +73 -0
  38. data/templates/config/deploy.rb +47 -0
  39. data/views/analytics.haml +12 -0
  40. data/views/atom.builder +28 -0
  41. data/views/categories.haml +3 -0
  42. data/views/comments.haml +8 -0
  43. data/views/error.haml +13 -0
  44. data/views/feed.haml +3 -0
  45. data/views/index.haml +5 -0
  46. data/views/layout.haml +27 -0
  47. data/views/master.sass +246 -0
  48. data/views/not_found.haml +13 -0
  49. data/views/page.haml +29 -0
  50. data/views/sidebar.haml +3 -0
  51. data/views/sitemap.builder +15 -0
  52. data/views/summaries.haml +14 -0
  53. metadata +302 -0
@@ -0,0 +1,139 @@
1
+ require 'fileutils'
2
+ # require 'sinatra/base'
3
+
4
+ module Sinatra
5
+
6
+ # Sinatra Caching module
7
+ #
8
+ # TODO:: Need to write documentation here
9
+ #
10
+ module Cache
11
+
12
+ VERSION = 'Sinatra::Cache v0.2.0'
13
+ def self.version; VERSION; end
14
+
15
+
16
+ module Helpers
17
+
18
+ # Caches the given URI to a html file in /public
19
+ #
20
+ # <b>Usage:</b>
21
+ # >> cache( erb(:contact, :layout => :layout))
22
+ # => returns the HTML output written to /public/<CACHE_DIR_PATH>/contact.html
23
+ #
24
+ # Also accepts an Options Hash, with the following options:
25
+ # * :extension => in case you need to change the file extension
26
+ #
27
+ # TODO:: implement the opts={} hash functionality. What other options are needed?
28
+ #
29
+ def cache(content, opts={})
30
+ return content unless options.cache_enabled
31
+
32
+ unless content.nil?
33
+ content = "#{content}\n#{page_cached_timestamp}\n"
34
+ path = cache_page_path(request.path_info,opts)
35
+ FileUtils.makedirs(File.dirname(path))
36
+ open(path, 'wb+') { |f| f << content }
37
+ log("Cached Page: [#{path}]",:info)
38
+ content
39
+ end
40
+ end
41
+
42
+ # Expires the cached URI (as .html file) in /public
43
+ #
44
+ # <b>Usage:</b>
45
+ # >> cache_expire('/contact')
46
+ # => deletes the /public/<CACHE_DIR_PATH>contact.html page
47
+ #
48
+ # get '/contact' do
49
+ # cache_expire # deletes the /public/<CACHE_DIR_PATH>contact.html page as well
50
+ # end
51
+ #
52
+ # TODO:: implement the options={} hash functionality. What options are really needed ?
53
+ def cache_expire(path = nil, opts={})
54
+ return unless options.cache_enabled
55
+
56
+ path = (path.nil?) ? cache_page_path(request.path_info) : cache_page_path(path)
57
+ if File.exist?(path)
58
+ File.delete(path)
59
+ log("Expired Page deleted at: [#{path}]",:info)
60
+ else
61
+ log("No Expired Page was found at the path: [#{path}]",:info)
62
+ end
63
+ end
64
+
65
+ # Prints a basic HTML comment with a timestamp in it, so that you can see when a file was cached last.
66
+ #
67
+ # *NB!* IE6 does NOT like this to be the first line of a HTML document, so output
68
+ # inside the <head> tag. Many hours wasted on that lesson ;-)
69
+ #
70
+ # <b>Usage:</b>
71
+ # >> <%= page_cached_timestamp %>
72
+ # => <!-- page cached: 2009-02-24 12:00:00 -->
73
+ #
74
+ def page_cached_timestamp
75
+ "<!-- page cached: #{Time.now.strftime("%Y-%d-%m %H:%M:%S")} -->\n" if options.cache_enabled
76
+ end
77
+
78
+
79
+ private
80
+
81
+ # Establishes the file name of the cached file from the path given
82
+ #
83
+ # TODO:: implement the opts={} functionality, and support for custom extensions on a per request basis.
84
+ #
85
+ def cache_file_name(path,opts={})
86
+ name = (path.empty? || path == "/") ? "index" : Rack::Utils.unescape(path.sub(/^(\/)/,'').chomp('/'))
87
+ name << options.cache_page_extension unless (name.split('/').last || name).include? '.'
88
+ return name
89
+ end
90
+
91
+ # Sets the full path to the cached page/file
92
+ # Dependent upon Sinatra.options .public and .cache_dir variables being present and set.
93
+ #
94
+ def cache_page_path(path,opts={})
95
+ # test if given a full path rather than relative path, otherwise join the public path to cache_dir
96
+ # and ensure it is a full path
97
+ cache_dir = (options.cache_dir == File.expand_path(options.cache_dir)) ?
98
+ options.cache_dir : File.expand_path("#{options.public}/#{options.cache_dir}")
99
+ cache_dir = cache_dir[0..-2] if cache_dir[-1,1] == '/'
100
+ "#{cache_dir}/#{cache_file_name(path,opts)}"
101
+ end
102
+
103
+ # TODO:: this implementation really stinks, how do I incorporate Sinatra's logger??
104
+ def log(msg,scope=:debug)
105
+ if options.cache_logging
106
+ "Log: msg=[#{msg}]" if scope == options.cache_logging_level
107
+ else
108
+ # just ignore the stuff...
109
+ # puts "just ignoring msg=[#{msg}] since cache_logging => [#{options.cache_logging.to_s}]"
110
+ end
111
+ end
112
+
113
+ end #/module Helpers
114
+
115
+
116
+ # Sets the default options:
117
+ #
118
+ # * +:cache_enabled+ => toggle for the cache functionality. Default is: +true+
119
+ # * +:cache_page_extension+ => sets the default extension for cached files. Default is: +.html+
120
+ # * +:cache_dir+ => sets cache directory where cached files are stored. Default is: ''(empty) == root of /public.<br>
121
+ # set to empty, since the ideal 'system/cache/' does not work with Passenger & mod_rewrite :(
122
+ # * +cache_logging+ => toggle for logging the cache calls. Default is: +true+
123
+ # * +cache_logging_level+ => sets the level of the cache logger. Default is: <tt>:info</tt>.<br>
124
+ # Options:(unused atm) [:info, :warn, :debug]
125
+ #
126
+ def self.registered(app)
127
+ app.helpers(Cache::Helpers)
128
+ app.set :cache_enabled, true
129
+ app.set :cache_page_extension, '.html'
130
+ app.set :cache_dir, ''
131
+ app.set :cache_logging, true
132
+ app.set :cache_logging_level, :info
133
+ end
134
+
135
+ end #/module Cache
136
+
137
+ register(Sinatra::Cache)
138
+
139
+ end #/module Sinatra
@@ -0,0 +1,135 @@
1
+ require 'erb'
2
+ require 'fileutils'
3
+
4
+ require File.expand_path('app', File.dirname(__FILE__))
5
+ require File.expand_path('path', File.dirname(__FILE__))
6
+ require File.expand_path('version', File.dirname(__FILE__))
7
+
8
+ module Nesta
9
+ module Commands
10
+ class UsageError < RuntimeError; end
11
+
12
+ module Command
13
+ def fail(message)
14
+ $stderr.puts "Error: #{message}"
15
+ exit 1
16
+ end
17
+
18
+ def template_root
19
+ File.expand_path('../../templates', File.dirname(__FILE__))
20
+ end
21
+
22
+ def copy_template(src, dest)
23
+ FileUtils.mkdir_p(File.dirname(dest))
24
+ template = ERB.new(File.read(File.join(template_root, src)))
25
+ File.open(dest, 'w') { |file| file.puts template.result(binding) }
26
+ end
27
+
28
+ def copy_templates(templates)
29
+ templates.each { |src, dest| copy_template(src, dest) }
30
+ end
31
+ end
32
+
33
+ class New
34
+ include Command
35
+
36
+ def initialize(path, options = {})
37
+ path.nil? && (raise UsageError.new('path not specified'))
38
+ fail("#{path} already exists") if File.exist?(path)
39
+ @path = path
40
+ @options = options
41
+ end
42
+
43
+ def make_directories
44
+ %w[content/attachments content/pages].each do |dir|
45
+ FileUtils.mkdir_p(File.join(@path, dir))
46
+ end
47
+ end
48
+
49
+ def have_rake_tasks?
50
+ @options['heroku'] || @options['vlad']
51
+ end
52
+
53
+ def execute
54
+ make_directories
55
+ templates = {
56
+ 'config.ru' => "#{@path}/config.ru",
57
+ 'config/config.yml' => "#{@path}/config/config.yml",
58
+ 'Gemfile' => "#{@path}/Gemfile"
59
+ }
60
+ templates['Rakefile'] = "#{@path}/Rakefile" if have_rake_tasks?
61
+ if @options['vlad']
62
+ templates['config/deploy.rb'] = "#{@path}/config/deploy.rb"
63
+ end
64
+ copy_templates(templates)
65
+ end
66
+ end
67
+
68
+ module Theme
69
+ class Create
70
+ include Command
71
+
72
+ def initialize(name, options = {})
73
+ name.nil? && (raise UsageError.new('name not specified'))
74
+ @name = name
75
+ @theme_path = Nesta::Path.themes(@name)
76
+ fail("#{@theme_path} already exists") if File.exist?(@theme_path)
77
+ end
78
+
79
+ def make_directories
80
+ FileUtils.mkdir_p(File.join(@theme_path, 'public', @name))
81
+ FileUtils.mkdir_p(File.join(@theme_path, 'views'))
82
+ end
83
+
84
+ def execute
85
+ make_directories
86
+ copy_templates(
87
+ 'themes/README.md' => "#{@theme_path}/README.md",
88
+ 'themes/app.rb' => "#{@theme_path}/app.rb"
89
+ )
90
+ end
91
+ end
92
+
93
+ class Install
94
+ include Command
95
+
96
+ def initialize(url, options = {})
97
+ url.nil? && (raise UsageError.new('URL not specified'))
98
+ @url = url
99
+ @name = File.basename(url, '.git').sub(/nesta-theme-/, '')
100
+ end
101
+
102
+ def execute
103
+ system('git', 'clone', @url, "themes/#{@name}")
104
+ FileUtils.rm_r(File.join("themes/#{@name}", '.git'))
105
+ enable(@name)
106
+ end
107
+ end
108
+
109
+ class Enable
110
+ include Command
111
+
112
+ def initialize(name, options = {})
113
+ name.nil? && (raise UsageError.new('name not specified'))
114
+ @name = name
115
+ end
116
+
117
+ def execute
118
+ theme_config = /^\s*#?\s*theme:.*/
119
+ configured = false
120
+ File.open(Nesta::Config.yaml_path, 'r+') do |file|
121
+ output = ''
122
+ file.each_line do |line|
123
+ output << line.sub(theme_config, "theme: #{@name}")
124
+ configured = true if line =~ theme_config
125
+ end
126
+ output << "theme: #{@name}\n" unless configured
127
+ file.pos = 0
128
+ file.print(output)
129
+ file.truncate(file.pos)
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,87 @@
1
+ require "yaml"
2
+
3
+ require "rubygems"
4
+ require "sinatra"
5
+
6
+ module Nesta
7
+ class Config
8
+ @settings = %w[
9
+ title subtitle description keywords theme disqus_short_name
10
+ cache content google_analytics_code
11
+ ]
12
+ @author_settings = %w[name uri email]
13
+ @yaml = nil
14
+
15
+ class << self
16
+ attr_accessor :settings, :author_settings, :yaml_conf
17
+ end
18
+
19
+ def self.method_missing(method, *args)
20
+ setting = method.to_s
21
+ if settings.include?(setting)
22
+ from_environment(setting) || from_yaml(setting)
23
+ else
24
+ super
25
+ end
26
+ end
27
+
28
+ def self.author
29
+ environment_config = {}
30
+ %w[name uri email].each do |setting|
31
+ variable = "NESTA_AUTHOR__#{setting.upcase}"
32
+ ENV[variable] && environment_config[setting] = ENV[variable]
33
+ end
34
+ environment_config.empty? ? from_yaml("author") : environment_config
35
+ end
36
+
37
+ def self.content_path(basename = nil)
38
+ get_path(content, basename)
39
+ end
40
+
41
+ def self.page_path(basename = nil)
42
+ get_path(File.join(content_path, "pages"), basename)
43
+ end
44
+
45
+ def self.attachment_path(basename = nil)
46
+ get_path(File.join(content_path, "attachments"), basename)
47
+ end
48
+
49
+ def self.yaml_path
50
+ File.expand_path('config/config.yml', Nesta::App.root)
51
+ end
52
+
53
+ def self.from_environment(setting)
54
+ value = ENV["NESTA_#{setting.upcase}"]
55
+ overrides = { "true" => true, "false" => false }
56
+ overrides.has_key?(value) ? overrides[value] : value
57
+ end
58
+ private_class_method :from_environment
59
+
60
+ def self.yaml_exists?
61
+ File.exist?(yaml_path)
62
+ end
63
+ private_class_method :yaml_exists?
64
+
65
+ def self.can_use_yaml?
66
+ ENV.keys.grep(/^NESTA/).empty? && yaml_exists?
67
+ end
68
+ private_class_method :can_use_yaml?
69
+
70
+ def self.from_yaml(setting)
71
+ if can_use_yaml?
72
+ self.yaml_conf ||= YAML::load(IO.read(yaml_path))
73
+ rack_env_conf = self.yaml_conf[Nesta::App.environment.to_s]
74
+ (rack_env_conf && rack_env_conf[setting]) || self.yaml_conf[setting]
75
+ end
76
+ rescue Errno::ENOENT # config file not found
77
+ raise unless Nesta::App.environment == :test
78
+ nil
79
+ end
80
+ private_class_method :from_yaml
81
+
82
+ def self.get_path(dirname, basename)
83
+ basename.nil? ? dirname : File.join(dirname, basename)
84
+ end
85
+ private_class_method :get_path
86
+ end
87
+ end
@@ -0,0 +1,313 @@
1
+ require "time"
2
+
3
+ require "rubygems"
4
+ require "maruku"
5
+ require "redcloth"
6
+
7
+ module Nesta
8
+ class FileModel
9
+ FORMATS = [:mdown, :haml, :textile]
10
+ @@cache = {}
11
+
12
+ attr_reader :filename, :mtime
13
+
14
+ def self.model_path(basename = nil)
15
+ Nesta::Config.content_path(basename)
16
+ end
17
+
18
+ def self.find_all
19
+ file_pattern = File.join(model_path, "**", "*.{#{FORMATS.join(',')}}")
20
+ Dir.glob(file_pattern).map do |path|
21
+ relative = path.sub("#{model_path}/", "")
22
+ load(relative.sub(/\.(#{FORMATS.join('|')})/, ""))
23
+ end
24
+ end
25
+
26
+ def self.needs_loading?(path, filename)
27
+ @@cache[path].nil? || File.mtime(filename) > @@cache[path].mtime
28
+ end
29
+
30
+ def self.load(path)
31
+ FORMATS.each do |format|
32
+ filename = model_path("#{path}.#{format}")
33
+ if File.exist?(filename) && needs_loading?(path, filename)
34
+ @@cache[path] = self.new(filename)
35
+ break
36
+ end
37
+ end
38
+ @@cache[path]
39
+ end
40
+
41
+ def self.purge_cache
42
+ @@cache = {}
43
+ end
44
+
45
+ def self.deprecated(name, message)
46
+ if Nesta::App.environment != :test
47
+ $stderr.puts "DEPRECATION WARNING: #{name} is deprecated; #{message}"
48
+ end
49
+ end
50
+
51
+ def self.menu_items
52
+ deprecated("Page.menu_items", "see Menu.top_level and Menu.for_path")
53
+ Menu.top_level
54
+ end
55
+
56
+ def initialize(filename)
57
+ @filename = filename
58
+ @format = filename.split(".").last.to_sym
59
+ parse_file
60
+ @mtime = File.mtime(filename)
61
+ end
62
+
63
+ def permalink
64
+ File.basename(@filename, ".*")
65
+ end
66
+
67
+ def path
68
+ abspath.sub(/^\//, "")
69
+ end
70
+
71
+ def abspath
72
+ prefix = File.dirname(@filename).sub(Nesta::Config.page_path, "")
73
+ File.join(prefix, permalink)
74
+ end
75
+
76
+ def layout
77
+ (metadata("layout") || "layout").to_sym
78
+ end
79
+
80
+ def template
81
+ (metadata("template") || "page").to_sym
82
+ end
83
+
84
+ def to_html(scope = nil)
85
+ case @format
86
+ when :mdown
87
+ Maruku.new(markup).to_html
88
+ when :haml
89
+ Haml::Engine.new(markup).to_html(scope || Object.new)
90
+ when :textile
91
+ RedCloth.new(markup).to_html
92
+ end
93
+ end
94
+
95
+ def last_modified
96
+ @last_modified ||= File.stat(@filename).mtime
97
+ end
98
+
99
+ def description
100
+ metadata("description")
101
+ end
102
+
103
+ def keywords
104
+ metadata("keywords")
105
+ end
106
+
107
+ private
108
+ def markup
109
+ @markup
110
+ end
111
+
112
+ def metadata(key)
113
+ @metadata[key]
114
+ end
115
+
116
+ def paragraph_is_metadata(text)
117
+ text.split("\n").first =~ /^[\w ]+:/
118
+ end
119
+
120
+ def parse_file
121
+ first_para, remaining = File.open(@filename).read.split(/\r?\n\r?\n/, 2)
122
+ @metadata = {}
123
+ if paragraph_is_metadata(first_para)
124
+ @markup = remaining
125
+ for line in first_para.split("\n") do
126
+ key, value = line.split(/\s*:\s*/, 2)
127
+ @metadata[key.downcase] = value.chomp
128
+ end
129
+ else
130
+ @markup = [first_para, remaining].join("\n\n")
131
+ end
132
+ rescue Errno::ENOENT # file not found
133
+ raise Sinatra::NotFound
134
+ end
135
+ end
136
+
137
+ class Page < FileModel
138
+ def self.model_path(basename = nil)
139
+ Nesta::Config.page_path(basename)
140
+ end
141
+
142
+ def self.find_by_path(path)
143
+ load(path)
144
+ end
145
+
146
+ def self.find_articles
147
+ find_all.select do |page|
148
+ page.date && page.date < DateTime.now
149
+ end.sort { |x, y| y.date <=> x.date }
150
+ end
151
+
152
+ def ==(other)
153
+ other.respond_to?(:path) && (self.path == other.path)
154
+ end
155
+
156
+ def heading
157
+ regex = case @format
158
+ when :mdown
159
+ /^#\s*(.*)/
160
+ when :haml
161
+ /^\s*%h1\s+(.*)/
162
+ when :textile
163
+ /^\s*h1\.\s+(.*)/
164
+ end
165
+ markup =~ regex
166
+ Regexp.last_match(1)
167
+ end
168
+
169
+ def date(format = nil)
170
+ @date ||= if metadata("date")
171
+ if format == :xmlschema
172
+ Time.parse(metadata("date")).xmlschema
173
+ else
174
+ DateTime.parse(metadata("date"))
175
+ end
176
+ end
177
+ end
178
+
179
+ def atom_id
180
+ metadata("atom id")
181
+ end
182
+
183
+ def read_more
184
+ metadata("read more") || "Continue reading"
185
+ end
186
+
187
+ def summary
188
+ if summary_text = metadata("summary")
189
+ summary_text.gsub!('\n', "\n")
190
+ case @format
191
+ when :textile
192
+ RedCloth.new(summary_text).to_html
193
+ else
194
+ Maruku.new(summary_text).to_html
195
+ end
196
+ end
197
+ end
198
+
199
+ def body
200
+ case @format
201
+ when :mdown
202
+ body_text = markup.sub(/^#[^#].*$\r?\n(\r?\n)?/, "")
203
+ Maruku.new(body_text).to_html
204
+ when :haml
205
+ body_text = markup.sub(/^\s*%h1\s+.*$\r?\n(\r?\n)?/, "")
206
+ Haml::Engine.new(body_text).render
207
+ when :textile
208
+ body_text = markup.sub(/^\s*h1\.\s+.*$\r?\n(\r?\n)?/, "")
209
+ RedCloth.new(body_text).to_html
210
+ end
211
+ end
212
+
213
+ def categories
214
+ categories = metadata("categories")
215
+ paths = categories.nil? ? [] : categories.split(",").map { |p| p.strip }
216
+ valid_paths(paths).map { |p| Page.find_by_path(p) }.sort do |x, y|
217
+ x.heading.downcase <=> y.heading.downcase
218
+ end
219
+ end
220
+
221
+ def parent
222
+ Page.load(File.dirname(path))
223
+ end
224
+
225
+ def pages
226
+ Page.find_all.select do |page|
227
+ page.date.nil? && page.categories.include?(self)
228
+ end.sort do |x, y|
229
+ x.heading.downcase <=> y.heading.downcase
230
+ end
231
+ end
232
+
233
+ def articles
234
+ Page.find_articles.select { |article| article.categories.include?(self) }
235
+ end
236
+
237
+ private
238
+ def valid_paths(paths)
239
+ paths.select do |path|
240
+ FORMATS.detect do |format|
241
+ File.exist?(File.join(Nesta::Config.page_path, "#{path}.#{format}"))
242
+ end
243
+ end
244
+ end
245
+ end
246
+
247
+ class Menu
248
+ INDENT = " " * 2
249
+
250
+ def self.full_menu
251
+ menu = []
252
+ menu_file = Nesta::Config.content_path('menu.txt')
253
+ if File.exist?(menu_file)
254
+ File.open(menu_file) { |file| append_menu_item(menu, file, 0) }
255
+ end
256
+ menu
257
+ end
258
+
259
+ def self.top_level
260
+ full_menu.reject { |item| item.is_a?(Array) }
261
+ end
262
+
263
+ def self.for_path(path)
264
+ path.sub!(Regexp.new('^/'), '')
265
+ if path.empty?
266
+ full_menu
267
+ else
268
+ find_menu_item_by_path(full_menu, path)
269
+ end
270
+ end
271
+
272
+ private
273
+ def self.append_menu_item(menu, file, depth)
274
+ path = file.readline
275
+ rescue EOFError
276
+ else
277
+ page = Page.load(path.strip)
278
+ if page
279
+ current_depth = path.scan(INDENT).size
280
+ if current_depth > depth
281
+ sub_menu_for_depth(menu, depth) << [page]
282
+ else
283
+ sub_menu_for_depth(menu, current_depth) << page
284
+ end
285
+ append_menu_item(menu, file, current_depth)
286
+ end
287
+ end
288
+
289
+ def self.sub_menu_for_depth(menu, depth)
290
+ sub_menu = menu
291
+ depth.times { sub_menu = sub_menu[-1] }
292
+ sub_menu
293
+ end
294
+
295
+ def self.find_menu_item_by_path(menu, path)
296
+ item = menu.detect do |item|
297
+ item.respond_to?(:path) && (item.path == path)
298
+ end
299
+ if item
300
+ subsequent = menu[menu.index(item) + 1]
301
+ item = [item]
302
+ item << subsequent if subsequent.respond_to?(:each)
303
+ else
304
+ sub_menus = menu.select { |menu_item| menu_item.respond_to?(:each) }
305
+ sub_menus.each do |sub_menu|
306
+ item = find_menu_item_by_path(sub_menu, path)
307
+ break if item
308
+ end
309
+ end
310
+ item
311
+ end
312
+ end
313
+ end