perron 0.13.3 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/README.md +1 -1
- data/lib/generators/perron/install_generator.rb +1 -4
- data/lib/generators/perron/templates/README.md.tt +5 -17
- data/lib/generators/perron/templates/initializer.rb.tt +3 -3
- data/lib/generators/rails/content/USAGE +41 -0
- data/lib/generators/rails/content/content_generator.rb +126 -0
- data/lib/perron/collection.rb +12 -7
- data/lib/perron/engine.rb +1 -0
- data/lib/perron/markdown.rb +51 -21
- data/lib/perron/resource/associations.rb +69 -0
- data/lib/perron/resource/metadata.rb +7 -3
- data/lib/perron/resource/previewable.rb +25 -0
- data/lib/perron/resource/publishable.rb +8 -0
- data/lib/perron/resource/reading_time.rb +36 -0
- data/lib/perron/resource/slug.rb +3 -2
- data/lib/perron/resource/sourceable.rb +102 -0
- data/lib/perron/resource.rb +8 -0
- data/lib/perron/site/builder/feeds/json.rb +1 -1
- data/lib/perron/site/builder/feeds/rss.rb +1 -1
- data/lib/perron/site/builder/paths.rb +1 -1
- data/lib/perron/site/builder.rb +13 -0
- data/lib/perron/site/validate.rb +3 -1
- data/lib/perron/tasks/sync_sources.rake +12 -0
- data/lib/perron/tasks/validate.rake +1 -1
- data/lib/perron/version.rb +1 -1
- metadata +14 -9
- data/lib/generators/content/USAGE +0 -16
- data/lib/generators/content/content_generator.rb +0 -72
- /data/lib/generators/{content → rails/content}/templates/controller.rb.tt +0 -0
- /data/lib/generators/{content → rails/content}/templates/index.html.erb.tt +0 -0
- /data/lib/generators/{content → rails/content}/templates/model.rb.tt +0 -0
- /data/lib/generators/{content → rails/content}/templates/root.erb.tt +0 -0
- /data/lib/generators/{content → rails/content}/templates/show.html.erb.tt +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f86f6e49cb1954ed51dc8aa0c2b6cfdaf3f1fcc3c78141b36c837f0eef26a9c8
|
|
4
|
+
data.tar.gz: efaff555c00579430812ca06e3ee6a87376db403321ab302762b393356d7607a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c7e7c64d77fac8aadb06e3fc3541db5f8593a571938c12020dcbcb03e3f968dd326eede32b06342843df4b0e84e8722b4beb95b5d202283539b763ba3db01ada
|
|
7
|
+
data.tar.gz: 1a3f470b3412fa9187bca282ee1ac051cd1674c34a19a761b6cdedf901f0e5e2e6bb2c1a025ffadf1e2f478fcf13b9f8a90ab29579d46ae0cf3594099b13fb5d
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -11,10 +11,7 @@ module Perron
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def create_data_directory
|
|
14
|
-
|
|
15
|
-
empty_directory data_directory
|
|
16
|
-
|
|
17
|
-
template "README.md.tt", File.join(data_directory, "README.md")
|
|
14
|
+
template "README.md.tt", "app/content/data/README.md"
|
|
18
15
|
end
|
|
19
16
|
|
|
20
17
|
def add_markdown_gems
|
|
@@ -1,37 +1,25 @@
|
|
|
1
1
|
# Data
|
|
2
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.
|
|
3
|
+
Perron can consume structured data from YML, JSON, or CSV files, making them available within your templates. This is useful for populating features, team members, or any other repeated data structure.
|
|
5
4
|
|
|
6
5
|
|
|
7
6
|
## Usage
|
|
8
7
|
|
|
9
|
-
To use a data file,
|
|
8
|
+
To use a data file, instantiate `Perron::Site.data` with the basename of the file and iterate over the result.
|
|
10
9
|
```erb
|
|
11
10
|
<%% Perron::Site.data.features.each do |feature| %>
|
|
12
11
|
<h4><%%= feature.name %></h4>
|
|
13
|
-
|
|
14
|
-
<p><%%= feature.description %></p>
|
|
15
|
-
<%% end %>
|
|
16
|
-
```
|
|
17
|
-
|
|
18
|
-
This is a convenient shorthand for `Perron::Data.new("features")`, which can also be used directly:
|
|
19
|
-
```ruby
|
|
20
|
-
<%% Perron::Data.new("features").each do |feature| %>
|
|
21
|
-
<h4><%%= feature.name %></h4>
|
|
22
|
-
|
|
23
12
|
<p><%%= feature.description %></p>
|
|
24
13
|
<%% end %>
|
|
25
14
|
```
|
|
26
15
|
|
|
27
16
|
|
|
28
|
-
## File
|
|
17
|
+
## File location and formats
|
|
29
18
|
|
|
30
|
-
By default, Perron looks up `app/content/data/` for files with a `.yml`, `.json`, or `.csv` extension.
|
|
31
|
-
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, like `Perron::Data.new("path-to-some-data-file")`.
|
|
19
|
+
By default, Perron looks up `app/content/data/` for files with a `.yml`, `.json`, or `.csv` extension. For a `features` call, it would find `features.yml`, `features.json`, or `features.csv`. You can also provide a path to any data file, via `Perron::Data.new("path/to/data.json")`.
|
|
32
20
|
|
|
33
21
|
|
|
34
|
-
## Accessing
|
|
22
|
+
## Accessing data
|
|
35
23
|
|
|
36
24
|
The wrapper object provides flexible, read-only access to each record's attributes. Both dot notation and hash-like key access are supported.
|
|
37
25
|
```ruby
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Perron.configure do |config|
|
|
2
2
|
# config.output = "output"
|
|
3
3
|
|
|
4
|
-
# config.site_name = "
|
|
4
|
+
# config.site_name = "Chirp Form"
|
|
5
5
|
|
|
6
6
|
# The build mode for Perron. Can be :standalone or :integrated
|
|
7
7
|
# config.mode = :standalone
|
|
@@ -16,8 +16,8 @@ Perron.configure do |config|
|
|
|
16
16
|
|
|
17
17
|
# Set default meta values
|
|
18
18
|
# Examples:
|
|
19
|
-
# - `config.metadata.description = "
|
|
20
|
-
# - `config.metadata.author = "
|
|
19
|
+
# - `config.metadata.description = "Add forms to any static site. Display responses anywhere."`
|
|
20
|
+
# - `config.metadata.author = "Chirp Form Team"`
|
|
21
21
|
|
|
22
22
|
# Set meta title suffix
|
|
23
23
|
# config.metadata.title_suffix = nil
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Generates content model scaffold (controller, views, routes) or creates
|
|
3
|
+
new content files from templates.
|
|
4
|
+
|
|
5
|
+
Examples:
|
|
6
|
+
Generate content scaffold:
|
|
7
|
+
rails generate content Post
|
|
8
|
+
rails generate content Post index
|
|
9
|
+
rails generate content Post index show
|
|
10
|
+
|
|
11
|
+
This will create:
|
|
12
|
+
app/content/posts/
|
|
13
|
+
app/models/content/post.rb
|
|
14
|
+
app/controllers/content/posts_controller.rb
|
|
15
|
+
app/views/content/posts/index.html.erb
|
|
16
|
+
app/views/content/posts/show.html.erb
|
|
17
|
+
|
|
18
|
+
And adds: resources :posts, module: :content, only: %w[index show]
|
|
19
|
+
|
|
20
|
+
Create new content file from template:
|
|
21
|
+
rails generate content Post --new
|
|
22
|
+
rails generate content Post --new "My First Post"
|
|
23
|
+
|
|
24
|
+
This will create a new content file in app/content/posts/ using:
|
|
25
|
+
- YYYY-MM-DD-template.*.tt (if exists, with date prefix)
|
|
26
|
+
- template.*.tt (if exists, without date prefix)
|
|
27
|
+
- Empty file with frontmatter dashes (if no template)
|
|
28
|
+
|
|
29
|
+
Template files support ERB:
|
|
30
|
+
---
|
|
31
|
+
title: <%= @title %>
|
|
32
|
+
published_at: <%= Time.current %>
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
Arguments:
|
|
36
|
+
NAME: Name of the content model (singular or plural)
|
|
37
|
+
actions: Actions to generate (default: index show)
|
|
38
|
+
|
|
39
|
+
Options:
|
|
40
|
+
--new [TITLE]: Create new content file instead of scaffold
|
|
41
|
+
--force-plural: Use plural form for model name and class
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
|
|
5
|
+
module Rails
|
|
6
|
+
module Generators
|
|
7
|
+
class ContentGenerator < Rails::Generators::NamedBase
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
class_option :force_plural, type: :boolean, default: false, desc: "Forces the use of a plural model name and class"
|
|
11
|
+
class_option :new, type: :string, default: nil, banner: "TITLE",
|
|
12
|
+
desc: "Create a new content file from template instead of generating scaffold"
|
|
13
|
+
|
|
14
|
+
argument :actions, type: :array, default: %w[index show], banner: "actions", desc: "Specify which actions to generate (index/show)"
|
|
15
|
+
|
|
16
|
+
def initialize(*args)
|
|
17
|
+
super
|
|
18
|
+
|
|
19
|
+
@content_mode = !options[:new].nil?
|
|
20
|
+
@content_title = options[:new].presence
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def create_content_file
|
|
24
|
+
return unless @content_mode
|
|
25
|
+
|
|
26
|
+
@title = @content_title
|
|
27
|
+
|
|
28
|
+
if template_file
|
|
29
|
+
create_file File.join(content_directory, filename_from_template), ERB.new(File.read(template_file)).result(binding)
|
|
30
|
+
else
|
|
31
|
+
create_file File.join(content_directory, filename_from_template), "---\n---\n"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def create_model
|
|
36
|
+
return if @content_mode
|
|
37
|
+
|
|
38
|
+
template "model.rb.tt", File.join("app/models/content", "#{file_name}.rb")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def create_controller
|
|
42
|
+
return if @content_mode
|
|
43
|
+
|
|
44
|
+
template "controller.rb.tt", File.join("app/controllers/content", "#{plural_file_name}_controller.rb")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def create_views
|
|
48
|
+
return if @content_mode
|
|
49
|
+
|
|
50
|
+
empty_directory view_directory
|
|
51
|
+
|
|
52
|
+
actions.each do |action|
|
|
53
|
+
template "#{action}.html.erb.tt", File.join(view_directory, "#{action}.html.erb")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def create_content_directory
|
|
58
|
+
return if @content_mode
|
|
59
|
+
|
|
60
|
+
FileUtils.mkdir_p(content_directory)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def create_pages_root
|
|
64
|
+
return if @content_mode
|
|
65
|
+
return unless pages_controller?
|
|
66
|
+
|
|
67
|
+
template "root.erb.tt", File.join(content_directory, "root.erb")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def add_content_route
|
|
71
|
+
return if @content_mode
|
|
72
|
+
|
|
73
|
+
route "resources :#{plural_file_name}, module: :content, only: %w[#{actions.join(" ")}]"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def add_root_route
|
|
77
|
+
return if @content_mode
|
|
78
|
+
return unless pages_controller?
|
|
79
|
+
return if root_route_exists?
|
|
80
|
+
|
|
81
|
+
inject_into_file "config/routes.rb", " root to: \"content/pages#root\"\n", before: /^\s*end\s*$/
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def file_name
|
|
87
|
+
options[:force_plural] ? super.pluralize : super.singularize
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def class_name
|
|
91
|
+
options[:force_plural] ? super.pluralize : super.singularize
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def view_directory = File.join(destination_root, "app", "views", "content", plural_file_name)
|
|
95
|
+
|
|
96
|
+
def content_directory = File.join(destination_root, "app", "content", plural_file_name)
|
|
97
|
+
|
|
98
|
+
def plural_class_name = plural_name.camelize
|
|
99
|
+
|
|
100
|
+
def pages_controller? = plural_file_name == "pages"
|
|
101
|
+
|
|
102
|
+
def root_route_exists?
|
|
103
|
+
routes = File.join(destination_root, "config", "routes.rb")
|
|
104
|
+
|
|
105
|
+
return false unless File.exist?(routes)
|
|
106
|
+
|
|
107
|
+
File.read(routes).match?(/\broot\s+to:/)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def template_file
|
|
111
|
+
@template_file ||= Dir.glob(File.join(content_directory, "{YYYY-MM-DD-,}template.*.tt")).first
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def filename_from_template
|
|
115
|
+
@filename_from_template ||= begin
|
|
116
|
+
return "untitled.md" unless template_file
|
|
117
|
+
|
|
118
|
+
File.basename(template_file, ".tt").tap do |name|
|
|
119
|
+
name.gsub!("YYYY-MM-DD", Time.current.strftime("%Y-%m-%d"))
|
|
120
|
+
name.sub!("template", @content_title ? @content_title.parameterize : "untitled")
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
data/lib/perron/collection.rb
CHANGED
|
@@ -16,17 +16,12 @@ module Perron
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def all(resource_class = "Content::#{name.classify}".safe_constantize)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
Dir.glob("#{@collection_path}/**/*.*")
|
|
22
|
-
.select { allowed_extensions.include?(File.extname(it)) }
|
|
23
|
-
.map { resource_class.new(it) }
|
|
24
|
-
.select(&:published?)
|
|
19
|
+
load_resources(resource_class).select(&:published?)
|
|
25
20
|
end
|
|
26
21
|
alias_method :resources, :all
|
|
27
22
|
|
|
28
23
|
def find(slug, resource_class = Resource)
|
|
29
|
-
resource =
|
|
24
|
+
resource = load_resources(resource_class).find { it.slug == slug }
|
|
30
25
|
|
|
31
26
|
return resource if resource
|
|
32
27
|
|
|
@@ -40,5 +35,15 @@ module Perron
|
|
|
40
35
|
end
|
|
41
36
|
|
|
42
37
|
def validate = Perron::Site::Validate.new(collections: [self]).validate
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def load_resources(resource_class = "Content::#{name.classify}".safe_constantize)
|
|
42
|
+
allowed_extensions = Perron.configuration.allowed_extensions.map { ".#{it}" }.to_set
|
|
43
|
+
|
|
44
|
+
Dir.glob("#{@collection_path}/**/*.*")
|
|
45
|
+
.select { allowed_extensions.include?(File.extname(it)) }
|
|
46
|
+
.map { resource_class.new(it) }
|
|
47
|
+
end
|
|
43
48
|
end
|
|
44
49
|
end
|
data/lib/perron/engine.rb
CHANGED
data/lib/perron/markdown.rb
CHANGED
|
@@ -17,39 +17,69 @@ module Perron
|
|
|
17
17
|
@parser ||= markdown_parser
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
def
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
20
|
+
def configured_parser
|
|
21
|
+
return unless (parser_name = Perron.configuration.markdown_parser)
|
|
22
|
+
class_name = parser_name.to_s.camelize
|
|
23
|
+
class_name += "Parser" unless class_name.end_with?("Parser")
|
|
24
|
+
|
|
25
|
+
klass = if const_defined?(class_name)
|
|
26
|
+
const_get(class_name)
|
|
27
|
+
elsif Object.const_defined?(class_name)
|
|
28
|
+
Object.const_get(class_name)
|
|
27
29
|
else
|
|
28
|
-
|
|
30
|
+
raise "Can't find parser #{parser_name} by class name #{class_name}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
unless klass.available?
|
|
34
|
+
raise "Parser #{parser_name} #{class_name} is not available (gem not installed?)"
|
|
29
35
|
end
|
|
36
|
+
|
|
37
|
+
klass
|
|
30
38
|
end
|
|
31
|
-
end
|
|
32
39
|
|
|
33
|
-
|
|
34
|
-
|
|
40
|
+
def available_parser = Parser.descendants.find(&:available?) || Parser
|
|
41
|
+
|
|
42
|
+
def markdown_parser
|
|
43
|
+
(configured_parser || available_parser).new(**Perron.configuration.markdown_options)
|
|
44
|
+
end
|
|
35
45
|
end
|
|
36
46
|
|
|
37
|
-
class
|
|
38
|
-
|
|
47
|
+
class Parser
|
|
48
|
+
attr_reader :options
|
|
49
|
+
|
|
50
|
+
def initialize(**options)
|
|
51
|
+
@options = options
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def parse(text) = text.to_s
|
|
55
|
+
|
|
56
|
+
def self.available? = true
|
|
39
57
|
end
|
|
40
58
|
|
|
41
|
-
class RedcarpetParser
|
|
42
|
-
def
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
markdown = Redcarpet::Markdown.new(renderer, options.fetch(:markdown_options, {}))
|
|
59
|
+
class RedcarpetParser < Parser
|
|
60
|
+
def renderer
|
|
61
|
+
@renderer ||= Redcarpet::Render::HTML.new(options.fetch(:renderer_options, {}))
|
|
62
|
+
end
|
|
46
63
|
|
|
47
|
-
|
|
64
|
+
def markdown
|
|
65
|
+
@markdown ||= Redcarpet::Markdown.new(renderer, options.fetch(:markdown_options, {}))
|
|
48
66
|
end
|
|
67
|
+
|
|
68
|
+
def parse(text) = markdown.render(text)
|
|
69
|
+
|
|
70
|
+
def self.available? = defined?(::Redcarpet)
|
|
49
71
|
end
|
|
50
72
|
|
|
51
|
-
class
|
|
52
|
-
def parse(text) = text.
|
|
73
|
+
class KramdownParser < Parser
|
|
74
|
+
def parse(text) = Kramdown::Document.new(text, options).to_html
|
|
75
|
+
|
|
76
|
+
def self.available? = defined?(::Kramdown)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
class CommonMarkerParser < Parser
|
|
80
|
+
def parse(text) = Commonmarker.to_html(text, **options)
|
|
81
|
+
|
|
82
|
+
def self.available? = defined?(::Commonmarker)
|
|
53
83
|
end
|
|
54
84
|
end
|
|
55
85
|
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Perron
|
|
4
|
+
class Resource
|
|
5
|
+
module Associations
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
class_methods do
|
|
9
|
+
def belongs_to(association_name, **options)
|
|
10
|
+
define_method(association_name) do
|
|
11
|
+
cache_belongs_to_association(association_name) do
|
|
12
|
+
associated_class = association_class_for(association_name, **options)
|
|
13
|
+
foreign_key = foreign_key_for(association_name, **options)
|
|
14
|
+
identifier = metadata[foreign_key]
|
|
15
|
+
|
|
16
|
+
identifier ? associated_class.find(identifier) : nil
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def has_many(association_name, **options)
|
|
22
|
+
define_method(association_name) do
|
|
23
|
+
cache_has_many_association(association_name) do
|
|
24
|
+
associated_class = association_class_for(association_name, singularize: true, **options)
|
|
25
|
+
foreign_key = foreign_key_for(inverse_association_name, **options)
|
|
26
|
+
primary_key_method = options.fetch(:primary_key, :slug)
|
|
27
|
+
lookup_value = public_send(primary_key_method)
|
|
28
|
+
|
|
29
|
+
associated_class.all.select { |record| record.metadata[foreign_key] == lookup_value }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def cache_belongs_to_association(name)
|
|
38
|
+
@belongs_to_cache ||= {}
|
|
39
|
+
return @belongs_to_cache[name] if @belongs_to_cache.key?(name)
|
|
40
|
+
|
|
41
|
+
@belongs_to_cache[name] = yield
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def cache_has_many_association(name)
|
|
45
|
+
@has_many_cache ||= {}
|
|
46
|
+
return @has_many_cache[name] if @has_many_cache.key?(name)
|
|
47
|
+
|
|
48
|
+
@has_many_cache[name] = yield
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def association_class_for(association_name, singularize: false, **options)
|
|
52
|
+
class_name = options[:class_name] || begin
|
|
53
|
+
name = association_name.to_s
|
|
54
|
+
name = name.singularize if singularize
|
|
55
|
+
|
|
56
|
+
"Content::#{name.classify}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class_name.constantize
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def foreign_key_for(base_name, **options)
|
|
63
|
+
(options[:foreign_key] || "#{base_name}_id").to_s
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def inverse_association_name = self.class.name.demodulize.underscore
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -49,10 +49,14 @@ module Perron
|
|
|
49
49
|
return @frontmatter[:canonical_url] if @frontmatter[:canonical_url]
|
|
50
50
|
return Rails.application.routes.url_helpers.root_url(**Perron.configuration.default_url_options) if @resource.slug == "/"
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
begin
|
|
53
|
+
Rails.application.routes.url_helpers.polymorphic_url(
|
|
54
|
+
@resource,
|
|
54
55
|
**Perron.configuration.default_url_options
|
|
55
|
-
|
|
56
|
+
)
|
|
57
|
+
rescue
|
|
58
|
+
false
|
|
59
|
+
end
|
|
56
60
|
end
|
|
57
61
|
|
|
58
62
|
def site_data
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Perron
|
|
4
|
+
class Resource
|
|
5
|
+
module Previewable
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
def previewable?
|
|
10
|
+
frontmatter.preview.present? && (draft? || scheduled?)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def preview_token
|
|
14
|
+
return nil unless previewable?
|
|
15
|
+
|
|
16
|
+
@preview_token ||= if frontmatter.preview == true
|
|
17
|
+
Digest::SHA256.hexdigest(file_path)[0..11]
|
|
18
|
+
else
|
|
19
|
+
frontmatter.preview
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -16,8 +16,16 @@ module Perron
|
|
|
16
16
|
true
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
def buildable?
|
|
20
|
+
published? || previewable?
|
|
21
|
+
end
|
|
22
|
+
|
|
19
23
|
def scheduled? = publication_date&.after?(Time.current)
|
|
20
24
|
|
|
25
|
+
def draft?
|
|
26
|
+
frontmatter.draft == true || frontmatter.published == false
|
|
27
|
+
end
|
|
28
|
+
|
|
21
29
|
def publication_date
|
|
22
30
|
@publication_date ||= begin
|
|
23
31
|
from_meta = frontmatter.published_at.present? ? begin
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Perron
|
|
4
|
+
class Resource
|
|
5
|
+
module ReadingTime
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
def estimated_reading_time(wpm: DEFAULT_WORDS_PER_MINUTE, format: DEFAULT_FORMAT)
|
|
9
|
+
word_count = content.scan(/\b[a-zA-Z]+\b/).size
|
|
10
|
+
total_minutes = [(word_count.to_f / wpm).ceil, 1].max
|
|
11
|
+
|
|
12
|
+
hours = total_minutes / 60
|
|
13
|
+
minutes = total_minutes % 60
|
|
14
|
+
seconds = ((word_count.to_f / wpm) * 60).to_i % 60
|
|
15
|
+
|
|
16
|
+
return total_minutes if format.blank?
|
|
17
|
+
|
|
18
|
+
format % {
|
|
19
|
+
minutes: minutes,
|
|
20
|
+
total_minutes: total_minutes,
|
|
21
|
+
hours: hours,
|
|
22
|
+
seconds: seconds,
|
|
23
|
+
min: minutes,
|
|
24
|
+
h: hours,
|
|
25
|
+
s: seconds
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
alias_method :reading_time, :estimated_reading_time
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
DEFAULT_WORDS_PER_MINUTE = 200
|
|
33
|
+
DEFAULT_FORMAT = "%{minutes} min read"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/perron/resource/slug.rb
CHANGED
|
@@ -15,8 +15,9 @@ module Perron
|
|
|
15
15
|
def create
|
|
16
16
|
return "/" if Perron.configuration.allowed_extensions.any? { @resource.filename == "root.#{it}" }
|
|
17
17
|
|
|
18
|
-
@frontmatter.slug.presence ||
|
|
19
|
-
|
|
18
|
+
base_slug = @frontmatter.slug.presence || @resource.filename.sub(/^[\d-]+-/, "").delete_suffixes(dot_prepended_allowed_extensions)
|
|
19
|
+
|
|
20
|
+
[base_slug, @resource.preview_token].compact.join("-")
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
private
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Perron
|
|
4
|
+
class Resource
|
|
5
|
+
module Sourceable
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
class_methods do
|
|
9
|
+
def sources(*arguments)
|
|
10
|
+
@source_definitions = parsed(*arguments)
|
|
11
|
+
end
|
|
12
|
+
alias_method :source, :sources
|
|
13
|
+
|
|
14
|
+
def source_definitions
|
|
15
|
+
@source_definitions || {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def source_names = source_definitions.keys
|
|
19
|
+
|
|
20
|
+
def generate_from_sources!
|
|
21
|
+
return unless source_backed?
|
|
22
|
+
|
|
23
|
+
combinations.each do |combo|
|
|
24
|
+
content = content_with combo
|
|
25
|
+
filename = filename_with combo
|
|
26
|
+
|
|
27
|
+
FileUtils.mkdir_p(output_dir)
|
|
28
|
+
File.write(output_dir.join("#{filename}.erb"), content)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def source_backed? = source_names.any?
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def parsed(*arguments)
|
|
37
|
+
return {} if arguments.empty?
|
|
38
|
+
|
|
39
|
+
arguments.flat_map { it.is_a?(Hash) ? it.to_a : [[it, {primary_key: :id}]] }.to_h
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def combinations
|
|
43
|
+
datasets = source_names.map { Perron::Site.data.public_send(it) }
|
|
44
|
+
|
|
45
|
+
datasets.first.product(*datasets[1..])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def content_with(combo)
|
|
49
|
+
data = source_names.each.with_index.to_h { |name, index| [name, combo[index]] }
|
|
50
|
+
sources = Source.new(data)
|
|
51
|
+
|
|
52
|
+
source_template(sources)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def filename_with(combo)
|
|
56
|
+
source_names.each_with_index.map do |name, index|
|
|
57
|
+
primary_key = source_definitions[name][:primary_key]
|
|
58
|
+
|
|
59
|
+
combo[index].public_send(primary_key)
|
|
60
|
+
end.join("-")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def output_dir = Perron.configuration.input.join(model_name.collection)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def source_backed? = self.class.source_backed?
|
|
67
|
+
|
|
68
|
+
def sources
|
|
69
|
+
@sources ||= begin
|
|
70
|
+
data = self.class.source_definitions.each_with_object({}) do |(name, options), hash|
|
|
71
|
+
primary_key = options[:primary_key]
|
|
72
|
+
singular_name = name.to_s.singularize
|
|
73
|
+
identifier = frontmatter["#{singular_name}_#{primary_key}"]
|
|
74
|
+
hash[name] = Perron::Site.data.public_send(name).find { it.public_send(primary_key).to_s == identifier.to_s }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
Source.new(data)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def source_template(sources)
|
|
82
|
+
raise NotImplementedError, "#{self.class.name} must implement #source_template"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
class Source
|
|
86
|
+
def initialize(data)
|
|
87
|
+
@data = data
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def method_missing(name, *arguments, &block)
|
|
91
|
+
return super if arguments.any? || block
|
|
92
|
+
return @data[name] if @data.key?(name)
|
|
93
|
+
|
|
94
|
+
super
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def respond_to_missing?(name, _) = @data.key?(name)
|
|
98
|
+
end
|
|
99
|
+
private_constant :Source
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
data/lib/perron/resource.rb
CHANGED
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
require "perron/resource/configuration"
|
|
4
4
|
require "perron/resource/core"
|
|
5
5
|
require "perron/resource/class_methods"
|
|
6
|
+
require "perron/resource/associations"
|
|
6
7
|
require "perron/resource/metadata"
|
|
8
|
+
require "perron/resource/previewable"
|
|
7
9
|
require "perron/resource/publishable"
|
|
10
|
+
require "perron/resource/reading_time"
|
|
8
11
|
require "perron/resource/related"
|
|
9
12
|
require "perron/resource/renderer"
|
|
10
13
|
require "perron/resource/slug"
|
|
11
14
|
require "perron/resource/separator"
|
|
15
|
+
require "perron/resource/sourceable"
|
|
12
16
|
require "perron/resource/table_of_content"
|
|
13
17
|
|
|
14
18
|
module Perron
|
|
@@ -20,7 +24,11 @@ module Perron
|
|
|
20
24
|
include Perron::Resource::Configuration
|
|
21
25
|
include Perron::Resource::Core
|
|
22
26
|
include Perron::Resource::ClassMethods
|
|
27
|
+
include Perron::Resource::Associations
|
|
28
|
+
include Perron::Resource::ReadingTime
|
|
29
|
+
include Perron::Resource::Sourceable
|
|
23
30
|
include Perron::Resource::Publishable
|
|
31
|
+
include Perron::Resource::Previewable
|
|
24
32
|
include Perron::Resource::TableOfContent
|
|
25
33
|
|
|
26
34
|
attr_reader :file_path, :id
|
|
@@ -25,7 +25,7 @@ module Perron
|
|
|
25
25
|
items: resources.map do |resource|
|
|
26
26
|
{
|
|
27
27
|
id: resource.id,
|
|
28
|
-
url: url.polymorphic_url(resource),
|
|
28
|
+
url: url.polymorphic_url(resource, ref: feed_configuration.ref).delete_suffix("?ref="),
|
|
29
29
|
date_published: resource.published_at&.iso8601,
|
|
30
30
|
title: resource.metadata.title,
|
|
31
31
|
content_html: Perron::Markdown.render(resource.content)
|
|
@@ -27,7 +27,7 @@ module Perron
|
|
|
27
27
|
resources.each do |resource|
|
|
28
28
|
xml.item do
|
|
29
29
|
xml.guid resource.id
|
|
30
|
-
xml.link url.polymorphic_url(resource), isPermaLink: true
|
|
30
|
+
xml.link url.polymorphic_url(resource, ref: feed_configuration.ref).delete_suffix("?ref="), isPermaLink: true
|
|
31
31
|
xml.pubDate(resource.published_at&.rfc822)
|
|
32
32
|
xml.title resource.metadata.title
|
|
33
33
|
xml.description { xml.cdata(Perron::Markdown.render(resource.content)) }
|
|
@@ -12,7 +12,7 @@ module Perron
|
|
|
12
12
|
@paths << routes.public_send(index_path) if routes.respond_to?(index_path)
|
|
13
13
|
|
|
14
14
|
if routes.respond_to?(show_path)
|
|
15
|
-
@collection.
|
|
15
|
+
@collection.send(:load_resources).select(&:buildable?).each do |resource|
|
|
16
16
|
root = resource.slug == "/"
|
|
17
17
|
|
|
18
18
|
next if skip? root
|
data/lib/perron/site/builder.rb
CHANGED
|
@@ -31,6 +31,8 @@ module Perron
|
|
|
31
31
|
Perron::Site::Builder::Sitemap.new(@output_path).generate
|
|
32
32
|
Perron::Site::Builder::Feeds.new(@output_path).generate
|
|
33
33
|
|
|
34
|
+
output_preview_urls
|
|
35
|
+
|
|
34
36
|
puts "\n✅ Build complete"
|
|
35
37
|
end
|
|
36
38
|
|
|
@@ -43,6 +45,17 @@ module Perron
|
|
|
43
45
|
end
|
|
44
46
|
|
|
45
47
|
def render_page(path) = Perron::Site::Builder::Page.new(path).render
|
|
48
|
+
|
|
49
|
+
def output_preview_urls
|
|
50
|
+
previewable_resources = Perron::Site.collections.flat_map { it.send(:load_resources) }.select(&:previewable?)
|
|
51
|
+
|
|
52
|
+
if previewable_resources.any?
|
|
53
|
+
puts "\n🔒 Preview URLs:"
|
|
54
|
+
previewable_resources.each do |resource|
|
|
55
|
+
puts " #{Rails.application.routes.url_helpers.polymorphic_url(resource, **Perron.configuration.default_url_options)}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
46
59
|
end
|
|
47
60
|
end
|
|
48
61
|
end
|
data/lib/perron/site/validate.rb
CHANGED
|
@@ -19,11 +19,13 @@ module Perron
|
|
|
19
19
|
|
|
20
20
|
puts [
|
|
21
21
|
"Validation finished",
|
|
22
|
-
(" with #{@failures.count} failures" if
|
|
22
|
+
(" with #{@failures.count} failures" if failed?),
|
|
23
23
|
"."
|
|
24
24
|
].join
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
def failed? = @failures.any?
|
|
28
|
+
|
|
27
29
|
private
|
|
28
30
|
|
|
29
31
|
Failure = ::Data.define(:identifier, :errors)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
namespace :perron do
|
|
2
|
+
desc "Sync source-backed resources"
|
|
3
|
+
task :sync_sources, [:name] => :environment do |_, arguments|
|
|
4
|
+
Rails.application.eager_load!
|
|
5
|
+
|
|
6
|
+
resource_classes = arguments.name ? ["Content::#{arguments.name.classify}".constantize] : Perron::Resource.descendants
|
|
7
|
+
|
|
8
|
+
resource_classes.compact.each do |resource_class|
|
|
9
|
+
resource_class.generate_from_sources! if resource_class.source_backed?
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
data/lib/perron/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: perron
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.15.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Rails Designer Developers
|
|
@@ -86,16 +86,16 @@ files:
|
|
|
86
86
|
- bin/rails
|
|
87
87
|
- bin/release
|
|
88
88
|
- bin/setup
|
|
89
|
-
- lib/generators/content/USAGE
|
|
90
|
-
- lib/generators/content/content_generator.rb
|
|
91
|
-
- lib/generators/content/templates/controller.rb.tt
|
|
92
|
-
- lib/generators/content/templates/index.html.erb.tt
|
|
93
|
-
- lib/generators/content/templates/model.rb.tt
|
|
94
|
-
- lib/generators/content/templates/root.erb.tt
|
|
95
|
-
- lib/generators/content/templates/show.html.erb.tt
|
|
96
89
|
- lib/generators/perron/install_generator.rb
|
|
97
90
|
- lib/generators/perron/templates/README.md.tt
|
|
98
91
|
- lib/generators/perron/templates/initializer.rb.tt
|
|
92
|
+
- lib/generators/rails/content/USAGE
|
|
93
|
+
- lib/generators/rails/content/content_generator.rb
|
|
94
|
+
- lib/generators/rails/content/templates/controller.rb.tt
|
|
95
|
+
- lib/generators/rails/content/templates/index.html.erb.tt
|
|
96
|
+
- lib/generators/rails/content/templates/model.rb.tt
|
|
97
|
+
- lib/generators/rails/content/templates/root.erb.tt
|
|
98
|
+
- lib/generators/rails/content/templates/show.html.erb.tt
|
|
99
99
|
- lib/perron.rb
|
|
100
100
|
- lib/perron/collection.rb
|
|
101
101
|
- lib/perron/configuration.rb
|
|
@@ -113,16 +113,20 @@ files:
|
|
|
113
113
|
- lib/perron/metatags.rb
|
|
114
114
|
- lib/perron/refinements/delete_suffixes.rb
|
|
115
115
|
- lib/perron/resource.rb
|
|
116
|
+
- lib/perron/resource/associations.rb
|
|
116
117
|
- lib/perron/resource/class_methods.rb
|
|
117
118
|
- lib/perron/resource/configuration.rb
|
|
118
119
|
- lib/perron/resource/core.rb
|
|
119
120
|
- lib/perron/resource/metadata.rb
|
|
121
|
+
- lib/perron/resource/previewable.rb
|
|
120
122
|
- lib/perron/resource/publishable.rb
|
|
123
|
+
- lib/perron/resource/reading_time.rb
|
|
121
124
|
- lib/perron/resource/related.rb
|
|
122
125
|
- lib/perron/resource/related/stop_words.rb
|
|
123
126
|
- lib/perron/resource/renderer.rb
|
|
124
127
|
- lib/perron/resource/separator.rb
|
|
125
128
|
- lib/perron/resource/slug.rb
|
|
129
|
+
- lib/perron/resource/sourceable.rb
|
|
126
130
|
- lib/perron/resource/table_of_content.rb
|
|
127
131
|
- lib/perron/root.rb
|
|
128
132
|
- lib/perron/site.rb
|
|
@@ -137,6 +141,7 @@ files:
|
|
|
137
141
|
- lib/perron/site/builder/sitemap.rb
|
|
138
142
|
- lib/perron/site/validate.rb
|
|
139
143
|
- lib/perron/tasks/build.rake
|
|
144
|
+
- lib/perron/tasks/sync_sources.rake
|
|
140
145
|
- lib/perron/tasks/validate.rake
|
|
141
146
|
- lib/perron/version.rb
|
|
142
147
|
- perron.gemspec
|
|
@@ -160,7 +165,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
160
165
|
- !ruby/object:Gem::Version
|
|
161
166
|
version: '0'
|
|
162
167
|
requirements: []
|
|
163
|
-
rubygems_version:
|
|
168
|
+
rubygems_version: 4.0.1
|
|
164
169
|
specification_version: 4
|
|
165
170
|
summary: Rails-based static site generator
|
|
166
171
|
test_files: []
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
Description:
|
|
2
|
-
Creates a new content model with specified actions
|
|
3
|
-
|
|
4
|
-
Example:
|
|
5
|
-
rails generate content post
|
|
6
|
-
|
|
7
|
-
This will create:
|
|
8
|
-
- app/models/content/post.rb
|
|
9
|
-
- app/controllers/content/posts_controller.rb
|
|
10
|
-
- app/views/content/posts/index.html.erb
|
|
11
|
-
- app/views/content/posts/show.html.erb
|
|
12
|
-
- …and add `resource :posts, module: :content, only: %w[index show]` to `config/routes.rb`
|
|
13
|
-
|
|
14
|
-
Arguments:
|
|
15
|
-
NAME: Name of the content model
|
|
16
|
-
actions: List of actions to generate (index or show)
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "rails/generators/base"
|
|
4
|
-
|
|
5
|
-
class ContentGenerator < Rails::Generators::NamedBase
|
|
6
|
-
source_root File.expand_path("templates", __dir__)
|
|
7
|
-
|
|
8
|
-
class_option :force_plural, type: :boolean, default: false, desc: "Forces the use of a plural model name and class"
|
|
9
|
-
|
|
10
|
-
argument :actions, type: :array, default: %w[index show], banner: "actions", desc: "Specify which actions to generate (index/show)"
|
|
11
|
-
|
|
12
|
-
def create_model
|
|
13
|
-
template "model.rb.tt", File.join("app/models/content", "#{file_name}.rb")
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def create_controller
|
|
17
|
-
template "controller.rb.tt", File.join("app/controllers/content", "#{plural_file_name}_controller.rb")
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def create_views
|
|
21
|
-
empty_directory view_directory
|
|
22
|
-
|
|
23
|
-
actions.each do |action|
|
|
24
|
-
template "#{action}.html.erb.tt", File.join(view_directory, "#{action}.html.erb")
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def create_content_directory = FileUtils.mkdir_p(content_directory)
|
|
29
|
-
|
|
30
|
-
def create_pages_root
|
|
31
|
-
return unless pages_controller?
|
|
32
|
-
|
|
33
|
-
template "root.erb.tt", File.join(content_directory, "root.erb")
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def add_content_route
|
|
37
|
-
route "resources :#{plural_file_name}, module: :content, only: %w[#{actions.join(" ")}]"
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def add_root_route
|
|
41
|
-
return unless pages_controller?
|
|
42
|
-
return if root_route_exists?
|
|
43
|
-
|
|
44
|
-
inject_into_file "config/routes.rb", " root to: \"content/pages#root\"\n", before: /^\s*end\s*$/
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
private
|
|
48
|
-
|
|
49
|
-
def file_name
|
|
50
|
-
options[:force_plural] ? super.pluralize : super.singularize
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def class_name
|
|
54
|
-
options[:force_plural] ? super.pluralize : super.singularize
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def view_directory = Rails.root.join("app", "views", "content", plural_file_name)
|
|
58
|
-
|
|
59
|
-
def content_directory = Rails.root.join("app", "content", plural_file_name)
|
|
60
|
-
|
|
61
|
-
def plural_class_name = plural_name.camelize
|
|
62
|
-
|
|
63
|
-
def pages_controller? = plural_file_name == "pages"
|
|
64
|
-
|
|
65
|
-
def root_route_exists?
|
|
66
|
-
routes = Rails.root.join("config", "routes.rb")
|
|
67
|
-
|
|
68
|
-
return false unless File.exist?(routes)
|
|
69
|
-
|
|
70
|
-
File.read(routes).match?(/\broot\s+to:/)
|
|
71
|
-
end
|
|
72
|
-
end
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|