robin_cms 0.1.2 → 0.1.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 398d0d377f476c68de1e02e504cbe7353347e1f93835d3cce55811d7a90e82a3
4
- data.tar.gz: 5b253a4f07db5e5282bf879695ec96ed51d28a2b8e667f1110213b45e0c63e98
3
+ metadata.gz: d3a49ee1ef9d638a300e82352bba65071dc6dfffecfb4cb409fbf8cbb8fbdd69
4
+ data.tar.gz: 928887dfc693144ebaaf9f2233a08b161faa7146ab7a037501098052d046fe74
5
5
  SHA512:
6
- metadata.gz: b976e15a5ca7c7af3f44f8be1ee2df5f6ddfe766ddfedafeb6c17dd41c48dc449e13d819b0dcdac17c23263a04d9c3e7e99a1f3cc5b66d97b562c7fc8d77ecfb
7
- data.tar.gz: 609ed3f32088e434615486ba25da6fdb199a074b0d628ee5e58f45b13f80151b7319437bad45538d636158e6b522701e3960663b2269db5f78ea3699e233ea07
6
+ metadata.gz: 17bcd5f6cc1d057c6740fb3d7cf393432cd823956fcf4e78421b085cfc1ec3ef382c40b776b5f570cfb295af6cae71fd8a0711e5accc118641509bc519947bd9
7
+ data.tar.gz: e7f2f736f7535dce94edbe15a41c24201959e1231d0827c542ec3b4560215d1285e49f1d94321a57bdca50c1cfd85015f8a11f74cbd952b967081e7f5b3291a3
data/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Robin CMS
2
2
 
3
+ > [!IMPORTANT]
4
+ > This software is currently in beta. There may be bugs and breaking changes.
5
+ > If you find a bug, I'd love to hear about it.
6
+
3
7
  ![Robin CMS logo](./assets/robin-logo.png)
4
8
 
5
9
  Robin CMS is a minimalist flat-file CMS built with Ruby and Sinatra. It is
data/lib/robin_cms/cms.rb CHANGED
@@ -2,9 +2,8 @@
2
2
 
3
3
  module RobinCMS
4
4
  class CMS < Sinatra::Base
5
- set :logging, true
6
- set :sessions, true
7
- set :session_secret, ENV.fetch("SESSION_SECRET", SecureRandom.hex(64))
5
+ include Helpers
6
+ include Auth
8
7
 
9
8
  attr_accessor :config
10
9
 
@@ -14,10 +13,9 @@ module RobinCMS
14
13
  @config = Configuration.parse(**opts)
15
14
  end
16
15
 
17
- helpers do
18
- include Helpers
19
- include Auth
20
- end
16
+ set :logging, true
17
+ set :sessions, true
18
+ set :session_secret, ENV.fetch("SESSION_SECRET", SecureRandom.hex(64))
21
19
 
22
20
  before do
23
21
  if %w[/login /logout].include?(request.path_info)
@@ -27,13 +25,18 @@ module RobinCMS
27
25
  redirect to("/login") unless session[:auth_user]
28
26
  end
29
27
 
30
- before(/\/libraries\/(.*).*/) do
28
+ before /\/libraries\/(.*).*/ do
31
29
  kind = params[:captures].first.split("/").first
30
+ library_schema = @config.find_library(kind)
32
31
 
33
- @library = @config.find_library(kind)
34
- @sorted_fields = @config.sorted_fields(kind)
32
+ halt 404 unless library_schema
35
33
 
36
- halt 404 unless @library
34
+ @library = case library_schema[:type]
35
+ when "collection"
36
+ CollectionLibrary.new(library_schema)
37
+ when "data"
38
+ DataLibrary.new(library_schema)
39
+ end
37
40
  end
38
41
 
39
42
  after do
@@ -50,7 +53,6 @@ module RobinCMS
50
53
 
51
54
  post "/login" do
52
55
  authenticate!(params[:username], params[:password])
53
-
54
56
  session[:auth_user] = params[:username]
