silly 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/Gemfile ADDED
@@ -0,0 +1,8 @@
1
+ source "https://rubygems.org"
2
+ gemspec
3
+
4
+ group :test do
5
+ gem 'cucumber'
6
+ gem 'capybara'
7
+ gem 'rspec'
8
+ end
@@ -0,0 +1,200 @@
1
+ [![Build Status](https://travis-ci.org/ruhoh/silly.png?branch=master)](https://travis-ci.org/ruhoh/silly)
2
+
3
+ Silly is a filesystem based Object Document Mapper.
4
+ Use it to query a directory like you would a database -- useful for static websites.
5
+
6
+ ## Installation
7
+
8
+ Silly is in alpha which means the API is unstable and still under development. Silly is/will-be used to power [ruhoh](http://ruhoh.com) 3.0. You can contribute and help stablize Silly by playing with it and providing feedback!
9
+
10
+ Install and run the development (head) version:
11
+
12
+ $ git@github.com:ruhoh/silly.git
13
+
14
+ Take note of where you downloaded the gem.
15
+
16
+ Navigate to a directory you want to query.
17
+
18
+ Create a file named `Gemfile` with the contents:
19
+
20
+ gem 'silly', :path => '/Users/jade/Dropbox/gems/silly'
21
+
22
+ Make sure to replace the path with where you downloaded the gem.
23
+
24
+ Install the bundle:
25
+
26
+ $ bundle install
27
+
28
+ ## Usage
29
+
30
+ We can test the query API by loading an irb session loading Silly:
31
+
32
+ Load irb:
33
+
34
+ $ bundle exec irb
35
+
36
+ Load and instantiate silly relative to the current directory:
37
+
38
+ > require 'silly'
39
+ > query = Silly::Query.new
40
+ > query.append_path(Dir.pwd)
41
+
42
+
43
+ ## Data
44
+
45
+ ### Cascade
46
+
47
+ Append an arbitrary number of paths to enable a logical cascade where files overload one another of the same name.
48
+
49
+ ```ruby
50
+ query = Silly::Query.new
51
+ query.append_path(Dir.pwd)
52
+ query.append_path(File.join(Dir.pwd, "theme-folder"))
53
+ ```
54
+
55
+ ### Data Files
56
+
57
+ Data files are automatically parsed and merged down the cascade:
58
+
59
+ Given:
60
+ ./data.yml
61
+ ./theme-folder/data.json
62
+
63
+ query = Silly::Query.new
64
+ query.append_path(Dir.pwd)
65
+ query.append_path("theme-folder")
66
+
67
+ query.where("$shortname" => "data").first
68
+
69
+ ### File metadata
70
+
71
+ Files have inherit metadata such as path, filename, extension etc.
72
+ Files can also define arbitrary in-file "top metadata" or FrontMatter made popular by static site generators like Jekyll.
73
+
74
+ **page.html**
75
+
76
+ ---
77
+ title: "A custom title"
78
+ date: "2013/12/12"
79
+ tags:
80
+ - opinion
81
+ - tech
82
+ ---
83
+
84
+ ... content ...
85
+
86
+
87
+ ## Query API
88
+
89
+ All queries return a [Silly::Collection](https://github.com/ruhoh/silly/blob/master/lib/silly/collection.rb) of [Silly::Item](https://github.com/ruhoh/silly/blob/master/lib/silly/item.rb)s.
90
+
91
+ `Silly::Item` represents a file.
92
+
93
+ `@item.data` lazily generates any metadata in the file.
94
+
95
+ `@item.content` lazily generates the raw content of the file.
96
+
97
+
98
+ ### Path
99
+
100
+ **Return all files in the base directory (unnested)**
101
+
102
+ ```ruby
103
+ query = Silly::Query.new
104
+ query.append_path(Dir.pwd)
105
+
106
+ query.to_a
107
+ ```
108
+
109
+ **Return files in the base directory and all sub-directories.**
110
+
111
+ ```ruby
112
+ query = Silly::Query.new
113
+ query.append_path(Dir.pwd)
114
+
115
+ query.path_all("").to_a
116
+ ```
117
+
118
+ ### Filter
119
+
120
+ **Return files with specific extension**
121
+
122
+ ```ruby
123
+ query = Silly::Query.new
124
+ query.append_path(Dir.pwd)
125
+
126
+ query.where("$ext" => ".md").to_a
127
+ ```
128
+
129
+ **Return files contained in a given directory (can be any nesting level)**
130
+
131
+ ```ruby
132
+ query = Silly::Query.new
133
+ query.append_path(Dir.pwd)
134
+
135
+ query.where("$directories" => "drafts").to_a
136
+ ```
137
+
138
+ **Return files where an attribute is a given value**
139
+
140
+ ```ruby
141
+ query = Silly::Query.new
142
+ query.append_path(Dir.pwd)
143
+
144
+ query.where("size" => "med").to_a
145
+ ```
146
+
147
+ **Return files where an attribute is not a given value**
148
+
149
+ ```ruby
150
+ query = Silly::Query.new
151
+ query.append_path(Dir.pwd)
152
+
153
+ query.where("size" => {"$ne" => "med"}).to_a
154
+ ```
155
+
156
+ **Return files where an attribute exists**
157
+
158
+ ```ruby
159
+ query = Silly::Query.new
160
+ query.append_path(Dir.pwd)
161
+
162
+ query.where("size" => {"$exists" => true}).to_a
163
+ ```
164
+
165
+
166
+ ### Sort
167
+
168
+ **Return files sorted by a given attribute**
169
+
170
+ ```ruby
171
+ query = Silly::Query.new
172
+ query.append_path(Dir.pwd)
173
+
174
+ query.sort([:date, :desc]).to_a
175
+ ```
176
+
177
+
178
+ ### Chaining
179
+
180
+ ```ruby
181
+ query = Silly::Query.new
182
+ query.append_path(Dir.pwd)
183
+
184
+ query
185
+ .path("posts")
186
+ .where("$directories" => { "drafts" => { "$ne" => "drafts" } })
187
+ .sort([:date, :desc])
188
+ .to_a
189
+ ```
190
+
191
+ ## Why
192
+
193
+ Silly has no dependencies so it's a great engine to build custom static site generators on top of.
194
+
195
+ I really like the idea of a static website generator but it's really hard to get people to adopt your philosphy of how thing's should work so rather than do that why not empower anyone to build a static generator however they want! That's the idea anyway and this is a very early release so we'll see.
196
+ Also I am really inspired by projects like [Tire](https://github.com/karmi/retire) and [Mongoid](https://github.com/mongoid/mongoid) of which I've taken heavy inspiration from. Silly is a way to level up my gem skills B).
197
+
198
+ ## License
199
+
200
+ [MIT](http://www.opensource.org/licenses/mit-license.html)
@@ -0,0 +1,23 @@
1
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), *%w[lib]))
2
+ require 'rubygems'
3
+ require 'rake'
4
+ require 'bundler'
5
+ require 'ruhoh/version'
6
+
7
+ name = Dir['*.gemspec'].first.split('.').first
8
+ gemspec_file = "#{name}.gemspec"
9
+ gem_file = "#{name}-#{Silly::VERSION}.gem"
10
+
11
+ task :release => :build do
12
+ sh "git commit --allow-empty -m 'Release #{Silly::VERSION}'"
13
+ sh "git tag v#{Silly::VERSION}"
14
+ sh "git push origin master --tags"
15
+ sh "git push origin v#{Silly::VERSION}"
16
+ sh "gem push pkg/#{name}-#{Silly::VERSION}.gem"
17
+ end
18
+
19
+ task :build do
20
+ sh "mkdir -p pkg"
21
+ sh "gem build #{gemspec_file}"
22
+ sh "mv #{gem_file} pkg"
23
+ end
File without changes
@@ -0,0 +1,82 @@
1
+ Feature: Data File
2
+ I want to natively access the data in data files so I can use the data in my content
3
+
4
+ Scenario: Query data file
5
+ Given the file "person.yml" with body:
6
+ """
7
+ ---
8
+ name: "jade"
9
+ address:
10
+ city: "alhambra"
11
+ fruits:
12
+ - mango
13
+ - kiwi
14
+ """
15
+ When I query the path ""
16
+ Then this query's first result should have the data:
17
+ """
18
+ {
19
+ "name": "jade",
20
+ "address": {
21
+ "city": "alhambra"
22
+ },
23
+ "fruits": [
24
+ "mango",
25
+ "kiwi"
26
+ ]
27
+ }
28
+ """
29
+
30
+ Scenario: Query a merged data file
31
+ Given the file "person.yml" with body:
32
+ """
33
+ ---
34
+ name: "jade"
35
+ address:
36
+ city: "alhambra"
37
+ fruits:
38
+ - mango
39
+ - kiwi
40
+ """
41
+ And the file "cascade/person.yml" with body:
42
+ """
43
+ ---
44
+ name: "Bob"
45
+ greeting: "Hai!"
46
+ """
47
+ And I append the path "cascade" to the query
48
+ When I query the path ""
49
+ Then this query's first result should have the data:
50
+ """
51
+ {
52
+ "name": "Bob",
53
+ "greeting" : "Hai!",
54
+ "address": {
55
+ "city": "alhambra"
56
+ },
57
+ "fruits": [
58
+ "mango",
59
+ "kiwi"
60
+ ]
61
+ }
62
+ """
63
+
64
+ Scenario: Query a merged data file with different formats
65
+ Given the file "person.json" with body:
66
+ """
67
+ { "name" : "Bob" }
68
+ """
69
+ And the file "cascade/person.yml" with body:
70
+ """
71
+ ---
72
+ greeting: "Hai!"
73
+ """
74
+ And I append the path "cascade" to the query
75
+ When I query the path ""
76
+ Then this query's first result should have the data:
77
+ """
78
+ {
79
+ "name": "Bob",
80
+ "greeting" : "Hai!"
81
+ }
82
+ """
@@ -0,0 +1,38 @@
1
+ Feature: Query Path
2
+ I want to scope a query to a path so I can better arrange my content for optimized user experience.
3
+
4
+ Scenario: Query with empty path (base path)
5
+ Given some files with values:
6
+ | file |
7
+ | apple.html |
8
+ | banana.html |
9
+ | cranberry.html |
10
+ When I query the path ""
11
+ Then this query returns the ordered results "apple.html, banana.html, cranberry.html"
12
+
13
+ Scenario: Query with path
14
+ Given some files with values:
15
+ | file |
16
+ | food/apple.html |
17
+ | food/banana.html |
18
+ | food/cranberry.html |
19
+ When I query the path "food"
20
+ Then this query returns the ordered results "food/apple.html, food/banana.html, food/cranberry.html"
21
+
22
+ Scenario: Query with path and nested files
23
+ Given some files with values:
24
+ | file |
25
+ | food/apple.html |
26
+ | food/cool/banana.html |
27
+ | food/cool/cranberry.html |
28
+ When I query the path "food"
29
+ Then this query returns the ordered results "food/apple.html"
30
+
31
+ Scenario: Query with path_all and nested files
32
+ Given some files with values:
33
+ | file |
34
+ | food/apple.html |
35
+ | food/cool/banana.html |
36
+ | food/cool/cranberry.html |
37
+ When I query the path_all "food"
38
+ Then this query returns the ordered results "food/apple.html, food/cool/banana.html, food/cool/cranberry.html"
@@ -0,0 +1,42 @@
1
+ Feature: Query Sort
2
+ I want to sort a query so I can better arrange my content for optimized user experience.
3
+
4
+ Scenario: Query with sort
5
+ Given some files with values:
6
+ | file |
7
+ | food/apple.html |
8
+ | food/banana.html |
9
+ | food/cranberry.html |
10
+ When I query the path "food"
11
+ And I sort the query by "id" "desc"
12
+ Then this query returns the ordered results "food/cranberry.html, food/banana.html, food/apple.html"
13
+
14
+ Scenario: Query with sort by metadata attribute
15
+ Given some files with values:
16
+ | file | order |
17
+ | food/apple.html | 2 |
18
+ | food/banana.html | 3 |
19
+ | food/cranberry.html | 1 |
20
+ When I query the path "food"
21
+ And I sort the query by "order" "asc"
22
+ Then this query returns the ordered results "food/cranberry.html, food/apple.html, food/banana.html"
23
+
24
+ Scenario: Query with sort by date attribute ascending
25
+ Given some files with values:
26
+ | file | date |
27
+ | essays/hello.md | 2013-12-01 |
28
+ | essays/zebra.md | 2013-12-10 |
29
+ | essays/apple.md | 2013-12-25 |
30
+ When I query the path "essays"
31
+ And I sort the query by "date" "asc"
32
+ Then this query returns the ordered results "essays/hello.md, essays/zebra.md, essays/apple.md"
33
+
34
+ Scenario: Query with sort by date attribute descending
35
+ Given some files with values:
36
+ | file | date |
37
+ | essays/hello.md | 2013-12-01 |
38
+ | essays/zebra.md | 2013-12-10 |
39
+ | essays/apple.md | 2013-12-25 |
40
+ When I query the path "essays"
41
+ And I sort the query by "date" "desc"
42
+ Then this query returns the ordered results "essays/apple.md, essays/zebra.md, essays/hello.md"
@@ -0,0 +1,57 @@
1
+ Transform(/^(should|should NOT)$/) do |matcher|
2
+ matcher.downcase.gsub(' ', '_')
3
+ end
4
+
5
+ When(/^I query the path "(.*?)"$/) do |term|
6
+ query.path(term)
7
+ end
8
+
9
+ When(/^I query the path_all "(.*?)"$/) do |term|
10
+ query.path_all(term)
11
+ end
12
+
13
+ When(/^I sort the query by "(.*?)" "(.*?)"$/) do |attribute, order|
14
+ query.sort([attribute, order])
15
+ end
16
+
17
+ When(/^I filter the query with:$/) do |json|
18
+ query.where(JSON.parse(json))
19
+ end
20
+
21
+ When(/^this query returns the ordered results "(.*?)"$/) do |results|
22
+ results = results.split(/[\s,]+/).map(&:strip)
23
+ query.map{ |a| a["id"] }.should == results
24
+ end
25
+
26
+ When(/^this query's first result should have the data:$/) do |json|
27
+ data = JSON.parse(json)
28
+ result = query.first
29
+ result.data.should == data
30
+ end
31
+
32
+ When(/^I append the path "(.*?)" to the query$/) do |path|
33
+ query.append_path(File.join(SampleSitePath, path))
34
+ end
35
+
36
+ Given(/^a config file with value:$/) do |string|
37
+ make_config(JSON.parse(string))
38
+ end
39
+
40
+ Given(/^a config file with values:$/) do |table|
41
+ data = table.rows_hash
42
+ data.each{ |key, value| data[key] = JSON.parse(value) }
43
+ make_config(data)
44
+ end
45
+
46
+ Given(/^the file "(.*)" with body:$/) do |file, body|
47
+ make_file(path: file, body: body)
48
+ end
49
+
50
+ Given(/^some files with values:$/) do |table|
51
+ table.hashes.each do |row|
52
+ file = row['file'] ; row.delete('file')
53
+ body = row['body'] ; row.delete('body')
54
+
55
+ make_file(path: file, data: row, body: body)
56
+ end
57
+ end
@@ -0,0 +1,9 @@
1
+ require 'fileutils'
2
+ require 'rspec/expectations'
3
+ #require 'capybara/cucumber'
4
+ World(RSpec::Matchers)
5
+
6
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
7
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
8
+
9
+ require 'silly'
@@ -0,0 +1,59 @@
1
+ SampleSitePath = File.expand_path(File.join(File.dirname(__FILE__), '..', '..', '__tmp'))
2
+
3
+ def query
4
+ @query ||= new_query
5
+ end
6
+
7
+ def new_query
8
+ query = Silly::Query.new
9
+ query.append_path(SampleSitePath)
10
+ query
11
+ end
12
+
13
+ def make_config(data)
14
+ path = File.join(SampleSitePath, "config.yml")
15
+ File.open(path, "w+") { |file|
16
+ file.puts data.to_yaml
17
+ }
18
+ end
19
+
20
+ def make_file(opts)
21
+ path = File.join(SampleSitePath, opts[:path])
22
+ FileUtils.mkdir_p(File.dirname(path))
23
+
24
+ data = opts[:data] || {}
25
+ if data['categories']
26
+ data['categories'] = data['categories'].to_s.split(',').map(&:strip)
27
+ end
28
+ if data['tags']
29
+ data['tags'] = data['tags'].to_s.split(',').map(&:strip)
30
+ puts "tags #{data['tags']}"
31
+ end
32
+ data.delete('layout') if data['layout'].to_s.strip.empty?
33
+
34
+ metadata = data.empty? ? '' : data.to_yaml.to_s + "\n---\n"
35
+
36
+ File.open(path, "w+") { |file|
37
+ if metadata.empty?
38
+ file.puts <<-TEXT
39
+ #{ opts[:body] }
40
+ TEXT
41
+ else
42
+ file.puts <<-TEXT
43
+ #{ metadata }
44
+
45
+ #{ opts[:body] }
46
+ TEXT
47
+ end
48
+ }
49
+ end
50
+
51
+ Before do
52
+ remove_instance_variable(:@query) if (instance_variable_defined?(:@query))
53
+ FileUtils.remove_dir(SampleSitePath,1) if Dir.exists? SampleSitePath
54
+ Dir.mkdir SampleSitePath
55
+ end
56
+
57
+ After do
58
+ FileUtils.remove_dir(SampleSitePath,1) if Dir.exists? SampleSitePath
59
+ end
@@ -0,0 +1,56 @@
1
+ Feature: Query filter
2
+ I want to filter a query so I can better arrange my content for optimized user experience.
3
+
4
+ Scenario: Query with filter by metadata attribute
5
+ Given some files with values:
6
+ | file | type |
7
+ | food/apple.html | fruit |
8
+ | food/banana.html | fruit |
9
+ | food/cranberry.html | fruit |
10
+ | food/peanuts.html | nuts |
11
+ When I query the path "food"
12
+ And I filter the query with:
13
+ """
14
+ {"type" : "nuts"}
15
+ """
16
+ Then this query returns the ordered results "food/peanuts.html"
17
+
18
+ Scenario: Query with filter by 2 different metadata attributes
19
+ Given some files with values:
20
+ | file | type | size |
21
+ | food/apple.html | fruit | small |
22
+ | food/banana.html | fruit | small |
23
+ | food/cranberry.html | fruit | small |
24
+ | food/peanuts.html | nuts | small |
25
+ | food/walnuts.html | nuts | med |
26
+ When I query the path "food"
27
+ And I filter the query with:
28
+ """
29
+ { "type" : "nuts" }
30
+ """
31
+ And I filter the query with:
32
+ """
33
+ { "size" : "med" }
34
+ """
35
+ Then this query returns the ordered results "food/walnuts.html"
36
+
37
+ Scenario: Query with filter by 2 of the same metadata attributes
38
+ Given some files with values:
39
+ | file | type | size |
40
+ | food/cranberry.html | fruit | med |
41
+ | food/peanuts.html | nuts | small |
42
+ | food/walnuts.html | nuts | med |
43
+ When I query the path "food"
44
+ And I filter the query with:
45
+ """
46
+ { "size" : "med" }
47
+ """
48
+ And I filter the query with:
49
+ """
50
+ { "type" : "nuts" }
51
+ """
52
+ And I filter the query with:
53
+ """
54
+ { "type" : {"$exists" : true } }
55
+ """
56
+ Then this query returns the ordered results "food/walnuts.html"
@@ -0,0 +1,72 @@
1
+ Feature: Query filter by file attributes
2
+ I want to filter a query so I can better arrange my content for optimized user experience.
3
+
4
+ Scenario: Query with filter by extension
5
+ Given some files with values:
6
+ | file |
7
+ | food/apple.html |
8
+ | food/banana.md |
9
+ | food/cranberry.html |
10
+ | food/peanuts.md |
11
+ When I query the path "food"
12
+ And I filter the query with:
13
+ """
14
+ { "$ext" : ".md" }
15
+ """
16
+ Then this query returns the ordered results "food/banana.md, food/peanuts.md"
17
+
18
+ Scenario: Query with filter by filename
19
+ Given some files with values:
20
+ | file |
21
+ | food/apple.html |
22
+ | food/banana.md |
23
+ | food/cranberry.html |
24
+ | food/peanuts.md |
25
+ When I query the path "food"
26
+ And I filter the query with:
27
+ """
28
+ { "$filename" : "food/peanuts" }
29
+ """
30
+ Then this query returns the ordered results "food/peanuts.md"
31
+
32
+ Scenario: Query with filter by filename nested
33
+ Given some files with values:
34
+ | file |
35
+ | food/fruit/apple.html |
36
+ | food/fruit/banana.md |
37
+ | food/banana.html |
38
+ | food/peanuts.md |
39
+ When I query the path_all "food"
40
+ And I filter the query with:
41
+ """
42
+ { "$filename" : "food/fruit/banana" }
43
+ """
44
+ Then this query returns the ordered results "food/fruit/banana.md"
45
+
46
+ Scenario: Query with filter by directories
47
+ Given some files with values:
48
+ | file |
49
+ | food/fruit/apple.html |
50
+ | food/fruit/banana.md |
51
+ | food/cranberry.html |
52
+ | food/peanuts.md |
53
+ When I query the path_all "food"
54
+ And I filter the query with:
55
+ """
56
+ { "$directories" : "fruit" }
57
+ """
58
+ Then this query returns the ordered results "food/fruit/apple.html, food/fruit/banana.md"
59
+
60
+ Scenario: Query with filter by shortname
61
+ Given some files with values:
62
+ | file |
63
+ | food/fruit/apple.html |
64
+ | food/fruit/banana.md |
65
+ | food/banana.html |
66
+ | food/peanuts.md |
67
+ When I query the path_all "food"
68
+ And I filter the query with:
69
+ """
70
+ { "$shortname" : "banana" }
71
+ """
72
+ Then this query returns the ordered results "food/banana.html food/fruit/banana.md"
@@ -0,0 +1,174 @@
1
+ # encoding: UTF-8
2
+ Encoding.default_internal = 'UTF-8'
3
+
4
+ require 'json'
5
+ require 'time'
6
+ require 'fileutils'
7
+ require 'delegate'
8
+ require 'observer'
9
+ require 'set'
10
+
11
+ FileUtils.cd(path = File.join(File.dirname(__FILE__), 'silly')) do
12
+ Dir[File.join('**', '*.rb')].each { |f| require File.join(path, f) }
13
+ end
14
+
15
+ module Silly
16
+ FileSeparator = File::ALT_SEPARATOR ?
17
+ %r{#{ File::SEPARATOR }|#{ File::ALT_SEPARATOR }} :
18
+ File::SEPARATOR
19
+
20
+ class Query
21
+ include Enumerable
22
+ attr_accessor :paths
23
+
24
+ def initialize
25
+ @criteria = {
26
+ "path" => nil,
27
+ "sort" => ["id", "asc"],
28
+ "where" => [],
29
+ }
30
+ @paths = Set.new
31
+ end
32
+
33
+ def append_path(path)
34
+ @paths << path
35
+ end
36
+
37
+ BlackList = ["", "~", "/"]
38
+ def path(path)
39
+ @criteria["path"] = BlackList.include?(path.to_s) ? "*" : File.join(path, "*")
40
+ self
41
+ end
42
+
43
+ def path_all(path)
44
+ @criteria["path"] = File.join(path, "**", "**")
45
+ self
46
+ end
47
+
48
+ def sort(conditions=[])
49
+ @criteria["sort"] = conditions
50
+ self
51
+ end
52
+
53
+ def where(conditions)
54
+ @criteria["where"] << conditions
55
+ self
56
+ end
57
+
58
+ def each
59
+ block_given? ?
60
+ execute.each { |a| yield(a) } :
61
+ execute
62
+ end
63
+
64
+ def execute
65
+ @criteria["path"] ||= "*"
66
+ puts "EXECUTE:\n #{ @criteria.pretty_inspect }"
67
+ data = files(@criteria["path"])
68
+
69
+ unless @criteria["where"].empty?
70
+ data = data.keep_if { |id, pointer| filter_function(pointer) }
71
+ end
72
+
73
+ data = data.values.sort { |a,b| sorting_function(a,b) }
74
+
75
+ Silly::Collection.new(data)
76
+ end
77
+
78
+ def list
79
+ results = Set.new
80
+
81
+ paths.each do |path|
82
+ FileUtils.cd(path) {
83
+ results += Dir['*'].select { |x| File.directory?(x) }
84
+ }
85
+ end
86
+
87
+ results.to_a
88
+ end
89
+
90
+ def inspect
91
+ "#{ self.class.name }\n criteria:\n #{ @criteria.inspect }"
92
+ end
93
+
94
+ private
95
+
96
+ # Collect all files for the given @collection["path"].
97
+ # Each item can have 3 file references, one per each cascade level.
98
+ # The file hashes are collected in order so they will overwrite eachother.
99
+ # but references to all found items on the cascade are recorded.
100
+ # @return[Hash] dictionary of Items.
101
+ def files(glob)
102
+ dict = {}
103
+
104
+ paths.each do |path|
105
+ FileUtils.cd(path) {
106
+ Dir[glob].each { |id|
107
+ next unless File.exist?(id) && FileTest.file?(id)
108
+
109
+ filename = id.gsub(Regexp.new("#{ File.extname(id) }$"), '')
110
+
111
+ if dict[filename]
112
+ dict[filename]["cascade"] << File.realpath(id)
113
+ else
114
+ dict[filename] = Silly::Item.new({
115
+ "id" => id,
116
+ "cascade" => [File.realpath(id)]
117
+ })
118
+ end
119
+ }
120
+ }
121
+ end
122
+
123
+ dict
124
+ end
125
+
126
+ FileAttributes = %{ id filename shortname directories ext }
127
+
128
+ def filter_function(item)
129
+ @criteria["where"].each do |condition|
130
+ condition.each do |attribute_name, value|
131
+ attribute_name = attribute_name.to_s
132
+ attribute = attribute_name[0] == "$" ?
133
+ item.__send__(attribute_name[1, attribute_name.size]) :
134
+ item.data[attribute_name]
135
+
136
+ valid = Silly::QueryOperators.execute(attribute, value)
137
+
138
+ return false unless valid
139
+ end
140
+ end
141
+ end
142
+
143
+ def sorting_function(a, b)
144
+ attribute = @criteria["sort"][0].to_s
145
+ direction = @criteria["sort"][1].to_s
146
+
147
+ # Optmization to omit parsing internal metadata when unecessary.
148
+ if FileAttributes.include?(attribute)
149
+ this_data = a.__send__(attribute)
150
+ other_data = b.__send__(attribute)
151
+ else
152
+ this_data = a.data[attribute]
153
+ other_data = b.data[attribute]
154
+ end
155
+
156
+ if attribute == "date"
157
+ if this_data.nil? || other_data.nil?
158
+ raise(
159
+ "ArgumentError:" +
160
+ " The query is sorting on 'date'" +
161
+ " but '#{ this_data['id'] }' or '#{ other_data['id'] }' has no parseable date in its metadata." +
162
+ " Add date: 'YYYY-MM-DD' to its metadata."
163
+ )
164
+ end
165
+ end
166
+
167
+ if direction == "asc"
168
+ this_data <=> other_data
169
+ else
170
+ other_data <=> this_data
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,10 @@
1
+ module Silly
2
+ class BaseModel < SimpleDelegator
3
+ def process
4
+ {
5
+ "data" => {},
6
+ "content" => File.open(realpath, 'r:UTF-8') { |f| f.read }
7
+ }
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,9 @@
1
+ module Silly
2
+ class Collection < SimpleDelegator
3
+ attr_accessor :collection_name
4
+
5
+ def initialize(data)
6
+ super(data)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,15 @@
1
+ module Silly
2
+ class DataModel < SimpleDelegator
3
+ def process
4
+ data = {}
5
+ cascade.each do |path|
6
+ data = Silly::Utils.deep_merge(data, (Silly::Parse.data_file(path) || {}))
7
+ end
8
+
9
+ {
10
+ "data" => data,
11
+ "content" => Silly::Parse.page_file(realpath)
12
+ }
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,73 @@
1
+ module Silly
2
+ class Item
3
+ include Observable
4
+ attr_reader :pointer
5
+ attr_accessor :content, :collection
6
+
7
+ def initialize(hash)
8
+ @pointer = hash
9
+ end
10
+
11
+ def [](key)
12
+ respond_to?(key) ? __send__(key) : nil
13
+ end
14
+
15
+ def id
16
+ @pointer["id"]
17
+ end
18
+
19
+ def realpath
20
+ @pointer["cascade"].last
21
+ end
22
+
23
+ def cascade
24
+ @pointer["cascade"]
25
+ end
26
+
27
+ def filename
28
+ @filename ||= id.gsub(Regexp.new("#{ ext }$"), '')
29
+ end
30
+
31
+ def shortname
32
+ File.basename(id, ext)
33
+ end
34
+
35
+ def directories
36
+ File.dirname(id).split(Silly::FileSeparator)
37
+ end
38
+
39
+ def model
40
+ %w{ .json .yaml .yml }.include?(ext) ?
41
+ "data" :
42
+ "page"
43
+ end
44
+
45
+ def ext
46
+ File.extname(id)
47
+ end
48
+
49
+ # @returns[Hash Object] Top page metadata
50
+ def data
51
+ @data ||= (_model.process["data"] || {})
52
+ end
53
+
54
+ # @returns[String] Raw page content
55
+ def content
56
+ @content ||= (_model.process["content"] || "")
57
+ end
58
+
59
+ private
60
+
61
+ def _model
62
+ return @_model if @_model
63
+
64
+ klass = if model == "data"
65
+ Silly::DataModel
66
+ else
67
+ Silly::PageModel
68
+ end
69
+
70
+ @_model = klass.new(self)
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,79 @@
1
+ module Silly
2
+ class PageModel < SimpleDelegator
3
+ DateMatcher = /^(.+\/)*(\d+-\d+-\d+)-(.*)(\.[^.]+)$/
4
+ Matcher = /^(.+\/)*(.*)(\.[^.]+)$/
5
+
6
+ # Process this file. See #parse_page_file
7
+ # @return[Hash] the processed data from the file.
8
+ # ex:
9
+ # { "content" => "..", "data" => { "key" => "value" } }
10
+ def process
11
+ return {} unless file?
12
+
13
+ parsed_page = Silly::Parse.page_file(realpath)
14
+ data = parsed_page['data']
15
+
16
+ filename_data = parse_page_filename(id)
17
+
18
+ data['pointer'] = pointer.dup
19
+ data['id'] = id
20
+
21
+ data['title'] = data['title'] || filename_data['title']
22
+ data['date'] ||= filename_data['date']
23
+
24
+ # Parse and store date as an object
25
+ begin
26
+ data['date'] = Time.parse(data['date']) unless data['date'].nil? || data['date'].is_a?(Time)
27
+ rescue
28
+ raise(
29
+ "ArgumentError: The date '#{data['date']}' specified in '#{ id }' is unparsable."
30
+ )
31
+ data['date'] = nil
32
+ end
33
+
34
+ parsed_page['data'] = data
35
+
36
+ parsed_page
37
+ end
38
+
39
+ private
40
+
41
+ # Is the item backed by a physical file in the filesystem?
42
+ # @return[Boolean]
43
+ def file?
44
+ !!realpath
45
+ end
46
+
47
+ def parse_page_filename(filename)
48
+ data = *filename.match(DateMatcher)
49
+ data = *filename.match(Matcher) if data.empty?
50
+ return {} if data.empty?
51
+
52
+ if filename =~ DateMatcher
53
+ {
54
+ "path" => data[1],
55
+ "date" => data[2],
56
+ "slug" => data[3],
57
+ "title" => to_title(data[3]),
58
+ "extension" => data[4]
59
+ }
60
+ else
61
+ {
62
+ "path" => data[1],
63
+ "slug" => data[2],
64
+ "title" => to_title(data[2]),
65
+ "extension" => data[3]
66
+ }
67
+ end
68
+ end
69
+
70
+ # my-post-title ===> My Post Title
71
+ def to_title(file_slug)
72
+ if file_slug == 'index' && !id.index('/').nil?
73
+ file_slug = id.split('/')[-2]
74
+ end
75
+
76
+ file_slug
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,88 @@
1
+ module Silly
2
+ module Parse
3
+ TopYAMLregex = /^(---\s*\n.*?\n?)^(---\s*$\n?)/m
4
+ TopJSONregex = /^({\s*\n.*?\n?)^(}\s*$\n?)/m
5
+
6
+ # Primary method to parse the file as a page-like object.
7
+ # File API is currently defines:
8
+ # 1. Top meta-data
9
+ # 2. Page Body
10
+ #
11
+ # @returns[Hash Object] processed top meta-data, raw (unconverted) content body
12
+ def self.page_file(filepath)
13
+ result = {}
14
+ front_matter = nil
15
+ format = nil
16
+ page = File.open(filepath, 'r:UTF-8') { |f| f.read }
17
+ first_line = page.lines.first.to_s
18
+
19
+ begin
20
+ if (first_line.strip == '---')
21
+ front_matter = page.match(TopYAMLregex)
22
+ format = 'yaml'
23
+ elsif (first_line.strip == '{')
24
+ front_matter = page.match(TopJSONregex)
25
+ format = 'json'
26
+ end
27
+ rescue => e
28
+ raise "Error trying to read meta-data from #{ filepath }.
29
+ It's probably a non text-based file like an image.
30
+ Please remove it or omit it from your query. Error details: #{ e }"
31
+ end
32
+
33
+ if format == 'yaml'
34
+ data = yaml_for_pages(front_matter, filepath)
35
+ result["content"] = page.gsub(TopYAMLregex, '')
36
+ else
37
+ data = json_for_pages(front_matter, filepath)
38
+ result["content"] = page.gsub(TopJSONregex, '')
39
+ end
40
+
41
+ result["data"] = data
42
+ result
43
+ end
44
+
45
+ def self.data_file(*args)
46
+ filepath = File.__send__(:join, args)
47
+ if File.extname(filepath).to_s.empty?
48
+ path = nil
49
+ ["#{ filepath }.json", "#{ filepath }.yml", "#{ filepath }.yaml"].each do |result|
50
+ filepath = path = result and break if File.exist?(result)
51
+ end
52
+
53
+ return nil unless path
54
+ end
55
+
56
+ file = File.open(filepath, 'r:UTF-8') { |f| f.read }
57
+
58
+ File.extname(filepath) == ".json" ? json(file) : yaml(file)
59
+ end
60
+
61
+ def self.yaml(file)
62
+ YAML.load(file) || {}
63
+ rescue Psych::SyntaxError => e
64
+ raise("ERROR in #{filepath}: #{e.message}")
65
+ nil
66
+ end
67
+
68
+ def self.json(file)
69
+ JSON.load(file) || {}
70
+ end
71
+
72
+ def self.yaml_for_pages(front_matter, filepath)
73
+ return {} unless front_matter
74
+ YAML.load(front_matter[0].gsub(/---\n/, "")) || {}
75
+ rescue Psych::SyntaxError => e
76
+ raise("Psych::SyntaxError while parsing top YAML Metadata in #{ filepath }\n" +
77
+ "#{ e.message }\n" +
78
+ "Try validating the YAML metadata using http://yamllint.com"
79
+ )
80
+ nil
81
+ end
82
+
83
+ def self.json_for_pages(front_matter, filepath)
84
+ return {} unless front_matter
85
+ JSON.load(front_matter[0]) || {}
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,47 @@
1
+ module Silly
2
+ module QueryOperators
3
+ def self.execute(attribute, value)
4
+ if value.is_a?(Hash)
5
+ type, value = value.to_a.first
6
+ else
7
+ type = "$equals"
8
+ value = value.is_a?(Symbol) ? value.to_s : value
9
+ end
10
+
11
+ command = type[1, type.size]
12
+
13
+ __send__(command, attribute, value)
14
+ end
15
+
16
+ def self.exists(attribute, value)
17
+ !!attribute == !!value
18
+ end
19
+
20
+ def self.equals(attribute, value)
21
+ case attribute
22
+ when Array
23
+ attribute.include?(value)
24
+ else
25
+ attribute == value
26
+ end
27
+ end
28
+
29
+ def self.ne(attribute, value)
30
+ case attribute
31
+ when Array
32
+ !attribute.include?(value)
33
+ else
34
+ attribute != value
35
+ end
36
+ end
37
+
38
+ def self.exclude(attribute, value)
39
+ case attribute
40
+ when Array
41
+ attribute.each{ |a| return false if (a =~ value) }
42
+ else
43
+ !(attribute =~ value)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,22 @@
1
+ module Silly
2
+ module Utils
3
+ # Merges hash with another hash, recursively.
4
+ #
5
+ # Adapted from Jekyll which got it from some gem whose link is now broken.
6
+ # Thanks to whoever made it.
7
+ def self.deep_merge(hash1, hash2)
8
+ target = hash1.dup
9
+
10
+ hash2.keys.each do |key|
11
+ if hash2[key].is_a?(Hash) && hash1[key].is_a?(Hash)
12
+ target[key] = deep_merge(target[key], hash2[key])
13
+ next
14
+ end
15
+
16
+ target[key] = hash2[key]
17
+ end
18
+
19
+ target
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,3 @@
1
+ module Silly
2
+ Version = VERSION = '0.0.1'
3
+ end
@@ -0,0 +1,24 @@
1
+ $LOAD_PATH.unshift 'lib'
2
+ require 'silly/version'
3
+
4
+ Gem::Specification.new do |s|
5
+ s.name = "silly"
6
+ s.version = Silly::Version
7
+ s.date = Time.now.strftime('%Y-%m-%d')
8
+ s.license = "http://www.opensource.org/licenses/MIT"
9
+ s.summary = 'Silly is a filesystem based Object Document Mapper.'
10
+ s.homepage = "http://github.com/ruhoh/silly"
11
+ s.email = "plusjade@gmail.com"
12
+ s.authors = ['Jade Dominguez']
13
+ s.description = 'Silly is an ODM for parsing and querying a directory like you would a database -- useful for static websites.'
14
+
15
+
16
+ s.add_development_dependency 'cucumber'
17
+ s.add_development_dependency 'capybara'
18
+ s.add_development_dependency 'rspec'
19
+
20
+ s.files = `git ls-files`.
21
+ split("\n").
22
+ sort.
23
+ reject { |file| file =~ /^(\.|rdoc|pkg|coverage)/ }
24
+ end
metadata ADDED
@@ -0,0 +1,117 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: silly
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Jade Dominguez
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2013-12-30 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: cucumber
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :development
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ! '>='
28
+ - !ruby/object:Gem::Version
29
+ version: '0'
30
+ - !ruby/object:Gem::Dependency
31
+ name: capybara
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ! '>='
36
+ - !ruby/object:Gem::Version
37
+ version: '0'
38
+ type: :development
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ! '>='
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: rspec
48
+ requirement: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - ! '>='
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ! '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ description: Silly is an ODM for parsing and querying a directory like you would a
63
+ database -- useful for static websites.
64
+ email: plusjade@gmail.com
65
+ executables: []
66
+ extensions: []
67
+ extra_rdoc_files: []
68
+ files:
69
+ - Gemfile
70
+ - README.md
71
+ - Rakefile
72
+ - features/cascade.feature
73
+ - features/data_file.feature
74
+ - features/path.feature
75
+ - features/sort.feature
76
+ - features/step_defs.rb
77
+ - features/support/env.rb
78
+ - features/support/helpers.rb
79
+ - features/where.feature
80
+ - features/where_file.feature
81
+ - lib/silly.rb
82
+ - lib/silly/base_model.rb
83
+ - lib/silly/collection.rb
84
+ - lib/silly/data_model.rb
85
+ - lib/silly/item.rb
86
+ - lib/silly/page_model.rb
87
+ - lib/silly/parse.rb
88
+ - lib/silly/query_operators.rb
89
+ - lib/silly/utils.rb
90
+ - lib/silly/version.rb
91
+ - silly.gemspec
92
+ homepage: http://github.com/ruhoh/silly
93
+ licenses:
94
+ - http://www.opensource.org/licenses/MIT
95
+ post_install_message:
96
+ rdoc_options: []
97
+ require_paths:
98
+ - lib
99
+ required_ruby_version: !ruby/object:Gem::Requirement
100
+ none: false
101
+ requirements:
102
+ - - ! '>='
103
+ - !ruby/object:Gem::Version
104
+ version: '0'
105
+ required_rubygems_version: !ruby/object:Gem::Requirement
106
+ none: false
107
+ requirements:
108
+ - - ! '>='
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ requirements: []
112
+ rubyforge_project:
113
+ rubygems_version: 1.8.24
114
+ signing_key:
115
+ specification_version: 3
116
+ summary: Silly is a filesystem based Object Document Mapper.
117
+ test_files: []