noumenon 0.0.3 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (73) hide show
  1. data/.gitignore +3 -0
  2. data/.travis.yml +0 -4
  3. data/.yardopts +5 -0
  4. data/Gemfile +1 -3
  5. data/README.md +57 -80
  6. data/Rakefile +17 -6
  7. data/bin/noumenon +6 -0
  8. data/features/dynamic_template_rendering.feature +107 -0
  9. data/features/generator/site_generator.feature +25 -0
  10. data/features/mounted_applications.feature +30 -0
  11. data/features/static_template_rendering.feature +43 -0
  12. data/features/step_definitions/asset_steps.rb +7 -0
  13. data/features/step_definitions/content_steps.rb +7 -0
  14. data/features/step_definitions/generator_steps.rb +22 -0
  15. data/features/step_definitions/request_steps.rb +31 -0
  16. data/features/step_definitions/theme_steps.rb +19 -0
  17. data/features/support/env.rb +38 -0
  18. data/features/support/theme/theme.yml +5 -0
  19. data/features/theme_assets.feature +22 -0
  20. data/generators/repository/index.yml +3 -0
  21. data/generators/site/Gemfile +3 -0
  22. data/generators/site/config.ru +7 -0
  23. data/generators/theme/assets/style.css +1 -0
  24. data/generators/theme/layouts/default.nou.html +23 -0
  25. data/generators/theme/templates/default.nou.html +12 -0
  26. data/generators/theme/theme.yml +5 -0
  27. data/lib/noumenon/cli.rb +27 -0
  28. data/lib/noumenon/core.rb +70 -77
  29. data/lib/noumenon/repository/file_system.rb +102 -0
  30. data/lib/noumenon/repository.rb +39 -0
  31. data/lib/noumenon/spec/example_app.rb +19 -6
  32. data/lib/noumenon/spec/theme_helpers.rb +66 -0
  33. data/lib/noumenon/spec.rb +4 -7
  34. data/lib/noumenon/string_extensions.rb +21 -0
  35. data/lib/noumenon/template.rb +113 -72
  36. data/lib/noumenon/theme/assets_middleware.rb +21 -0
  37. data/lib/noumenon/theme.rb +106 -0
  38. data/lib/noumenon/version.rb +3 -1
  39. data/lib/noumenon.rb +68 -100
  40. data/noumenon.gemspec +13 -9
  41. data/spec/noumenon/repository/file_system_spec.rb +115 -0
  42. data/spec/noumenon/repository_spec.rb +40 -0
  43. data/spec/noumenon/template_spec.rb +9 -7
  44. data/spec/noumenon/theme_spec.rb +129 -0
  45. data/spec/noumenon_spec.rb +24 -80
  46. data/spec/spec_helper.rb +5 -14
  47. data/spec/support/file_matchers.rb +45 -0
  48. data/spec/support/templates/basic_template.html +1 -0
  49. data/spec/{fixtures/themes/example_without_layout → support}/templates/template_with_fields.html +1 -1
  50. metadata +143 -62
  51. data/lib/noumenon/configuration.rb +0 -28
  52. data/lib/noumenon/spec/fixtures.rb +0 -34
  53. data/spec/fixtures/fixture_specs/test +0 -1
  54. data/spec/fixtures/missing_application/mounted_app/config.yml +0 -1
  55. data/spec/fixtures/static_example/directory_with_index/index.yml +0 -1
  56. data/spec/fixtures/static_example/found.html +0 -1
  57. data/spec/fixtures/static_example/liquid_example.html +0 -1
  58. data/spec/fixtures/static_example/mounted_app/config.yml +0 -1
  59. data/spec/fixtures/static_example/template_with_substitutions.html +0 -1
  60. data/spec/fixtures/static_example/templates/basic_example.yml +0 -1
  61. data/spec/fixtures/static_example/templates/with_fields.yml +0 -2
  62. data/spec/fixtures/themes/example/assets/example.txt +0 -1
  63. data/spec/fixtures/themes/example/templates/layout.html +0 -3
  64. data/spec/fixtures/themes/example_without_layout/templates/basic_template.html +0 -1
  65. data/spec/fixtures/themes/example_without_layout/templates/fields.html +0 -1
  66. data/spec/fixtures/themes/external_theme/assets/example.txt +0 -1
  67. data/spec/fixtures/themes/unregistered/lib/unregistered.rb +0 -1
  68. data/spec/noumenon/config_spec.rb +0 -29
  69. data/spec/noumenon/core_spec.rb +0 -105
  70. data/spec/noumenon/spec/example_app_spec.rb +0 -14
  71. data/spec/noumenon/spec/fixtures_spec.rb +0 -41
  72. data/spec/noumenon/spec_spec.rb +0 -7
  73. data/watchr.rb +0 -2