55
57
  redirect to(home_page)
56
58
  rescue AuthenticationError
@@ -80,11 +82,9 @@ module RobinCMS
80
82
  end
81
83
 
82
84
  authenticate!(session[:auth_user], params[:old_password])
83
-
84
85
  update_credentials(params[:new_password])
85
86
  session[:auth_user] = nil
86
87
  flash[:success] = "Password updated succesfully. Please log in with your new credentials."
87
-
88
88
  redirect to("/login")
89
89
  rescue AuthenticationError
90
90
  flash[:error] = "Incorrect username or password"
@@ -92,75 +92,72 @@ module RobinCMS
92
92
  end
93
93
 
94
94
  get "/libraries/:kind" do
95
- @items = Item.where(@library,
95
+ @items = @library.query(
96
96
  sort: params[:sort],
97
97
  published: params[:published],
98
- q: params[:q])
99
-
98
+ q: params[:q]
99
+ )
100
100
  erb :library
101
101
  end
102
102
 
103
103
  get "/libraries/:kind/item" do
104
- if params[:id]
105
- @item = Item.find_one(@library, params[:id])
106
- halt 404 unless @item
107
- else
108
- @item = Item.blank(@library)
109
- end
104
+ @item = @library.blank
105
+ erb :library_item
106
+ end
110
107
 
108
+ get "/libraries/:kind/item/:id" do
109
+ @item = @library.find_one(params[:id])
110
+ halt 404 unless @item
111
111
  erb :library_item
112
112
  end
113
113
 
114
114
  post "/libraries/:kind/item" do
115
- if params[:id]
116
- @item = Item.find_one(@library, params[:id])
117
- halt 404 unless @item
118
-
119
- if params[:image]
120
- filename = params[:image][:filename]
121
- tempfile = params[:image][:tempfile]
122
-
123
- StaticItem.delete_if_exists!(@library, @item.attributes[:image_src])
124
- StaticItem.create!(@library, filename, tempfile).then do |s|
125
- params[:image_src] = "/" + s.filepath
126
- end
127
- end
128
-
129
- @item.attributes = params
130
- @item.update!
131
- else
132
- if params[:image]
133
- filename = params[:image][:filename]
134
- tempfile = params[:image][:tempfile]
135
-
136
- StaticItem.create!(@library, filename, tempfile).then do |s|
137
- params[:image_src] = "/" + s.filepath
138
- end
139
- end
140
-
141
- Item.create!(@library, params)
115
+ if params[:image]
116
+ static_path = @library.create_static(
117
+ params[:image][:filename],
118
+ params[:image][:tempfile].to_path
119
+ )
120
+ params[:image_src] = "/" + static_path
142
121
  end
143
122
 
123
+ @library.create(params)
144
124
  redirect to("/libraries/#{params[:kind]}")
145
125
  rescue ItemExistsError
146
126
  flash[:error] = "An item with the same name already exists"
147
127
  redirect to("/libraries/#{params[:kind]}/item")
148
128
  end
149
129
 
150
- post "/libraries/:kind/item/delete" do
151
- if params[:id]
152
- @item = Item.find_one(@library, params[:id])
153
- halt 404 unless @item
130
+ post "/libraries/:kind/item/:id" do
131
+ @item = @library.find_one(params[:id])
132
+ halt 404 unless @item
154
133
 
155
- if @item.attributes[:image_src]
156
- StaticItem.delete_if_exists!(@library, @item.attributes[:image_src])
134
+ if params[:image]
135
+ # If there is an existing image for this item, delete it to prevent
136
+ # accumulation of old files.
137
+ if @item.attributes[:image_src] && !@item.attributes[:image_src].empty?
138
+ @library.delete_static(@item.attributes[:image_src])
157
139
  end
140
+ static_path = @library.create_static(
141
+ params[:image][:filename],
142
+ params[:image][:tempfile].to_path
143
+ )
144
+ params[:image_src] = "/" + static_path
145
+ end
158
146
 
