robin_cms 0.1.2 → 0.1.3

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: 7496d517d8e733bc0e0876e806347a0c3670efc489d974b16718e3b015d0a8e1
4
+ data.tar.gz: cc6df56fde6950e75fddf3fef947e5363cdc67f506bd07c16f5bbe0f1940d629
5
5
  SHA512:
6
- metadata.gz: b976e15a5ca7c7af3f44f8be1ee2df5f6ddfe766ddfedafeb6c17dd41c48dc449e13d819b0dcdac17c23263a04d9c3e7e99a1f3cc5b66d97b562c7fc8d77ecfb
7
- data.tar.gz: 609ed3f32088e434615486ba25da6fdb199a074b0d628ee5e58f45b13f80151b7319437bad45538d636158e6b522701e3960663b2269db5f78ea3699e233ea07
6
+ metadata.gz: 6f4c01cfe9c26b0c7e864af98d405dfe55fbe986f78a3ee9295cb20e9c6bd808bbd9473685ddd17c158574540a63535c16a783a650a937f3e6419f215a81c51f
7
+ data.tar.gz: 36e240374f3bcf0c564d15347b4847a6b781d7b6192b3b3214fbf35f297748415d1729773039dca5f61b00ce088d681d7b3b02ac340fc3aa1f981c2fa4dd63dc
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
@@ -92,20 +95,21 @@ module RobinCMS
92
95
  end
93
96
 
94
97
  get "/libraries/:kind" do
95
- @items = Item.where(@library,
98
+ @items = @library.query(
96
99
  sort: params[:sort],
97
100
  published: params[:published],
98
- q: params[:q])
101
+ q: params[:q]
102
+ )
99
103
 
100
104
  erb :library
101
105
  end
102
106
 
103
107
  get "/libraries/:kind/item" do
104
108
  if params[:id]
105
- @item = Item.find_one(@library, params[:id])
109
+ @item = @library.find_one(params[:id])
106
110
  halt 404 unless @item
107
111
  else
108
- @item = Item.blank(@library)
112
+ @item = @library.blank
109
113
  end
110
114
 
111
115
  erb :library_item
@@ -113,32 +117,30 @@ module RobinCMS
113
117
 
114
118
  post "/libraries/:kind/item" do
115
119
  if params[:id]
116
- @item = Item.find_one(@library, params[:id])
120
+ @item = @library.find_one(params[:id])
117
121
  halt 404 unless @item
118
122
 
119
123
  if params[:image]
120
124
  filename = params[:image][:filename]
121
- tempfile = params[:image][:tempfile]
125
+ tempfile = params[:image][:tempfile].to_path
122
126
 
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
+ @library.delete_static(@item.attributes[:image_src])
128
+ static_path = @library.create_static(filename, tempfile)
129
+ params[:image_src] = "/" + static_path
127
130
  end
128
131
 
129
132
  @item.attributes = params
130
- @item.update!
133
+ @library.write(@item)
131
134
  else
132
135
  if params[:image]
133
136
  filename = params[:image][:filename]
134
- tempfile = params[:image][:tempfile]
137
+ tempfile = params[:image][:tempfile].to_path
135
138
 
136
- StaticItem.create!(@library, filename, tempfile).then do |s|
137
- params[:image_src] = "/" + s.filepath
138
- end
139
+ static_path = @library.create_static(filename, tempfile)
140
+ params[:image_src] = "/" + static_path
139
141
  end
140
142
 
141
- Item.create!(@library, params)
143
+ @library.create(params)
142
144
  end
143
145
 
144
146
  redirect to("/libraries/#{params[:kind]}")
@@ -149,14 +151,14 @@ module RobinCMS
149
151
 
150
152
  post "/libraries/:kind/item/delete" do
151
153
  if params[:id]
152
- @item = Item.find_one(@library, params[:id])
154
+ @item = @library.find_one(params[:id])
153
155
  halt 404 unless @item
154
156
 
155
157
  if @item.attributes[:image_src]
156
- StaticItem.delete_if_exists!(@library, @item.attributes[:image_src])
158
+ @library.delete_static(@item.attributes[:image_src])
157
159
  end
158
160
 
159
- @item.delete!
161
+ @library.delete(@item.id)
160
162
  else
161
163
  halt 404
162
164
  end