@@ -0,0 +1,23 @@
1
+ title:
2
+ type: string
3
+ description: The title to put in the browser's title bar.
4
+ required: false
5
+
6
+ content:
7
+ type: string
8
+ description: The content from the page template.
9
+ required: true
10
+ ---
11
+ <!doctype html>
12
+ <html>
13
+ <head>
14
+ <title>{{ title }}</title>
15
+ <link rel="stylesheet" href="/themes/Your%20Theme/styles.css" />
16
+ </head>
17
+
18
+ <body>
19
+ <section id="content">
20
+ {{ content }}
21
+ </section>
22
+ </body>
23
+ </html>
@@ -0,0 +1,12 @@
1
+ title:
2
+ description: The title of the content block
3
+ required: true
4
+ type: string
5
+
6
+ body:
7
+ description: The main content
8
+ required: true
9
+ type: text
10
+ ---
11
+ <h1>{{ title }}</h1>
12
+ <p>{{ body }}</p>
@@ -0,0 +1,5 @@
1
+ name: "Generated Theme"
2
+ author: "Your Name"
3
+ email: "you@example.org"
4
+ copyright: "2011, Your Name"
5
+ license: "MIT"
@@ -0,0 +1,27 @@
1
+ require 'thor'
2
+
3
+ class Noumenon::Cli < Thor
4
+ include Thor::Actions
5
+
6
+ source_root File.expand_path("../../../generators", __FILE__)
7
+
8
+ desc "site PATH", "Generate a new site at PATH"
9
+ def site(path)
10
+ say_status :site, path
11
+ directory "site", path
12
+ invoke :theme, [ File.join(path, "theme") ]
13
+ invoke :repository, [ File.join(path, "content") ]
14
+ end
15
+
16
+ desc "theme PATH", "Generate a new theme at PATH"
17
+ def theme(path)
18
+ say_status :theme, path
19
+ directory "theme", path
20
+ end
21
+
22
+ desc "repository PATH", "Generate a new content repository at PATH"
23
+ def repository(path)
24
+ say_status :repository, path
25
+ directory "repository", path
26
+ end
27
+ end
data/lib/noumenon/core.rb CHANGED
@@ -1,93 +1,86 @@
1
+ require 'noumenon'
1
2
  require 'sinatra'
2
- require 'liquid'
3
- require 'yaml'
4
3
 
5
- # :nodoc:
6
- module Noumenon
7
- # The base of all Noumenon sites, responsible for building the URL structure, and providing
8
- # access to the content repository.
4
+ # The core Noumenon web application, responsible for loading content from the repository,
5
+ # and then either rendering it with the appropriate template, or passing it to the specified
6
+ # application.
7
+ #
8
+ # @api public
9
+ class Noumenon::Core < Sinatra::Base
10
+ # Renders a content item within it's template and layout.
9
11
  #
10
- # By default any request will be routed to the content repository to find an item of content
11
- # using #locate_content, which will then be rendered using the template specified by the content
12
- # file.
12
+ # If the template or layout is not specified then it will be set to "default".
13
13
  #
14
- # A piece of content takes the form of a YAML file, which should provide any fields required by
15
- # template specified. If any required fields are missing then a MissingContentError will be raised.
16
- #
17
- class Core < Sinatra::Base
18
- def config
19
- Noumenon.config
20
- end
21
-
22
- # Get the path to a file in the content repository.
23
- def content_path(path)
24
- File.join(config.content_repository_path, path)
25
- end
26
-
27
- # Get the path to a file provided by the current theme.
28
- def theme_path(path)
29
- File.join(Noumenon.themes[config.theme][:path], path)
14
+ # @param [ Hash ] page The content item to render.
15
+ # @option page [ String ] :template The template to use when rendering the item.
16
+ # @option page [ String ] :layout The layout to put the template within.
17
+ # @return [ String ] The rendered page, or an error message if any templates could not be rendered.
18
+ # @api public
19
+ def render_page(page)
20
+ page[:template] ||= "default"
21
+ page[:layout] ||= "default"
22
+
23
+ begin
24
+ template = Noumenon.theme.template("#{page[:template]}.nou.html")
25
+ content = template.render(page)
26
+
27
+ wrap_with_layout(content, page)
28
+ rescue Noumenon::Template::NotFoundError => e
29
+ halt 500, "<h1>Missing Template</h1><p>The template '#{page[:template]}' does not exist within the current theme.</p>"
30
+ rescue Noumenon::Template::MissingContentError => e
31
+ halt 500, "<h1>Missing Fields</h1><p>#{e}</p>"
30
32
  end