159
- @item.delete!
160
- else
161
- halt 404
147
+ @item.attributes = params
148
+ @library.write(@item)
149
+ redirect to("/libraries/#{params[:kind]}")
150
+ end
151
+
152
+ post "/libraries/:kind/item/:id/delete" do
153
+ @item = @library.find_one(params[:id])
154
+ halt 404 unless @item
155
+
156
+ if @item.attributes[:image_src]
157
+ @library.delete_static(@item.attributes[:image_src])
162
158
  end
163
159
 
160
+ @library.delete(@item.id)
164
161
  redirect to("/libraries/#{params[:kind]}")
165
162
  end
166
163
 
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobinCMS
4
+ class CollectionLibrary < Library
5
+ def create(attributes)
6
+ id = make_slug(attributes[:title], @schema[:pattern])
7
+ item = Item.new(id, attributes, **@schema)
8
+
9
+ if File.exist?(filepath(id))
10
+ raise ItemExistsError, "An item with the same name already exists"
11
+ end
12
+
13
+ write(item)
14
+ end
15
+
16
+ def find_one(id)
17
+ path = filepath(id)
18
+
19
+ return unless File.exist?(path)
20
+
21
+ deserialize(path)
22
+ end
23
+
24
+ def all
25
+ Dir.glob(filepath("*"))
26
+ .filter { |f| File.file?(f) }
27
+ .map { |path| deserialize(path) }
28
+ end
29
+
30
+ def write(item)
31
+ path = filepath(item.id)
32
+
33
+ FileUtils.mkdir_p(File.dirname(path))
34
+ FileUtils.touch(path)
35
+
36
+ item.update_timestamps!
37
+
38
+ serialized = YAML.dump(item.frontmatter, stringify_names: true)
39
+ if item.attributes[:content]
40
+ serialized << "---\n"
41
+ serialized << item.attributes[:content]
42
+ end
43
+ File.write(path, serialized)
44
+
45
+ path
46
+ end
47
+
48
+ def delete(id)
49
+ File.delete(filepath(id))
50
+ end
51
+
52
+ private
53
+
54
+ def filepath(id)
55
+ File.join(@schema[:location], "#{id}.#{@schema[:filetype]}")
56
+ end
57
+
58
+ def deserialize(path)
59
+ raw = File.read(path)
60
+ id = File.basename(path, File.extname(path))
61
+
62
+ _, frontmatter, content = raw.split("---")
63
+
64
+ attributes = YAML.load(frontmatter, symbolize_names: true)
65
+ attributes[:content] = content.strip
66
+
67
+ Item.new(id, attributes, **@schema)
68
+ end
69
+ end
70
+ end
@@ -50,6 +50,7 @@
50
50
  "can_create": { "type": "boolean" },
51
51
  "can_delete": { "type": "boolean" },
52
52
  "pattern": { "type": "string" },
