noumenon 0.0.3 → 0.1.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 (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 }