mars-nesta 0.9.4

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 (61) hide show
  1. data/.gitignore +13 -0
  2. data/CHANGES +97 -0
  3. data/Gemfile +6 -0
  4. data/Gemfile.lock +44 -0
  5. data/LICENSE +19 -0
  6. data/README.md +42 -0
  7. data/Rakefile +12 -0
  8. data/bin/nesta +79 -0
  9. data/config.ru +9 -0
  10. data/config/deploy.rb.sample +62 -0
  11. data/lib/nesta/app.rb +167 -0
  12. data/lib/nesta/cache.rb +139 -0
  13. data/lib/nesta/commands.rb +188 -0
  14. data/lib/nesta/config.rb +86 -0
  15. data/lib/nesta/models.rb +364 -0
  16. data/lib/nesta/navigation.rb +60 -0
  17. data/lib/nesta/nesta.rb +7 -0
  18. data/lib/nesta/overrides.rb +59 -0
  19. data/lib/nesta/path.rb +11 -0
  20. data/lib/nesta/plugins.rb +15 -0
  21. data/lib/nesta/version.rb +3 -0
  22. data/nesta.gemspec +48 -0
  23. data/scripts/import-from-mephisto +207 -0
  24. data/spec/atom_spec.rb +138 -0
  25. data/spec/commands_spec.rb +292 -0
  26. data/spec/config_spec.rb +69 -0
  27. data/spec/model_factory.rb +94 -0
  28. data/spec/models_spec.rb +554 -0
  29. data/spec/overrides_spec.rb +114 -0
  30. data/spec/page_spec.rb +458 -0
  31. data/spec/path_spec.rb +28 -0
  32. data/spec/sitemap_spec.rb +102 -0
  33. data/spec/spec.opts +1 -0
  34. data/spec/spec_helper.rb +70 -0
  35. data/templates/Gemfile +8 -0
  36. data/templates/Rakefile +11 -0
  37. data/templates/config.ru +9 -0
  38. data/templates/config/config.yml +67 -0
  39. data/templates/config/deploy.rb +47 -0
  40. data/templates/index.haml +1 -0
  41. data/templates/themes/README.md +7 -0
  42. data/templates/themes/app.rb +19 -0
  43. data/views/analytics.haml +12 -0
  44. data/views/atom.haml +28 -0
  45. data/views/categories.haml +4 -0
  46. data/views/colors.sass +10 -0
  47. data/views/comments.haml +8 -0
  48. data/views/error.haml +12 -0
  49. data/views/feed.haml +3 -0
  50. data/views/footer.haml +5 -0
  51. data/views/header.haml +4 -0
  52. data/views/layout.haml +24 -0
  53. data/views/master.sass +246 -0
  54. data/views/mixins.sass +39 -0
  55. data/views/not_found.haml +12 -0
  56. data/views/page.haml +21 -0
  57. data/views/page_meta.haml +16 -0
  58. data/views/sidebar.haml +3 -0
  59. data/views/sitemap.haml +11 -0
  60. data/views/summaries.haml +15 -0
  61. metadata +235 -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,188 @@
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
+
32
+ def update_config_yaml(pattern, replacement)
33
+ configured = false
34
+ File.open(Nesta::Config.yaml_path, 'r+') do |file|
35
+ output = ''
36
+ file.each_line do |line|
37
+ if configured
38
+ output << line
39
+ else
40
+ output << line.sub(pattern, replacement)
41
+ configured = true if line =~ pattern
42
+ end
43
+ end
44
+ output << "#{replacement}\n" unless configured
45
+ file.pos = 0
46
+ file.print(output)
47
+ file.truncate(file.pos)
48
+ end
49
+ end
50
+ end
51
+
52
+ class New
53
+ include Command
54
+
55
+ def initialize(path, options = {})
56
+ path.nil? && (raise UsageError.new('path not specified'))
57
+ fail("#{path} already exists") if File.exist?(path)
58
+ @path = path
59
+ @options = options
60
+ end
61
+
62
+ def make_directories
63
+ %w[content/attachments content/pages].each do |dir|
64
+ FileUtils.mkdir_p(File.join(@path, dir))
65
+ end
66
+ end
67
+
68
+ def have_rake_tasks?
69
+ @options['vlad']
70
+ end
71
+
72
+ def create_repository
73
+ FileUtils.cd(@path) do
74
+ File.open('.gitignore', 'w') do |file|
75
+ file.puts %w[._* .*.swp .bundle .DS_Store .sass-cache].join("\n")
76
+ end
77
+ system('git', 'init')
78
+ system('git', 'add', '.')
79
+ system('git', 'commit', '-m', 'Initial commit')
80
+ end
81
+ end
82
+
83
+ def execute
84
+ make_directories
85
+ templates = {
86
+ 'config.ru' => "#{@path}/config.ru",
87
+ 'config/config.yml' => "#{@path}/config/config.yml",
88
+ 'index.haml' => "#{@path}/content/pages/index.haml",
89
+ 'Gemfile' => "#{@path}/Gemfile"
90
+ }
91
+ templates['Rakefile'] = "#{@path}/Rakefile" if have_rake_tasks?
92
+ if @options['vlad']
93
+ templates['config/deploy.rb'] = "#{@path}/config/deploy.rb"
94
+ end
95
+ copy_templates(templates)
96
+ create_repository if @options['git']
97
+ end
98
+ end
99
+
100
+ module Demo
101
+ class Content
102
+ include Command
103
+
104
+ def initialize
105
+ @dir = 'content-demo'
106
+ end
107
+
108
+ def clone_or_update_repository
109
+ repository = 'git://github.com/gma/nesta-demo-content.git'
110
+ path = Nesta::Path.local(@dir)
111
+ if File.exist?(path)
112
+ FileUtils.cd(path) { system('git', 'pull', 'origin', 'master') }
113
+ else
114
+ system('git', 'clone', repository, path)
115
+ end
116
+ end
117
+
118
+ def configure_git_to_ignore_repo
119
+ excludes = Nesta::Path.local('.git/info/exclude')
120
+ if File.exist?(excludes) && File.read(excludes).scan(@dir).empty?
121
+ File.open(excludes, 'a') { |file| file.puts @dir }
122
+ end
123
+ end
124
+
125
+ def execute
126
+ clone_or_update_repository
127
+ configure_git_to_ignore_repo
128
+ update_config_yaml(/^\s*#?\s*content:.*/, "content: #{@dir}")
129
+ end
130
+ end
131
+ end
132
+
133
+ module Theme
134
+ class Create
135
+ include Command
136
+
137
+ def initialize(name, options = {})
138
+ name.nil? && (raise UsageError.new('name not specified'))
139
+ @name = name
140
+ @theme_path = Nesta::Path.themes(@name)
141
+ fail("#{@theme_path} already exists") if File.exist?(@theme_path)
142
+ end
143
+
144
+ def make_directories
145
+ FileUtils.mkdir_p(File.join(@theme_path, 'public', @name))
146
+ FileUtils.mkdir_p(File.join(@theme_path, 'views'))
147
+ end
148
+
149
+ def execute
150
+ make_directories
151
+ copy_templates(
152
+ 'themes/README.md' => "#{@theme_path}/README.md",
153
+ 'themes/app.rb' => "#{@theme_path}/app.rb"
154
+ )
155
+ end
156
+ end
157
+
158
+ class Install
159
+ include Command
160
+
161
+ def initialize(url, options = {})
162
+ url.nil? && (raise UsageError.new('URL not specified'))
163
+ @url = url
164
+ @name = File.basename(url, '.git').sub(/nesta-theme-/, '')
165
+ end
166
+
167
+ def execute
168
+ system('git', 'clone', @url, "themes/#{@name}")
169
+ FileUtils.rm_r(File.join("themes/#{@name}", '.git'))
170
+ enable(@name)
171
+ end
172
+ end
173
+
174
+ class Enable
175
+ include Command
176
+
177
+ def initialize(name, options = {})
178
+ name.nil? && (raise UsageError.new('name not specified'))
179
+ @name = name
180
+ end
181
+
182
+ def execute
183
+ update_config_yaml(/^\s*#?\s*theme:.*/, "theme: #{@name}")
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,86 @@
1
+ require "yaml"
2
+
3
+ require "rubygems"
4
+ require "sinatra"
5
+
6
+ module Nesta
7
+ class Config
8
+ @settings = %w[
9
+ title subtitle theme disqus_short_name cache content google_analytics_code
10
+ ]
11
+ @author_settings = %w[name uri email]
12
+ @yaml = nil
13
+
14
+ class << self
15
+ attr_accessor :settings, :author_settings, :yaml_conf
16
+ end
17
+
18
+ def self.method_missing(method, *args)
19
+ setting = method.to_s
20
+ if settings.include?(setting)
21
+ from_environment(setting) || from_yaml(setting)
22
+ else
23
+ super
24
+ end
25
+ end
26
+
27
+ def self.author
28
+ environment_config = {}
29
+ %w[name uri email].each do |setting|
30
+ variable = "NESTA_AUTHOR__#{setting.upcase}"
31
+ ENV[variable] && environment_config[setting] = ENV[variable]
32
+ end
33
+ environment_config.empty? ? from_yaml("author") : environment_config
34
+ end
35
+
36
+ def self.content_path(basename = nil)
37
+ get_path(content, basename)
38
+ end
39
+
40
+ def self.page_path(basename = nil)
41
+ get_path(File.join(content_path, "pages"), basename)
42
+ end
43
+
44
+ def self.attachment_path(basename = nil)
45
+ get_path(File.join(content_path, "attachments"), basename)
46
+ end
47
+
48
+ def self.yaml_path
49
+ File.expand_path('config/config.yml', Nesta::App.root)
50
+ end
51
+
52
+ def self.from_environment(setting)
53
+ value = ENV["NESTA_#{setting.upcase}"]
54
+ overrides = { "true" => true, "false" => false }
55
+ overrides.has_key?(value) ? overrides[value] : value
56
+ end
57
+ private_class_method :from_environment
58
+
59
+ def self.yaml_exists?
60
+ File.exist?(yaml_path)
61
+ end
62
+ private_class_method :yaml_exists?
63
+
64
+ def self.can_use_yaml?
65
+ ENV.keys.grep(/^NESTA/).empty? && yaml_exists?
66
+ end
67
+ private_class_method :can_use_yaml?
68
+
69
+ def self.from_yaml(setting)
70
+ if can_use_yaml?
71
+ self.yaml_conf ||= YAML::load(IO.read(yaml_path))
72
+ rack_env_conf = self.yaml_conf[Nesta::App.environment.to_s]
73
+ (rack_env_conf && rack_env_conf[setting]) || self.yaml_conf[setting]
74
+ end
75
+ rescue Errno::ENOENT # config file not found
76
+ raise unless Nesta::App.environment == :test
77
+ nil
78
+ end
79
+ private_class_method :from_yaml
80
+
81
+ def self.get_path(dirname, basename)
82
+ basename.nil? ? dirname : File.join(dirname, basename)
83
+ end
84
+ private_class_method :get_path
85
+ end
86
+ end
@@ -0,0 +1,364 @@
1
+ require "time"
2
+
3
+ require "rubygems"
4
+ require "redcloth"
5
+ begin
6
+ require "redcarpet"
7
+ Maruku = Redcarpet
8
+ rescue LoadError
9
+ require "maruku"
10
+ end
11
+
12
+ module Nesta
13
+ class FileModel
14
+ FORMATS = [:mdown, :haml, :textile]
15
+ @@cache = {}
16
+
17
+ attr_reader :filename, :mtime
18
+
19
+ def self.model_path(basename = nil)
20
+ Nesta::Config.content_path(basename)
21
+ end
22
+
23
+ def self.find_all
24
+ file_pattern = File.join(model_path, "**", "*.{#{FORMATS.join(',')}}")
25
+ Dir.glob(file_pattern).map do |path|
26
+ relative = path.sub("#{model_path}/", "")
27
+ load(relative.sub(/\.(#{FORMATS.join('|')})/, ""))
28
+ end
29
+ end
30
+
31
+ def self.needs_loading?(path, filename)
32
+ @@cache[path].nil? || File.mtime(filename) > @@cache[path].mtime
33
+ end
34
+
35
+ def self.load(path)
36
+ FORMATS.each do |format|
37
+ [path, File.join(path, 'index')].each do |basename|
38
+ filename = model_path("#{basename}.#{format}")
39
+ if File.exist?(filename) && needs_loading?(path, filename)
40
+ @@cache[path] = self.new(filename)
41
+ break
42
+ end
43
+ end
44
+ end
45
+ @@cache[path]
46
+ end
47
+
48
+ def self.purge_cache
49
+ @@cache = {}
50
+ end
51
+
52
+ def self.menu_items
53
+ Nesta.deprecated('Page.menu_items', 'see Menu.top_level and Menu.for_path')
54
+ Menu.top_level
55
+ end
56
+
57
+ def initialize(filename)
58
+ @filename = filename
59
+ @format = filename.split(".").last.to_sym
60
+ parse_file
61
+ @mtime = File.mtime(filename)
62
+ end
63
+
64
+ def index_page?
65
+ @filename =~ /\/?index\.\w+$/
66
+ end
67
+
68
+ def abspath
69
+ page_path = @filename.sub(Nesta::Config.page_path, '')
70
+ if index_page?
71
+ File.dirname(page_path)
72
+ else
73
+ File.join(File.dirname(page_path), File.basename(page_path, '.*'))
74
+ end
75
+ end
76
+
77
+ def path
78
+ abspath.sub(/^\//, '')
79
+ end
80
+
81
+ def permalink
82
+ File.basename(path)
83
+ end
84
+
85
+ def layout
86
+ (metadata("layout") || "layout").to_sym
87
+ end
88
+
89
+ def template
90
+ (metadata("template") || "page").to_sym
91
+ end
92
+
93
+ def to_html(scope = nil)
94
+ case @format
95
+ when :mdown
96
+ Maruku.new(markup).to_html
97
+ when :haml
98
+ Haml::Engine.new(markup).to_html(scope || Object.new)
99
+ when :textile
100
+ RedCloth.new(markup).to_html
101
+ end
102
+ end
103
+
104
+ def last_modified
105
+ @last_modified ||= File.stat(@filename).mtime
106
+ end
107
+
108
+ def description
109
+ metadata("description")
110
+ end
111
+
112
+ def keywords
113
+ metadata("keywords")
114
+ end
115
+
116
+ def metadata(key)
117
+ @metadata[key]
118
+ end
119
+
120
+ private
121
+ def markup
122
+ @markup
123
+ end
124
+
125
+ def paragraph_is_metadata(text)
126
+ text.split("\n").first =~ /^[\w ]+:/
127
+ end
128
+
129
+ def parse_file
130
+ first_para, remaining = File.open(@filename).read.split(/\r?\n\r?\n/, 2)
131
+ @metadata = {}
132
+ if paragraph_is_metadata(first_para)
133
+ @markup = remaining
134
+ for line in first_para.split("\n") do
135
+ key, value = line.split(/\s*:\s*/, 2)
136
+ @metadata[key.downcase] = value.chomp
137
+ end
138
+ else
139
+ @markup = [first_para, remaining].join("\n\n")
140
+ end
141
+ rescue Errno::ENOENT # file not found
142
+ raise Sinatra::NotFound
143
+ end
144
+ end
145
+
146
+ class Page < FileModel
147
+ def self.model_path(basename = nil)
148
+ Nesta::Config.page_path(basename)
149
+ end
150
+
151
+ def self.find_by_path(path)
152
+ load(path)
153
+ end
154
+
155
+ def self.find_articles
156
+ find_all.select do |page|
157
+ page.date && page.date < DateTime.now
158
+ end.sort { |x, y| y.date <=> x.date }
159
+ end
160
+
161
+ def ==(other)
162
+ other.respond_to?(:path) && (self.path == other.path)
163
+ end
164
+
165
+ def heading
166
+ regex = case @format
167
+ when :mdown
168
+ /^#\s*(.*)/
169
+ when :haml
170
+ /^\s*%h1\s+(.*)/
171
+ when :textile
172
+ /^\s*h1\.\s+(.*)/
173
+ end
174
+ markup =~ regex
175
+ Regexp.last_match(1)
176
+ end
177
+
178
+ def title
179
+ if metadata('title')
180
+ metadata('title')
181
+ elsif parent && (! parent.heading.nil?)
182
+ "#{heading} - #{parent.heading}"
183
+ elsif heading
184
+ "#{heading} - #{Nesta::Config.title}"
185
+ elsif abspath == '/'
186
+ Nesta::Config.title
187
+ end
188
+ end
189
+
190
+ def date(format = nil)
191
+ @date ||= if metadata("date")
192
+ if format == :xmlschema
193
+ Time.parse(metadata("date")).xmlschema
194
+ else
195
+ DateTime.parse(metadata("date"))
196
+ end
197
+ end
198
+ end
199
+
200
+ def atom_id
201
+ metadata("atom id")
202
+ end
203
+
204
+ def read_more
205
+ metadata("read more") || "Continue reading"
206
+ end
207
+
208
+ def summary
209
+ if summary_text = metadata("summary")
210
+ summary_text.gsub!('\n', "\n")
211
+ case @format
212
+ when :textile
213
+ RedCloth.new(summary_text).to_html
214
+ else
215
+ Maruku.new(summary_text).to_html
216
+ end
217
+ end
218
+ end
219
+
220
+ def body
221
+ case @format
222
+ when :mdown
223
+ body_text = markup.sub(/^#[^#].*$\r?\n(\r?\n)?/, "")
224
+ Maruku.new(body_text).to_html
225
+ when :haml
226
+ body_text = markup.sub(/^\s*%h1\s+.*$\r?\n(\r?\n)?/, "")
227
+ Haml::Engine.new(body_text).render
228
+ when :textile
229
+ body_text = markup.sub(/^\s*h1\.\s+.*$\r?\n(\r?\n)?/, "")
230
+ RedCloth.new(body_text).to_html
231
+ end
232
+ end
233
+
234
+ def categories
235
+ paths = category_strings.map { |specifier| specifier.sub(/:-?\d+$/, '') }
236
+ pages = valid_paths(paths).map { |p| Page.find_by_path(p) }
237
+ pages.sort do |x, y|
238
+ x.heading.downcase <=> y.heading.downcase
239
+ end
240
+ end
241
+
242
+ def priority(category)
243
+ category_string = category_strings.detect do |string|
244
+ string =~ /^#{category}([,:\s]|$)/
245
+ end
246
+ category_string && category_string.split(':', 2)[-1].to_i
247
+ end
248
+
249
+ def parent
250
+ if abspath == '/'
251
+ nil
252
+ else
253
+ parent_path = File.dirname(path)
254
+ while parent_path != '.' do
255
+ parent = Page.load(parent_path)
256
+ return parent unless parent.nil?
257
+ parent_path = File.dirname(parent_path)
258
+ end
259
+ Page.load('index')
260
+ end
261
+ end
262
+
263
+ def pages
264
+ Page.find_all.select do |page|
265
+ page.date.nil? && page.categories.include?(self)
266
+ end.sort do |x, y|
267
+ by_priority = y.priority(path) <=> x.priority(path)
268
+ if by_priority == 0
269
+ x.heading.downcase <=> y.heading.downcase
270
+ else
271
+ by_priority
272
+ end
273
+ end
274
+ end
275
+
276
+ def articles
277
+ Page.find_articles.select { |article| article.categories.include?(self) }
278
+ end
279
+
280
+ private
281
+ def category_strings
282
+ strings = metadata('categories')
283
+ strings.nil? ? [] : strings.split(',').map { |string| string.strip }
284
+ end
285
+
286
+ def valid_paths(paths)
287
+ page_dir = Nesta::Config.page_path
288
+ paths.select do |path|
289
+ FORMATS.detect do |format|
290
+ [path, File.join(path, 'index')].detect do |candidate|
291
+ File.exist?(File.join(page_dir, "#{candidate}.#{format}"))
292
+ end
293
+ end
294
+ end
295
+ end
296
+ end
297
+
298
+ class Menu
299
+ INDENT = " " * 2
300
+
301
+ def self.full_menu
302
+ menu = []
303
+ menu_file = Nesta::Config.content_path('menu.txt')
304
+ if File.exist?(menu_file)
305
+ File.open(menu_file) { |file| append_menu_item(menu, file, 0) }
306
+ end
307
+ menu
308
+ end
309
+
310
+ def self.top_level
311
+ full_menu.reject { |item| item.is_a?(Array) }
312
+ end
313
+
314
+ def self.for_path(path)
315
+ path.sub!(Regexp.new('^/'), '')
316
+ if path.empty?
317
+ full_menu
318
+ else
319
+ find_menu_item_by_path(full_menu, path)
320
+ end
321
+ end
322
+
323
+ private
324
+ def self.append_menu_item(menu, file, depth)
325
+ path = file.readline
326
+ rescue EOFError
327
+ else
328
+ page = Page.load(path.strip)
329
+ if page
330
+ current_depth = path.scan(INDENT).size
331
+ if current_depth > depth
332
+ sub_menu_for_depth(menu, depth) << [page]
333
+ else
334
+ sub_menu_for_depth(menu, current_depth) << page
335
+ end
336
+ append_menu_item(menu, file, current_depth)
337
+ end
338
+ end
339
+
340
+ def self.sub_menu_for_depth(menu, depth)
341
+ sub_menu = menu
342
+ depth.times { sub_menu = sub_menu[-1] }
343
+ sub_menu
344
+ end
345
+
346
+ def self.find_menu_item_by_path(menu, path)
347
+ item = menu.detect do |item|
348
+ item.respond_to?(:path) && (item.path == path)
349
+ end
350
+ if item
351
+ subsequent = menu[menu.index(item) + 1]
352
+ item = [item]
353
+ item << subsequent if subsequent.respond_to?(:each)
354
+ else
355
+ sub_menus = menu.select { |menu_item| menu_item.respond_to?(:each) }
356
+ sub_menus.each do |sub_menu|
357
+ item = find_menu_item_by_path(sub_menu, path)
358
+ break if item
359
+ end
360
+ end
361
+ item
362
+ end
363
+ end
364
+ end