silly 0.0.1

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.
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: []