33
+ end
34
+
35
+
36
+ # Convenience method to access the current content repository.
37
+ #
38
+ # @api public
39
+ # @return [ Noumenon::Repository ] the current content repository
40
+ def content
41
+ Noumenon.content_repository
42
+ end
43
+
44
+ get "*" do |path|
45
+ page = content.get(path)
31
46
 
32
- # Render a template, surrounding it with the theme layout if a theme has been set, and
33
- # it provides a layout.
34
- #
35
- # Any locals provided will be passed onto Liquid for use in the template.
36
- #
37
- # Valid options:
38
- #
39
- # layout: If set to a string, an attempt will be made to use that layout.
40
- # If set to false no layout will be used.
41
- # If set to true (the default) the default layout will be used.
42
- def render_template(path, locals = {}, options = {})
43
- options[:layout] = "layout" if options[:layout] == true || options[:layout].nil?
44
-
45
- template = Template.from_file(path)
46
- body = template.render(locals)
47
-
48
- if options[:layout]
49
- begin
50
- body = render_template(theme_path("templates/#{options[:layout]}.html"), { 'body' => body }, :layout => false)
51
- rescue Noumenon::Template::NotFoundError => ignore_missing_layouts
52
- end
47
+ unless page
48
+ # Search up the tree until we either hit the top, and 404, or find a mounted application.
49
+ path = path.split("/")
50
+ while path.size > 0
51
+ path.pop
52
+ page = content.get(path.join("/"))
53
+ break if page && page[:type] == "application"
53
54
  end
54
55
 
55
- body
56
+ halt 404, "<h1>Page Not Found</h1>" unless page
57
+ path = path.join("/")
56
58
  end
57
59
 
58
- # Render a piece of content from the content repository, using the template specified within
59
- # the content item.
60
- #
61
- # If no template was specified then the template "default" will be used.
62
- def render_content(path)
63
- content = File.read(content_path(path))
64
- item = YAML.load( content )
60
+ case page[:type]
61
+ when "application"
62
+ app = page[:application].constantize
65
63
 
66
- [ 200, render_template( theme_path("templates/#{item["template"]}.html"), item ) ]
64
+ # Rewrite PATH_INFO so that the child application can listen to URLs assuming it's at the root.
65
+ env["PATH_INFO"].gsub!("#{path}", "")
66
+ env["PATH_INFO"] = "/" if env["PATH_INFO"] == ""
67
+ app.new(page).call(env)
68
+ else
69
+ render_page(page)
67
70
  end
68
-
69
- # Locate a piece of content in the content repository based on the URL it is being accessed from.
70
- #
71
- # The following files will satisfy a request for "/example", in the order shown:
72
- #
73
- # 1. "repository/example.yml
74
- # 2. "repository/example/index.yml
75
- #
76
- def locate_content_for(path)
77
- if File.exists?(content_path("#{path}.yml"))
78
- return "#{path}.yml"
79
- else
80
- if File.exists?(content_path(path)) && File.exists?(content_path("#{path}/index.yml"))
81
- return "#{path}/index.yml"
71
+ end
72
+
73
+ private
74
+ def wrap_with_layout(content, page)
75
+ begin
76
+ layout = Noumenon.theme.layout("#{page[:layout]}.nou.html")
77
+ return layout.render( page.merge(content: content) )
78
+ rescue Noumenon::Template::NotFoundError => e
79
+ if page[:layout] == "default"
80
+ return content
81
+ else
82
+ halt 500, "<h1>Missing Layout</h1><p>The layout '#{page[:layout]}' does not exist within the current theme.</p>"
82
83
  end
83
84
  end
84
-
85
- nil
86
85
  end
87
-
88
- get '*' do |path|
89
- path = locate_content_for(path)
90
- path ? render_content(path) : [ 404, "Not Found" ]
91
- end
92
- end
93
86
  end
