noumenon 0.0.3 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +3 -0
- data/.travis.yml +0 -4
- data/.yardopts +5 -0
- data/Gemfile +1 -3
- data/README.md +57 -80
- data/Rakefile +17 -6
- data/bin/noumenon +6 -0
- data/features/dynamic_template_rendering.feature +107 -0
- data/features/generator/site_generator.feature +25 -0
- data/features/mounted_applications.feature +30 -0
- data/features/static_template_rendering.feature +43 -0
- data/features/step_definitions/asset_steps.rb +7 -0
- data/features/step_definitions/content_steps.rb +7 -0
- data/features/step_definitions/generator_steps.rb +22 -0
- data/features/step_definitions/request_steps.rb +31 -0
- data/features/step_definitions/theme_steps.rb +19 -0
- data/features/support/env.rb +38 -0
- data/features/support/theme/theme.yml +5 -0
- data/features/theme_assets.feature +22 -0
- data/generators/repository/index.yml +3 -0
- data/generators/site/Gemfile +3 -0
- data/generators/site/config.ru +7 -0
- data/generators/theme/assets/style.css +1 -0
- data/generators/theme/layouts/default.nou.html +23 -0
- data/generators/theme/templates/default.nou.html +12 -0
- data/generators/theme/theme.yml +5 -0
- data/lib/noumenon/cli.rb +27 -0
- data/lib/noumenon/core.rb +70 -77
- data/lib/noumenon/repository/file_system.rb +102 -0
- data/lib/noumenon/repository.rb +39 -0
- data/lib/noumenon/spec/example_app.rb +19 -6
- data/lib/noumenon/spec/theme_helpers.rb +66 -0
- data/lib/noumenon/spec.rb +4 -7
- data/lib/noumenon/string_extensions.rb +21 -0
- data/lib/noumenon/template.rb +113 -72
- data/lib/noumenon/theme/assets_middleware.rb +21 -0
- data/lib/noumenon/theme.rb +106 -0
- data/lib/noumenon/version.rb +3 -1
- data/lib/noumenon.rb +68 -100
- data/noumenon.gemspec +13 -9
- data/spec/noumenon/repository/file_system_spec.rb +115 -0
- data/spec/noumenon/repository_spec.rb +40 -0
- data/spec/noumenon/template_spec.rb +9 -7
- data/spec/noumenon/theme_spec.rb +129 -0
- data/spec/noumenon_spec.rb +24 -80
- data/spec/spec_helper.rb +5 -14
- data/spec/support/file_matchers.rb +45 -0
- data/spec/support/templates/basic_template.html +1 -0
- data/spec/{fixtures/themes/example_without_layout → support}/templates/template_with_fields.html +1 -1
- metadata +143 -62
- data/lib/noumenon/configuration.rb +0 -28
- data/lib/noumenon/spec/fixtures.rb +0 -34
- data/spec/fixtures/fixture_specs/test +0 -1
- data/spec/fixtures/missing_application/mounted_app/config.yml +0 -1
- data/spec/fixtures/static_example/directory_with_index/index.yml +0 -1
- data/spec/fixtures/static_example/found.html +0 -1
- data/spec/fixtures/static_example/liquid_example.html +0 -1
- data/spec/fixtures/static_example/mounted_app/config.yml +0 -1
- data/spec/fixtures/static_example/template_with_substitutions.html +0 -1
- data/spec/fixtures/static_example/templates/basic_example.yml +0 -1
- data/spec/fixtures/static_example/templates/with_fields.yml +0 -2
- data/spec/fixtures/themes/example/assets/example.txt +0 -1
- data/spec/fixtures/themes/example/templates/layout.html +0 -3
- data/spec/fixtures/themes/example_without_layout/templates/basic_template.html +0 -1
- data/spec/fixtures/themes/example_without_layout/templates/fields.html +0 -1
- data/spec/fixtures/themes/external_theme/assets/example.txt +0 -1
- data/spec/fixtures/themes/unregistered/lib/unregistered.rb +0 -1
- data/spec/noumenon/config_spec.rb +0 -29
- data/spec/noumenon/core_spec.rb +0 -105
- data/spec/noumenon/spec/example_app_spec.rb +0 -14
- data/spec/noumenon/spec/fixtures_spec.rb +0 -41
- data/spec/noumenon/spec_spec.rb +0 -7
- 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>
|
data/lib/noumenon/cli.rb
ADDED
@@ -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
|
-
#
|
6
|
-
|
7
|
-
|
8
|
-
|
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
|
-
#
|
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
|
-
#
|
15
|
-
#
|
16
|
-
#
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
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
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
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
|
-
|
56
|
+
halt 404, "<h1>Page Not Found</h1>" unless page
|
57
|
+
path = path.join("/")
|
56
58
|
end
|
57
59
|
|
58
|
-
|
59
|
-
|
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
|
-
|
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
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
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
|
-
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
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
|
2
|
-
require 'noumenon/spec/example_app'
|
1
|
+
require 'noumenon'
|
3
2
|
|
4
|
-
module Noumenon
|
5
|
-
|
6
|
-
|
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 }
|