53
+ "display_name_pattern": { "type": "string" },
53
54
  "fields": {
54
55
  "type": "array",
55
56
  "items": {
@@ -89,6 +90,7 @@
89
90
  "can_create": { "type": "boolean" },
90
91
  "can_delete": { "type": "boolean" },
91
92
  "pattern": { "type": "string" },
93
+ "display_name_pattern": { "type": "string" },
92
94
  "fields": {
93
95
  "type": "array",
94
96
  "items": {
@@ -102,27 +102,13 @@ module RobinCMS
102
102
  end
103
103
 
104
104
  def find_library(kind)
105
- self[:libraries].find { |c| c[:id] == kind }
106
- end
107
-
108
- def sorted_fields(kind)
109
- find_library(kind)
110
- &.dig(:fields)
111
- &.sort_by { |field| field[:order] || Float::INFINITY }
112
- end
113
-
114
- def drafts_enabled?(library)
115
- collection?(library)
105
+ self[:libraries].find { |library| library[:id] == kind }
116
106
  end
117
107
 
118
108
  def collection?(library)
119
109
  library[:type].nil? || library[:type] == "collection"
120
110
  end
121
111
 
122
- def data?(library)
123
- !collection?(library)
124
- end
125
-
126
112
  def has_image?(library)
127
113
  library[:fields].find { |f| f[:type] == "image" }
128
114
  end
@@ -148,7 +134,7 @@ module RobinCMS
148
134
  config[:libraries].each do |library|
149
135
  library[:fields].append(*AUTOMATIC_FIELDS)
150
136
 
151
- if drafts_enabled?(library)
137
+ if collection?(library)
152
138
  library[:fields].append(*AUTOMATIC_DRAFT_FIELDS)
153
139
  end
154
140
 
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobinCMS
4
+ class DataLibrary < Library
5
+ def create(attributes)
6
+ item = Item.new(nil, attributes, **@schema)
7
+ write(item)
8
+ end
9
+
10
+ def find_one(id)
11
+ all[id.to_i]
12
+ end
13
+
14
+ def all
15
+ return [] unless File.exist?(filepath)
16
+
17
+ items = YAML.load_file(filepath, symbolize_names: true)
18
+
19
+ return [] unless items
20
+
21
+ items.each_with_index.map do |attrs, i|
22
+ Item.new(i, attrs, **@schema)
23
+ end
24
+ end
25
+
26
+ def write(item)
27
+ FileUtils.mkdir_p(File.dirname(filepath))
28
+ FileUtils.touch(filepath)
29
+
30
+ item.update_timestamps!
31
+
32
+ all_items = YAML.load_file(filepath) || []
33
+ if item.id.nil?
34
+ all_items.append(item.frontmatter)
35
+ else
36
+ all_items[item.id.to_i] = item.frontmatter
37
+ end
38
+
39
+ serialized = YAML.dump(all_items, stringify_names: true)
40
+ File.write(filepath, serialized)
41
+
42
+ filepath
43
+ end
44
+
45
+ def delete(id)
46
+ all_items = YAML.load_file(filepath)
47
+ all_items[id.to_i] = nil
48
+ serialized = YAML.dump(all_items.compact, stringify_names: true)
49
+ File.write(filepath, serialized)
50
+ end
51
+
52
+ private
53
+
54
+ def filepath
55
+ File.join(@schema[:location], "#{@schema[:id]}.#{@schema[:filetype]}")
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobinCMS
4
+ # This module provides methods for editing image files.
5
+ # No methods are required in the base class in order to use this mixin.
6
+ module Editable
7
+ def resize_image(filepath, to:)
8
+ # The mogrify command edits images in place. For more info, see
9
+ # mogrify(1).
10
+ system("mogrify -resize #{to} #{filepath}")
11
+ if $?.exitstatus != 0
12
+ raise ConversionError, "Could not resize image #{filepath}"
13
+ end
14
+ end
15
+
16
+ def convert_image(filepath, to:)
17
+ system("mogrify -format #{to} #{filepath}")
18
+ if $?.exitstatus != 0
19
+ raise ConversionError, "Could not format image #{filepath}"
20
+ end
21
+
22
+ # The file created by the mogrify command will be the same as the
23
+ # original but with the new file extension.
24
+ converted = filepath.sub(/#{File.extname(filepath)}$/, ".#{to}")
25
+
26
+ if converted == filepath
27
+ # If the converted file has the same filename as the original (i.e. no
28
+ # conversion happened), there is nothing more to do.
29
+ filepath
30
+ else
31
+ # Otherwise, copy the contents of the mogrify output file to a new
32
+ # tempfile and delete the mogrify output.
33
+ tempfile = Tempfile.new(["robin_cms" ".#{to}"])
34
+ IO.copy_stream(converted, tempfile)
35
+ File.delete(converted)
36
+ tempfile
37
+ end
38
+ end
39
+ end
40
+ end
@@ -1,34 +1,104 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RobinCMS
4
- # This is a wrapper class for providing a uniform interface to both the
5
- # DataItem class and the CollectionItem class. It's sole job is to dispatch
6
- # methods to the appropriate class based on the settings of the library.
7
4
  class Item
8
- METHOD_NAMES = %i[all where create! find_one blank]
5
+ attr_reader :id, :attributes
9
6
 
10
- def self.method_missing(method_name, *args, **kwargs)
11
- unless METHOD_NAMES.include?(method_name)
12
- super
13
- return
7
+ DATETIME_FORMAT = "%Y-%m-%d"
8
+
9
+ # The keys which we don't want to serialize.
10
+ FRONTMATTER_IGNORE_KEYS = [:id, :content, :image, :captures].freeze
11
+
12
+ def initialize(id, attrs = {}, **opts)
13
+ if !attrs.empty? && !attrs.has_key?(:title)
14
+ raise TypeError, "Missing required field `title'"
15
+ end
16
+
17
+ @id = id
18
+ @opts = opts
19
+
20
+ # Be sure to use the setter here so the keys get converted to symbols.
21
+ self.attributes = attrs
22
+ end
23
+
24
+ def attributes=(attributes)
25
+ @attributes = attributes.to_h.transform_keys(&:to_sym)
26
+
27
+ if attributes.has_key?(:published)
28
+ @attributes[:published] = attributes[:published].to_s == "true"
29
+ end
30
+ end
31
+
32
+ def inspect
33
+ "<#{self.class} id=\"#{id}\">"
34
+ end
35
+
36
+ def published?
37
+ if @attributes.has_key?(:published)
38
+ @attributes[:published]
39
+ else
40
+ true
41
+ end
42
+ end
43
+
44
+ def published_label
45
+ if published?
46
+ "Published"
47
+ else
48
+ "Draft"
14
49
  end
50
+ end
51
+
52
+ def created_at
53
+ return unless @attributes[:created_at]
54
+
55
+ Time.parse(@attributes[:created_at])
56
+ end
15
57
 
16
- # All the class methods for DataItem and CollectionItem should have
17
- # the library as the first argument.
18
- library = args.first
58
+ def updated_at
59
+ return unless @attributes[:updated_at]
19
60
 
20
- case library[:type]
21
- when "data"
22
- DataItem.send(method_name, *args, **kwargs)
23
- when "collection"
24
- CollectionItem.send(method_name, *args, **kwargs)
61
+ Time.parse(@attributes[:updated_at])
62
+ end
63
+
64
+ def update_timestamps!
65
+ timestamp = Time.now.strftime(DATETIME_FORMAT)
66
+
67
+ if !@attributes.has_key?(:created_at) || @attributes[:created_at].empty?
68
+ @attributes[:created_at] = timestamp
69
+ end
70
+ @attributes[:updated_at] = timestamp
71
+ end
72
+
73
+ def display_name
74
+ return @attributes[:title] unless @opts[:display_name_pattern]
75
+
76
+ @opts[:display_name_pattern].clone.tap do |name|
77
+ @attributes.each { |key, value| name.gsub!(":#{key}", value) }
78
+ end
79
+ end
80
+
81
+ # Get the frontmatter as a hash with stringified keys.
82
+ def frontmatter
83
+ frontmatter = @attributes.clone
84
+ FRONTMATTER_IGNORE_KEYS.each { |key| frontmatter.delete(key) }
85
+
86
+ if published?
87
+ frontmatter.delete(:published)
25
88
  else
26
- super
89
+ frontmatter[:published] = false
27
90
  end
91
+
92
+ # The Psych module (for which YAML is an alias) has a
93
+ # stringify_names option which does exactly this. However it was
94
+ # only introduced in Ruby 3.4. Using transform_keys is a workaround
95
+ # to support earlier versions of Ruby.
96
+ frontmatter.to_h.transform_keys(&:to_s)
28
97
  end
29
98
 
30
- def self.respond_to_missing?(method_name)
31
- METHOD_NAMES.include?(method_name)
99
+ def field_value_or_default(field)
100
+ value = @attributes[field[:id].to_sym] || field[:default]
101
+ value.to_s
32
102
  end
33
103
  end
34
104
  end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobinCMS
4
+ class Library
5
+ include Editable
6
+ include Sluggable
7
+ include Queryable
8
+
9
+ attr_reader :schema
10
+
11
+ def initialize(schema)
12
+ @schema = schema.freeze
13
+ end
14
+
15
+ def blank
16
+ Item.new(nil, **@schema)
17
+ end
18
+
19
+ def create(attributes)
20
+ raise NotImplementedError
21
+ end
22
+
23
+ def find_one(id)
24
+ raise NotImplementedError
25
+ end
26
+
27
+ def all
28
+ raise NotImplementedError
29
+ end
30
+
31
+ # Writes the specified item to disk.
32
+ #
33
+ # Returns the path where the item was written.
34
+ def write(item)
35
+ raise NotImplementedError
36
+ end
37
+
38
+ # Delete the item specified by +id+.
39
+ def delete(id)
40
+ raise NotImplementedError
41
+ end
42
+
43
+ def create_static(filename, tempfile)
44
+ path = File.join(@schema[:static_location], File.basename(make_slug(filename)))
45
+ FileUtils.mkdir_p(File.dirname(path))
46
+
47
+ if File.exist?(path)
48
+ raise ItemExistsError, "An item with the same name already exists"
49
+ end
50
+
51
+ image_field = @schema[:fields].find { |f| f[:type] == "image" }
52
+ dimensions = image_field&.dig(:dimensions)
53
+ filetype = image_field&.dig(:filetype)
54
+
55
+ resize_image(tempfile, to: dimensions) if dimensions
56
+
57
+ # Only convert if the specified filetype is different to the actual
58
+ # filetype. Unlike resize_image which modifies the image in-pace, this
59
+ # function necessarily creates a new file, so we need to ensure we
60
+ # update the path with the new file extension.
61
+ if filetype && File.extname(tempfile) != ".#{filetype}"
62
+ tempfile = convert_image(tempfile, to: filetype)
63
+ path = File.join(File.dirname(path), "#{File.basename(path, '.*')}.#{filetype}")
64
+ end
65
+
66
+ FileUtils.cp(tempfile, path)
67
+ path
68
+ end
69
+
70
+ def delete_static(filename)
71
+ return if filename.nil? || filename.empty?
72
+ path = File.join(@schema[:static_location], File.basename(filename))
73
+ return unless File.exist?(path)
74
+
75
+ File.delete(path)
76
+ end
77
+
78
+ def drafts_enabled?
79
+ @schema[:type].nil? || @schema[:type] == "collection"
80
+ end
81
+
82
+ def sorted_fields
83
+ @schema[:fields].sort_by { |field| field[:order] || Float::INFINITY }
84
+ end
85
+
86
+ def [](key)
87
+ @schema[key]
88
+ end
89
+
90
+ def kind
91
+ @schema[:id]
92
+ end
93
+ end
94
+ end
@@ -1,8 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RobinCMS
4
+ # This module provides methods for querying items.
5
+ #
6
+ # In order to use this as a mixin, you only need to implement an +all+
7
+ # method in the base class. This method should take no parameters, and
8
+ # return all items.
4
9
  module Queryable
5
- def where(library, **kwargs)
10
+ def query(**kwargs)
6
11
  by_published = lambda do |i|
7
12
  case kwargs[:published]
8
13
  when nil, ""
@@ -34,14 +39,11 @@ module RobinCMS
34
39
  end * sort_direction
35
40
  end
36
41
 
37
- all(library)
38
- .filter(&by_search)
39
- .filter(&by_published)
40
- .sort(&by_field)
42
+ all.filter(&by_search).filter(&by_published).sort(&by_field)
41
43
  end
42
44
 
43
- def count(library)
44
- all(library).size
45
+ def count
46
+ all.size
45
47
  end
46
48
  end
47
49
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RobinCMS
4
- VERSION = "0.1.2"
4
+ VERSION = "0.1.4"
5
5
  end