Skip to content

Commit cdb45ab

Browse files
authored
Support for data files (#3)
1 parent 9197165 commit cdb45ab

File tree

13 files changed

+291
-1
lines changed

13 files changed

+291
-1
lines changed

Gemfile.lock

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ PATH
22
remote: .
33
specs:
44
perron (0.5.0)
5+
csv
6+
json
7+
psych
58
rails (>= 7.2.0)
69

710
GEM
@@ -86,6 +89,7 @@ GEM
8689
concurrent-ruby (1.3.5)
8790
connection_pool (2.5.3)
8891
crass (1.0.6)
92+
csv (3.3.5)
8993
date (3.4.1)
9094
debug (1.11.0)
9195
irb (~> 1.10)

README.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,35 @@ bundle add {commonmarker,kramdown,redcarpet}
8989
```
9090

9191

92+
## Data Files
93+
94+
Perron can consume structured data from YML, JSON, or CSV files, making them available within your templates.
95+
This is useful for populating features, team members, or any other repeated data structure.
96+
97+
### Usage
98+
99+
To use a data file, instantiate `Perron::Data` with the basename of the file and iterate over the result.
100+
```erb
101+
<% Perron::Data.new("features").each do |feature| %>
102+
<h4><%= feature.name %></h4>
103+
<p><%= feature.description %></p>
104+
<% end %>
105+
```
106+
107+
### File Location and Formats
108+
109+
By default, Perron looks up `app/content/data/` for files with a `.yml`, `.json`, or `.csv` extension.
110+
For a `new("features")` call, it would find `features.yml`, `features.json`, or `features.csv`. You can also provide a full, absolute path to any data file.
111+
112+
### Accessing Data
113+
114+
The wrapper object provides flexible, read-only access to each record's attributes. Both dot notation and hash-like key access are supported.
115+
```ruby
116+
feature.name
117+
feature[:name]
118+
```
119+
120+
92121
## Metatags
93122

94123
The `meta_tags` helper automatically generates SEO and social sharing meta tags for your pages.
@@ -115,7 +144,7 @@ Or exclude certain tags:
115144
<%= meta_tags except: %w[twitter_card twitter_image] %>
116145
```
117146

118-
### Metadata Priority
147+
### Priority
119148

120149
Values are determined with the following precedence, from highest to lowest:
121150

lib/generators/perron/install_generator.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,12 @@ class InstallGenerator < Rails::Generators::Base
55
def copy_initializer
66
template "initializer.rb.tt", "config/initializers/perron.rb"
77
end
8+
9+
def create_data_directory
10+
data_directory = Rails.root.join("app", "views", "content", "data")
11+
empty_directory data_directory
12+
13+
template "README.md.tt", File.join(data_directory, "README.md")
14+
end
815
end
916
end
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Data
2+
3+
Perron can consume structured data from YML, JSON, or CSV files, making them available within your templates.
4+
This is useful for populating features, team members, or any other repeated data structure.
5+
6+
7+
## Usage
8+
9+
To use a data file, instantiate `Perron::Data` with the basename of the file and iterate over the result.
10+
```erb
11+
<% Perron::Data.new("features").each do |feature| %>
12+
<h4><%= feature.name %></h4>
13+
14+
<p><%= feature.description %></p>
15+
<% end %>
16+
```
17+
18+
## File Location and Formats
19+
20+
By default, Perron looks up `app/content/data/` for files with a `.yml`, `.json`, or `.csv` extension.
21+
For a `new("features")` call, it would find `features.yml`, `features.json`, or `features.csv`. You can also provide a full, absolute path to any data file.
22+
23+
24+
## Accessing Data
25+
26+
The wrapper object provides flexible, read-only access to each record's attributes. Both dot notation and hash-like key access are supported.
27+
```ruby
28+
feature.name
29+
feature[:name]
30+
```

lib/perron/errors.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,9 @@ class CollectionNotFoundError < StandardError; end
55
class FileNotFoundError < StandardError; end
66

77
class ResourceNotFoundError < StandardError; end
8+
9+
class UnsupportedDataFormatError < StandardError; end
10+
11+
class DataParseError < StandardError; end
812
end
913
end

lib/perron/site.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
require "perron/site/builder"
44
require "perron/site/collection"
55
require "perron/site/resource"
6+
require "perron/site/data"
67

78
module Perron
89
module Site

lib/perron/site/data.rb

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# frozen_string_literal: true
2+
3+
require "csv"
4+
5+
module Perron
6+
class Data < SimpleDelegator
7+
def initialize(identifier)
8+
@file_path = path_for(identifier)
9+
@records = records
10+
11+
super(records)
12+
end
13+
14+
private
15+
16+
PARSER_METHODS = {
17+
".yml" => :parse_yaml, ".yaml" => :parse_yaml,
18+
".json" => :parse_json, ".csv" => :parse_csv
19+
}.freeze
20+
SUPPORTED_EXTENSIONS = PARSER_METHODS.keys
21+
22+
def path_for(identifier)
23+
path = Pathname.new(identifier)
24+
25+
return path.to_s if path.file? && path.absolute?
26+
27+
path = SUPPORTED_EXTENSIONS.lazy.map { Rails.root.join("app", "content", "data").join("#{identifier}#{it}") }.find(&:exist?)
28+
path&.to_s or raise Errors::FileNotFoundError, "No data file found for '#{identifier}'"
29+
end
30+
31+
def records
32+
content = File.read(@file_path)
33+
extension = File.extname(@file_path)
34+
parser = PARSER_METHODS.fetch(extension) do
35+
raise Errors::UnsupportedDataFormatError, "Unsupported data format: #{extension}"
36+
end
37+
38+
data = send(parser, content)
39+
40+
unless data.is_a?(Array)
41+
raise Errors::DataParseError, "Data in '#{@file_path}' must be an array of objects."
42+
end
43+
44+
data.map { Item.new(it) }
45+
rescue Psych::SyntaxError, JSON::ParserError, CSV::MalformedCSVError => error
46+
raise Errors::DataParseError, "Failed to parse '#{@file_path}': #{error.message}"
47+
end
48+
49+
def parse_yaml(content)
50+
YAML.safe_load(content, permitted_classes: [Symbol], aliases: true)
51+
end
52+
53+
def parse_json(content)
54+
JSON.parse(content, symbolize_names: true)
55+
end
56+
57+
def parse_csv(content)
58+
CSV.new(content, headers: true, header_converters: :symbol).to_a.map(&:to_h)
59+
end
60+
61+
class Item
62+
def initialize(attributes)
63+
@attributes = attributes.transform_keys(&:to_sym)
64+
end
65+
66+
def [](key) = @attributes[key.to_sym]
67+
68+
def method_missing(method_name, *arguments, &block)
69+
return super if !@attributes.key?(method_name) || arguments.any? || block
70+
71+
@attributes[method_name]
72+
end
73+
74+
def respond_to_missing?(method_name, include_private = false)
75+
@attributes.key?(method_name) || super
76+
end
77+
end
78+
private_constant :Item
79+
end
80+
end
81+
82+
# require "csv"
83+
84+
# module Perron
85+
# class Data
86+
# include Enumerable
87+
88+
# def initialize(resource)
89+
# @file_path = path_for(resource)
90+
# @data = data
91+
# end
92+
93+
# def each(&block)
94+
# @data.each(&block)
95+
# end
96+
97+
# private
98+
99+
# PARSER_METHODS = {
100+
# ".csv" => :parse_csv,
101+
# ".json" => :parse_json,
102+
# ".yaml" => :parse_yaml,
103+
# ".yml" => :parse_yaml
104+
# }.freeze
105+
# SUPPORTED_EXTENSIONS = PARSER_METHODS.keys.freeze
106+
107+
# def path_for(identifier)
108+
# path = Pathname.new(identifier)
109+
110+
# return path.to_s if path.file? && path.absolute?
111+
112+
# found_path = SUPPORTED_EXTENSIONS.lazy.map do |extension|
113+
# Rails.root.join("app", "content", "data").join("#{identifier}#{extension}")
114+
# end.find(&:exist?)
115+
116+
# found_path&.to_s or raise Errors::FileNotFoundError, "No data file found for '#{identifier}'"
117+
# end
118+
119+
# def data
120+
# content = File.read(@file_path)
121+
# extension = File.extname(@file_path)
122+
# parser = PARSER_METHODS.fetch(extension) do
123+
# raise Errors::UnsupportedDataFormatError, "Unsupported data format: #{extension}"
124+
# end
125+
126+
# raw_data = send(parser, content)
127+
128+
# unless raw_data.is_a?(Array)
129+
# raise Errors::DataParseError, "Data in '#{@file_path}' must be an array of objects."
130+
# end
131+
132+
# struct = Struct.new(*raw_data.first.keys, keyword_init: true)
133+
# raw_data.map { struct.new(**it) }
134+
# rescue Psych::SyntaxError, JSON::ParserError, CSV::MalformedCSVError => error
135+
# raise Errors::DataParseError, "Failed to parse '#{@file_path}': #{error.message}"
136+
# end
137+
138+
# def parse_yaml(content) = YAML.safe_load(content, permitted_classes: [Symbol], aliases: true)
139+
140+
# def parse_json(content) = JSON.parse(content, symbolize_names: true)
141+
142+
# def parse_csv(content) = CSV.new(content, headers: true, header_converters: :symbol).to_a.map(&:to_h)
143+
# end
144+
# end

perron.gemspec

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,8 @@ Gem::Specification.new do |spec|
1919
spec.required_ruby_version = ">= 3.4.0"
2020

2121
spec.add_dependency "rails", ">= 7.2.0"
22+
23+
spec.add_runtime_dependency "csv"
24+
spec.add_runtime_dependency "json"
25+
spec.add_runtime_dependency "psych"
2226
end

test/data_test.rb

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
require "test_helper"
2+
3+
class PerronDataTest < ActiveSupport::TestCase
4+
test "loads yaml file by basename" do
5+
data = Perron::Data.new("users")
6+
7+
assert_equal 2, data.count
8+
9+
first_user = data.first
10+
11+
assert_equal "Cam", first_user.name
12+
assert_equal "administrator", first_user[:role]
13+
end
14+
15+
test "loads json file by basename" do
16+
data = Perron::Data.new("products")
17+
18+
assert_equal 2, data.count
19+
20+
last_product = data.last
21+
22+
assert_equal "MSE-002", last_product.sku
23+
assert_equal 25, last_product[:price]
24+
end
25+
26+
test "loads csv file by basename" do
27+
data = Perron::Data.new("orders")
28+
29+
assert_equal 2, data.count
30+
31+
first_order = data.first
32+
33+
assert_equal "101", first_order.order_id
34+
assert_equal "75.20", first_order[:amount]
35+
end
36+
37+
test "loads file with a full path" do
38+
full_path = Rails.root.join("app", "content", "data", "users.yml").to_s
39+
data = Perron::Data.new(full_path)
40+
41+
assert_equal "Kendall", data.last.name
42+
end
43+
44+
test "raises FileNotFoundError for a missing file" do
45+
assert_raises Perron::Errors::FileNotFoundError do
46+
Perron::Data.new("non_existent_file")
47+
end
48+
end
49+
50+
test "raises DataParseError for data not structured as an array" do
51+
assert_raises Perron::Errors::DataParseError do
52+
Perron::Data.new("not_an_array")
53+
end
54+
end
55+
end
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{ "error": "This root object is not an array" }

0 commit comments

Comments
 (0)