@@ -0,0 +1,102 @@
1
+ require 'noumenon/repository'
2
+ require 'yaml'
3
+ require 'fileutils'
4
+
5
+ # A content repository which uses YAML files within the filesystem for storage.
6
+ #
7
+ # @example The structure of a filesystem repository
8
+ # index.yml
9
+ # |- about
10
+ # |- index.yml
11
+ # |- team.yml
12
+ # |- company.yml
13
+ # contact.yml
14
+ #
15
+ # @example A piece of content
16
+ # template: team_member
17
+ # name: Jon Wood
18
+ # position: Head Honcho of Awesomeness
19
+ # bio: Jon's just awesome, we'll leave it at that.
20
+ #
21
+ # @api public
22
+ class Noumenon::Repository::FileSystem < Noumenon::Repository
23
+ # @param [ Hash ] options A hash of options.
24
+ # @option options [ String ] :path The path to access the repository at.
25
+ # @api public
26
+ def initialize(options = {})
27
+ unless options.key? :path
28
+ raise ArgumentError.new("You must provide a path to the content repository: Noumenon::Repository::FileSystem.new(path: '/tmp')")
29
+ end
30
+
31
+ super options
32
+ end
33
+
34
+ # Loads a piece of content from the repository.
35
+ #
36
+ # @param [ String ] path The path to load from.
37
+ # @param [ Boolean ] check_for_index Whether sub-directories of the same name should be checked for an index.yml file
38
+ # @return [ Hash, #each, nil ] The piece of content, or nil if it could not be found.
39
+ # @api public
40
+ def get(path, check_for_index = true)
41
+ file = repository_path("#{path}.yml")
42
+
43
+ if File.exist?(file)
44
+ YAML.load(File.read(file)).symbolize_keys
45
+ elsif check_for_index
46
+ return get("#{path}/index", false)
47
+ end
48
+ end
49
+
50
+ # Saves a piece of content to the repsitory.
51
+ #
52
+ # @see Noumenon::Repository#put
53
+ def put(path, content = {})
54
+ create_tree(path)
55
+
56
+ path = File.join(path, "index") if File.exist?(repository_path(path))
57
+ File.open(repository_path("#{path}.yml"), "w") { |f| f.print content.symbolize_keys.to_yaml }
58
+ end
59
+
60
+ private
61
+ # Return the on-disk path to a repository item.
62
+ def repository_path(path)
63
+ File.join(options[:path], path)
64
+ end
65
+
66
+ # Creates any neccesary directories, and moves files that conflict with newly created
67
+ # directories to index.yml
68
+ def create_tree(path)
69
+ path_on_disk = File.dirname(repository_path(path))
70
+
71
+ unless File.exists?(path_on_disk)
72
+ FileUtils.mkdir_p(path_on_disk)
73
+ create_indexes(path)
74
+ end
75
+ end
76
+
77
+ # Moves conflicting YAML files into directory/index.yml
78
+ #
79
+ # For example given
80
+ #
81
+ # /foo
82
+ # /foo.yml
83
+ #
84
+ # /foo.yml will be moved to /foo/index.yml
85
+ def create_indexes(path)
86
+ sub_path = options[:path]
87
+
88
+ path.split("/").each do |directory|
89
+ move_to_index File.join(sub_path, directory)
90
+ sub_path = File.join(sub_path, directory)
91
+ end
92
+ end
93
+
94
+ def move_to_index(path)
95
+ path_on_disk = path
96
+ yaml_file = "#{path_on_disk}.yml"
97
+
98
+ if File.exist? yaml_file
99
+ FileUtils.mv yaml_file, File.join("#{path_on_disk}/index.yml")
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,39 @@
1
+ require 'noumenon'
2
+
3
+ # The base class for content repositories. This class is predominantly present just to document the
4
+ # expected API for a repository.
5
+ #
6
+ # @api public
7
+ class Noumenon::Repository
8
+ autoload :FileSystem, 'noumenon/repository/file_system'
9
+
10
+ # Provides access to options set on initialization.
11
+ # @api public
12
+ attr_reader :options
13
+
14
+ # Create a new Repository instance.
15
+ #
16
+ # @param [ Hash, #each ] options A hash of options to use when configuring the repository.
17
+ # @api public
18
+ def initialize(options = {})
19
+ @options = options
20
+ end
21
+
22
+ # Save an item in the repository. If an item already exists then it should be overwritten.
23
+ #
24
+ # @param [ #to_s ] path The path the save the content at.
25
+ # @param [ Hash, #each ] content A hash of key/value pairs to use as the content.
26
+ # @api public
27
+ def put(path, content)
28
+ raise NotImplementedError.new("This repository type does not support updating it's contents.")
29
+ end
30
+
31
+ # Load an item from the repository. If the item does not exist then `nil` should be returned.
32
+ #
33
+ # @param [ #to_s ] path The path to laod content from
34
+ # @return [ Hash, #each, nil ] Either the hash stored at the specified path, or nil if no content was found.
35
+ # @api public
36
+ def get(path)
37
+ raise NotImplementedError.new("This repository type does not support reading it's contents.")
38
+ end
39
+ end
@@ -1,9 +1,22 @@
1
- module Noumenon
2
- module Spec
3
- class ExampleApp < Noumenon::Core
4
- get '/' do
5
- [ 200, "This was served by Noumenon::Spec::ExampleApp" ]
6
- end
1
+ require 'noumenon'
2
+
3
+ class Noumenon::Spec::ExampleApp
4
+ def initialize(options = {})
5
+ @options = { message: "This is a mounted application" }.merge(options.symbolize_keys)
6
+ end
7
+
8
+ def call(env)
9
+ case env["PATH_INFO"]
10
+ when "/"
11
+ respond 200, "<h1>#{@options[:message]}</h1>"
12
+ when "/sub"
13
+ respond 200, "<h1>This is a sub-page</h1>"
14
+ else
15
+ respond 404, "Page Not Found"
7
16
  end