@@ -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,34 @@
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, dimensions)
8
+ # The mogrify command edits images in place. For more info, see
9
+ # mogrify(1).
10
+
11
+ system("mogrify -resize #{dimensions} #{filepath}")
12
+ if $?.exitstatus != 0
13
+ raise ConversionError, "Could not resize image #{filepath}"
14
+ end
15
+ end
16
+
17
+ def format_image(filepath, filetype)
18
+ system("mogrify -format #{filetype} #{filepath}")
19
+ if $?.exitstatus != 0
20
+ raise ConversionError, "Could not format image #{filepath}"
21
+ end
22
+
23
+ # The name of the converted file will be the same as the original but
24
+ # with a new file extension.
25
+ converted = filepath.sub(/#{File.extname(filepath)}$/, ".#{filetype}")
26
+
27
+ # Mogrify's format command creates a new file with the new extension.
28
+ # Copy the contents of this file to the original, then delete the newly
29
+ # created file.
30
+ IO.copy_stream(converted, filepath)
31
+ File.delete(converted)
32
+ end
33
+ end
34
+ 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,83 @@
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
+
46
+ if File.exist?(path)
47
+ raise ItemExistsError, "An item with the same name already exists"
48
+ end
49
+
50
+ image_field = @schema[:fields].find { |f| f[:type] == "image" }
51
+ dimensions = image_field&.dig(:dimensions)
52
+ filetype = image_field&.dig(:filetype)
53
+
54
+ resize_image(tempfile, dimensions) if dimensions
55
+ format_image(tempfile, filetype) if filetype
56
+
57
+ FileUtils.mkdir_p(File.dirname(path))
58
+ FileUtils.cp(tempfile, path)
59
+
60
+ path
61
+ end
62
+
63
+ def delete_static(filename)
64
+ path = File.join(@schema[:static_location], File.basename(filename))
65
+
66
+ return unless File.exist?(path)
67
+
68
+ File.delete(path)
69
+ end
70
+
71
+ def drafts_enabled?
72
+ @schema[:type].nil? || @schema[:type] == "collection"
73
+ end
74
+
75
+ def sorted_fields
76
+ @schema[:fields].sort_by { |field| field[:order] || Float::INFINITY }
77
+ end
78
+
79
+ def [](key)
80
+ @schema[key]
81
+ end
82
+ end
83
+ 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.3"
5
5
  end
@@ -11,7 +11,7 @@
11
11
  </option>
12
12
  <% end %>
13
13
  </select>
14
- <% if @config.drafts_enabled?(@library) %>
14
+ <% if @library.drafts_enabled? %>
15
15
  <select id="published" name="published">
16
16
  <% for option in PUBLISHED_OPTIONS%>
17
17
  <option
@@ -2,5 +2,5 @@
2
2
  id="<%= safe_id(field[:id], 'field') %>"
3
3
  type="hidden"
4
4
  name="<%= field[:id] %>"
5
- value="<%= @item.attributes[field[:id].to_sym] || field[:default] %>"
5
+ value="<%= @item.field_value_or_default(field) %>"
6
6
  />
@@ -3,7 +3,7 @@
3
3
  id="<%= safe_id(field[:id], 'field') %>"
4
4
  type="<%= field[:type] %>"
5
5
  name="<%= field[:id] %>"
6
- value="<%= @item.attributes[field[:id].to_sym] || field[:default] %>"
6
+ value="<%= @item.field_value_or_default(field) %>"
7
7
  <% if field[:required] %>required<% end %>
8
8
  <% if field[:readonly] %>readonly<% end %>
9
9
  />
@@ -3,8 +3,11 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
6
+ <meta name="robots" content="noindex,nofollow" />
6
7
  <title><%= @config[:title] %> | admin</title>
7
- <%= erb :style %>
8
+ <style>
9
+ <%= erb :"stylesheet.css" %>
10
+ </style>
8
11
  </head>
9
12
  <body <% if flash[:success_dialog] %>onload="successdialog.showModal()"<% end %>>
10
13
  <% if session[:auth_user] %>
@@ -14,7 +17,7 @@
14
17
  </span>
15
18
  <span class="controls --gap-md">
16
19
  <a href="<%= @config[:url] %>" target="_blank">
17
- <%= @config[:url] %><%= erb :new_tab %>
20
+ <%= @config[:url] %><%= erb :"new_tab.svg" %>
18
21
  </a>
19
22
  <% if @config[:build_command] %>
20
23
  <form id="publish-form" action="/admin/publish" method="post">
@@ -29,7 +32,7 @@
29
32
  <main id="site-content">
30
33
  <%= yield %>
31
34
  <footer id="site-footer" class="card --clear">
32
- <%= erb :logo %>
35
+ <%= erb :"logo.svg" %>
33
36
  <p>
34
37
  Made with 🧡
35
38
  <br />