8
17
  end
18
+
19
+ def respond(status, content)
20
+ [ status, { "Content-Type" => "text/html" }, StringIO.new(content) ]
21
+ end
9
22
  end
@@ -0,0 +1,66 @@
1
+ require 'fileutils'
2
+
3
+ module Noumenon
4
+ module Spec
5
+ # Methods which can be used in RSpec based tests to create and use themes that will be recreated on
6
+ # each test run.
7
+ #
8
+ # @api public
9
+ module ThemeHelpers
10
+ # The path to the current theme.
11
+ # @return [ String ]
12
+ # @api public
13
+ def theme_path
14
+ File.join(File.dirname(__FILE__), "..", "tmp", "example_theme")
15
+ end
16
+
17
+ # Create a new theme directory at #theme_path.
18
+ #
19
+ # @param [ Hash, nil ] description Either the description to write to theme.yml, or nil to prevent
20
+ # creation of the theme.yml file.
21
+ # @return [ Noumenon::Theme, nil ] If a description was provided the theme will be loaded, otherwise
22
+ # nil will be returned.
23
+ def create_theme(description = {})
24
+ FileUtils.mkdir_p theme_path
25
+ FileUtils.mkdir_p File.join(theme_path, "templates")
26
+ FileUtils.mkdir_p File.join(theme_path, "layouts")
27
+ FileUtils.mkdir_p File.join(theme_path, "assets")
28
+
29
+ if description
30
+ File.open(File.join(theme_path, "theme.yml"), "w") do |f|
31
+ f.print description.to_yaml
32
+ end
33
+
34
+ return Noumenon::Theme.load(theme_path)
35
+ end
36
+ end
37
+
38
+ # Creates a theme with the provided description, runs the block, and then deletes the theme again.
39
+ #
40
+ # Useful in RSpec around blocks:
41
+ #
42
+ # @example Using with RSpec
43
+ # describe "theme stuff" do
44
+ # include Noumenon::Spec::ThemeHelpers
45
+ #
46
+ # around do |ex|
47
+ # with_temporary_theme(name: "My Theme") do
48
+ # ex.run
49
+ # end
50
+ # end
51
+ #
52
+ # it "should have the right name" do
53
+ # theme = Noumenon::Theme.load(theme_path)
54
+ # theme.name.should == "My Theme"
55
+ # end
56
+ # end
57
+ def with_temporary_theme(description = {})
58
+ create_theme(description)
59
+
60
+ yield
61
+
62
+ FileUtils.rm_r theme_path
63
+ end
64
+ end
65
+ end
66
+ end
data/lib/noumenon/spec.rb CHANGED
@@ -1,9 +1,6 @@
1
- require 'noumenon/spec/fixtures'
2
- require 'noumenon/spec/example_app'
1
+ require 'noumenon'
3
2
 
4
- module Noumenon
5
- # Spec helpers used while testing Noumenon, and Noumenon applications.
6
- module Spec
7
- include Noumenon::Spec::Fixtures
8
- end
3
+ module Noumenon::Spec
4
+ autoload :ExampleApp, 'noumenon/spec/example_app'
5
+ autoload :ThemeHelpers, 'noumenon/spec/theme_helpers'
9
6
  end
@@ -0,0 +1,21 @@
1
+ # Adds some additional methods to String objects.
2
+ #
3
+ # @api public
4
+ module Noumenon::StringExtensions
5
+ # Attempts to convert a string into a constant.
6
+ #
7
+ # If the constant could not be found then a NameError will be raised.
8
+ #
9
+ # @example
10
+ # "Foo::Bar".constantize # => Foo::Bar
11
+ def constantize
12
+ mod = Module
13
+ split("::").each do |mod_name|
14
+ mod = mod.const_get(mod_name.to_sym)
15
+ end
16
+
17
+ mod
18
+ end
19
+ end
20
+
21
+ String.class_eval { include Noumenon::StringExtensions }