robin_cms 0.1.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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +183 -0
  4. data/lib/jekyll/commands/serve.rb +35 -0
  5. data/lib/robin_cms/auth.rb +39 -0
  6. data/lib/robin_cms/cms.rb +200 -0
  7. data/lib/robin_cms/collection_item.rb +82 -0
  8. data/lib/robin_cms/configuration-schema.json +116 -0
  9. data/lib/robin_cms/configuration.rb +181 -0
  10. data/lib/robin_cms/data_item.rb +88 -0
  11. data/lib/robin_cms/flash.rb +24 -0
  12. data/lib/robin_cms/helpers.rb +60 -0
  13. data/lib/robin_cms/item.rb +34 -0
  14. data/lib/robin_cms/itemable.rb +124 -0
  15. data/lib/robin_cms/queryable.rb +47 -0
  16. data/lib/robin_cms/sluggable.rb +22 -0
  17. data/lib/robin_cms/static_item.rb +92 -0
  18. data/lib/robin_cms/version.rb +5 -0
  19. data/lib/robin_cms/views/change_password.erb +22 -0
  20. data/lib/robin_cms/views/delete_dialog.erb +25 -0
  21. data/lib/robin_cms/views/error.erb +1 -0
  22. data/lib/robin_cms/views/filter_form.erb +37 -0
  23. data/lib/robin_cms/views/flash.erb +6 -0
  24. data/lib/robin_cms/views/hidden_field.erb +6 -0
  25. data/lib/robin_cms/views/image_field.erb +20 -0
  26. data/lib/robin_cms/views/input_field.erb +9 -0
  27. data/lib/robin_cms/views/layout.erb +59 -0
  28. data/lib/robin_cms/views/library.erb +70 -0
  29. data/lib/robin_cms/views/library_actions.erb +4 -0
  30. data/lib/robin_cms/views/library_item.erb +44 -0
  31. data/lib/robin_cms/views/login.erb +17 -0
  32. data/lib/robin_cms/views/logo.erb +82 -0
  33. data/lib/robin_cms/views/nav.erb +11 -0
  34. data/lib/robin_cms/views/new_tab.erb +4 -0
  35. data/lib/robin_cms/views/profile.erb +12 -0
  36. data/lib/robin_cms/views/richtext_field.erb +10 -0
  37. data/lib/robin_cms/views/select_field.erb +23 -0
  38. data/lib/robin_cms/views/style.erb +441 -0
  39. data/lib/robin_cms.rb +45 -0
  40. metadata +139 -0
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobinCMS
4
+ class Configuration < Hash
5
+ DEFAULTS = {
6
+ title: "Robin CMS",
7
+ build_command: nil,
8
+ accent_color: "#fd8a13"
9
+ }.freeze
10
+
11
+ COLLECTION_DEFAULTS = {
12
+ type: "collection",
13
+ location: ".",
14
+ static_location: "assets",
15
+ filetype: "html",
16
+ description: "",
17
+ can_create: true,
18
+ can_delete: true,
19
+ fields: []
20
+ }.freeze
21
+
22
+ DATA_DEFAULTS = {
23
+ type: "data",
24
+ location: ".",
25
+ static_location: "assets",
26
+ filetype: "yml",
27
+ description: "",
28
+ can_create: true,
29
+ can_delete: true,
30
+ fields: []
31
+ }.freeze
32
+
33
+ FIELD_DEFAULTS = {
34
+ type: "text",
35
+ default: nil,
36
+ required: false,
37
+ readonly: false
38
+ }.freeze
39
+
40
+ AUTOMATIC_FIELDS = [{
41
+ label: "Title",
42
+ id: "title",
43
+ type: "text",
44
+ required: true,
45
+ order: 1
46
+ }, {
47
+ label: "Published date",
48
+ id: "created_at",
49
+ type: "hidden"
50
+ }, {
51
+ label: "Last edited",
52
+ id: "updated_at",
53
+ type: "hidden"
54
+ }].freeze
55
+
56
+ AUTOMATIC_DRAFT_FIELDS = [{
57
+ label: "Published",
58
+ id: "published",
59
+ type: "select",
60
+ default: false,
61
+ options: [{
62
+ label: "Draft",
63
+ value: false
64
+ }, {
65
+ label: "Published",
66
+ value: true
67
+ }],
68
+ order: 2
69
+ }].freeze
70
+
71
+ AUTOMATIC_IMAGE_FIELDS = [{
72
+ id: "image_src",
73
+ type: "hidden"
74
+ }, {
75
+ id: "image_alt",
76
+ type: "text",
77
+ label: "Alt text"
78
+ }].freeze
79
+
80
+ def self.parse(config_file: "_cms.yml", jekyll_plugin: false)
81
+ data = if jekyll_plugin
82
+ YAML.load_file("_config.yml", symbolize_names: true).then do |yaml|
83
+ # If running as a Jekyll plugin, get the url and title from the
84
+ # Jekyll config, and get everything else from the 'cms' key.
85
+ {url: yaml[:url], title: yaml[:title], **yaml[:cms]}
86
+ end
87
+ else
88
+ YAML.load_file(config_file, symbolize_names: true)
89
+ end
90
+
91
+ schema_file = File.join(__dir__, "configuration-schema.json")
92
+ schema = JSON.parse(File.read(schema_file))
93
+ JSON::Validator.validate!(schema, data)
94
+
95
+ config = from_h(DEFAULTS).merge_config(data).merge_automatic_fields
96
+ config.validate!
97
+ config.freeze
98
+ end
99
+
100
+ def self.from_h(hash)
101
+ hash.each_with_object(new) { |(k, v), h| h[k] = v }
102
+ end
103
+
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)
116
+ end
117
+
118
+ def collection?(library)
119
+ library[:type].nil? || library[:type] == "collection"
120
+ end
121
+
122
+ def data?(library)
123
+ !collection?(library)
124
+ end
125
+
126
+ def has_image?(library)
127
+ library[:fields].find { |f| f[:type] == "image" }
128
+ end
129
+
130
+ def merge_config(config)
131
+ merge(config, {
132
+ libraries: config[:libraries].map do |library|
133
+ library_defaults =
134
+ collection?(library) ? COLLECTION_DEFAULTS : DATA_DEFAULTS
135
+
136
+ library_defaults.merge(library, {
137
+ fields: library[:fields].map do |field|
138
+ FIELD_DEFAULTS.merge(field)
139
+ end
140
+ })
141
+ end
142
+ })
143
+ end
144
+
145
+ def merge_automatic_fields
146
+ config = clone
147
+
148
+ config[:libraries].each do |library|
149
+ library[:fields].append(*AUTOMATIC_FIELDS)
150
+
151
+ if drafts_enabled?(library)
152
+ library[:fields].append(*AUTOMATIC_DRAFT_FIELDS)
153
+ end
154
+
155
+ if has_image?(library)
156
+ library[:fields].append(*AUTOMATIC_IMAGE_FIELDS)
157
+ end
158
+
159
+ library[:fields].uniq! { |f| f[:id] }
160
+ end
161
+
162
+ config
163
+ end
164
+
165
+ # This method provides custom validation which can't be expressed (or is
166
+ # too difficult to express) using the Json Schema standard.
167
+ def validate!
168
+ self[:libraries].each do |library|
169
+ next if library[:fields].nil?
170
+
171
+ if library[:fields].count { |f| f[:type] == "richtext" } > 1
172
+ raise ValidationError, "Only one richtext field per library allowed"
173
+ end
174
+
175
+ if library[:fields].count { |f| f[:type] == "image" } > 1
176
+ raise ValidationError, "Only one image field per library allowed"
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobinCMS
4
+ class DataItem
5
+ include Itemable
6
+ extend Queryable
7
+
8
+ def self.blank(library)
9
+ new(nil, library)
10
+ end
11
+
12
+ def self.create!(library, attributes)
13
+ new(nil, library, attributes).tap do |item|
14
+ item.save!
15
+ end
16
+ end
17
+
18
+ def self.find_one(library, id)
19
+ all(library)[id.to_i]
20
+ end
21
+
22
+ def self.all(library)
23
+ location = library[:location]
24
+ name = library[:id]
25
+ ext = library[:filetype]
26
+ path = File.join(location, "#{name}.#{ext}")
27
+
28
+ return [] unless File.exist?(path)
29
+
30
+ deserialize(library, path)
31
+ end
32
+
33
+ def self.deserialize(library, path)
34
+ raw = File.read(path)
35
+ items = YAML.load(raw, symbolize_names: true)
36
+
37
+ return [] unless items
38
+
39
+ items.each_with_index.map do |attrs, i|
40
+ new(i, library, attrs)
41
+ end
42
+ end
43
+
44
+ private_class_method :deserialize
45
+
46
+ def initialize(id, library, attrs = {})
47
+ super
48
+
49
+ @mark_for_deletion = false
50
+ end
51
+
52
+ def filepath
53
+ location = @library[:location]
54
+ name = @library[:id]
55
+ ext = @library[:filetype]
56
+ File.join(location, "#{name}.#{ext}")
57
+ end
58
+
59
+ def delete!
60
+ @mark_for_deletion = true
61
+ save!
62
+ end
63
+
64
+ private
65
+
66
+ def serialize
67
+ # See comment in corresponding method in lib/collection_item.rb.
68
+ fm = frontmatter.to_h.transform_keys(&:to_s)
69
+
70
+ items = if File.exist?(filepath)
71
+ raw = File.read(filepath)
72
+ YAML.load(raw)
73
+ else
74
+ []
75
+ end
76
+
77
+ if @mark_for_deletion
78
+ items[@id.to_i] = nil
79
+ elsif @id.nil?
80
+ items.append(fm)
81
+ else
82
+ items[@id.to_i] = fm
83
+ end
84
+
85
+ YAML.dump(items.compact, stringify_names: true)
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobinCMS
4
+ class Flash
5
+ attr_reader :now, :next
6
+
7
+ def initialize(hash)
8
+ @now = hash || {}
9
+ @next = {}
10
+ end
11
+
12
+ def []=(key, value)
13
+ @next[key] = value
14
+ end
15
+
16
+ def method_missing(method_name, *args)
17
+ @now.send(method_name, *args)
18
+ end
19
+
20
+ def respond_to_missing?(method_name)
21
+ true
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobinCMS
4
+ module Helpers
5
+ SORT_OPTIONS = [{
6
+ label: "Sort by name (a-z)",
7
+ value: "id"
8
+ }, {
9
+ label: "Sort by name (z-a)",
10
+ value: "-id"
11
+ }, {
12
+ label: "Sort by created date (newest - oldest)",
13
+ value: "created_at"
14
+ }, {
15
+ label: "Sort by created date (oldest - newest)",
16
+ value: "-created_at"
17
+ }, {
18
+ label: "Sort by updated date (newest - oldest)",
19
+ value: "updated_at"
20
+ }, {
21
+ label: "Sort by updated date (oldest - newest)",
22
+ value: "-updated_at"
23
+ }].freeze
24
+
25
+ PUBLISHED_OPTIONS = [{
26
+ label: "Any status",
27
+ value: ""
28
+ }, {
29
+ label: "Draft",
30
+ value: "false"
31
+ }, {
32
+ label: "Published",
33
+ value: "true"
34
+ }].freeze
35
+
36
+ def query_params
37
+ URI.decode_www_form(request.query_string).to_h
38
+ end
39
+
40
+ def current_page
41
+ _, libraries, item = request.path_info.split("/")
42
+ item || libraries
43
+ end
44
+
45
+ def home_page
46
+ "/libraries/#{@config[:libraries].first[:id]}"
47
+ end
48
+
49
+ # For generating safe id's where the id needs to be based on user
50
+ # input. Prevents potential id collisions.
51
+ def safe_id(id, prefix)
52
+ id = id.to_s.tr("-", "_").gsub(/\W+/, "")
53
+ "__#{prefix}_#{id}"
54
+ end
55
+
56
+ def flash
57
+ @flash ||= Flash.new(session ? session[:flash] : {})
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
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
+ class Item
8
+ METHOD_NAMES = %i[all where create! find_one blank]
9
+
10
+ def self.method_missing(method_name, *args, **kwargs)
11
+ unless METHOD_NAMES.include?(method_name)
12
+ super
13
+ return
14
+ end
15
+
16
+ # All the class methods for DataItem and CollectionItem should have
17
+ # the library as the first argument.
18
+ library = args.first
19
+
20
+ case library[:type]
21
+ when "data"
22
+ DataItem.send(method_name, *args, **kwargs)
23
+ when "collection"
24
+ CollectionItem.send(method_name, *args, **kwargs)
25
+ else
26
+ super
27
+ end
28
+ end
29
+
30
+ def self.respond_to_missing?(method_name)
31
+ METHOD_NAMES.include?(method_name)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobinCMS
4
+ module Itemable
5
+ attr_reader :id, :library, :attributes
6
+
7
+ DATETIME_FORMAT = "%Y-%m-%d"
8
+
9
+ # The keys which we don't want to serialize.
10
+ SERIALIZE_IGNORE_KEYS = [:id, :kind, :content, :image, :captures].freeze
11
+
12
+ def initialize(id, library, attrs = {})
13
+ [:id, :location, :filetype].each do |key|
14
+ unless library.has_key?(key)
15
+ raise TypeError, "Missing required field #{key}"
16
+ end
17
+ end
18
+
19
+ if !attrs.empty? && !attrs.has_key?(:title)
20
+ raise TypeError, "Missing required field `title'"
21
+ end
22
+
23
+ @id = id
24
+ @library = library
25
+
26
+ # Be sure to use the setter here so the keys get converted to symbols.
27
+ self.attributes = attrs
28
+ end
29
+
30
+ def attributes=(attributes)
31
+ @attributes = attributes.to_h.transform_keys(&:to_sym)
32
+
33
+ if attributes.has_key?(:published)
34
+ @attributes[:published] = attributes[:published].to_s == "true"
35
+ end
36
+ end
37
+
38
+ def kind
39
+ @library[:id]
40
+ end
41
+
42
+ def inspect
43
+ "<#{self.class} id=\"#{id}\" kind=\"#{kind}\">"
44
+ end
45
+
46
+ def published?
47
+ if @attributes.has_key?(:published)
48
+ @attributes[:published]
49
+ else
50
+ true
51
+ end
52
+ end
53
+
54
+ def published_label
55
+ if published?
56
+ "Published"
57
+ else
58
+ "Draft"
59
+ end
60
+ end
61
+
62
+ def created_at
63
+ if @attributes[:created_at]
64
+ Time.parse(@attributes[:created_at])
65
+ else
66
+ File.birthtime(filepath)
67
+ end
68
+ end
69
+
70
+ def updated_at
71
+ if @attributes[:updated_at]
72
+ Time.parse(@attributes[:updated_at])
73
+ else
74
+ File.mtime(filepath)
75
+ end
76
+ end
77
+
78
+ def display_name
79
+ return @attributes[:title] unless @library[:display_name_pattern]
80
+
81
+ @library[:display_name_pattern].clone.tap do |name|
82
+ @attributes.each { |key, value| name.gsub!(":#{key}", value) }
83
+ end
84
+ end
85
+
86
+ def save!
87
+ timestamp = Time.now.strftime(DATETIME_FORMAT)
88
+ @attributes[:created_at] = timestamp
89
+ @attributes[:updated_at] = timestamp
90
+
91
+ FileUtils.mkdir_p(File.dirname(filepath))
92
+ File.write(filepath, serialize)
93
+ end
94
+
95
+ def update!
96
+ timestamp = Time.now.strftime(DATETIME_FORMAT)
97
+ @attributes[:updated_at] = timestamp
98
+
99
+ if !@attributes.has_key?(:created_at) || @attributes[:created_at].empty?
100
+ @attributes[:created_at] = timestamp
101
+ end
102
+
103
+ FileUtils.mkdir_p(File.dirname(filepath))
104
+ File.write(filepath, serialize)
105
+ end
106
+
107
+ def delete!
108
+ File.delete(filepath)
109
+ end
110
+
111
+ def frontmatter
112
+ frontmatter = @attributes.clone
113
+ SERIALIZE_IGNORE_KEYS.each { |key| frontmatter.delete(key) }
114
+
115
+ if published?
116
+ frontmatter.delete(:published)
117
+ else
118
+ frontmatter[:published] = false
119
+ end
120
+
121
+ frontmatter
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobinCMS
4
+ module Queryable
5
+ def where(library, **kwargs)
6
+ by_published = lambda do |i|
7
+ case kwargs[:published]
8
+ when nil, ""
9
+ true
10
+ when "true", true
11
+ i.published?
12
+ when "false", false
13
+ !i.published?
14
+ end
15
+ end
16
+
17
+ by_search = lambda do |i|
18
+ return true if kwargs[:q].nil?
19
+
20
+ i.attributes[:title].match?(/#{kwargs[:q]}/i)
21
+ end
22
+
23
+ by_field = lambda do |a, b|
24
+ return 0 if kwargs[:sort].nil?
25
+
26
+ sort_by = kwargs[:sort].sub("-", "").to_sym
27
+ sort_direction = kwargs[:sort].start_with?("-") ? -1 : 1
28
+
29
+ case sort_by
30
+ when :id
31
+ a.id <=> b.id
32
+ when :created_at, :updated_at
33
+ b.attributes[sort_by] <=> a.attributes[sort_by]
34
+ end * sort_direction
35
+ end
36
+
37
+ all(library)
38
+ .filter(&by_search)
39
+ .filter(&by_published)
40
+ .sort(&by_field)
41
+ end
42
+
43
+ def count(library)
44
+ all(library).size
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobinCMS
4
+ module Sluggable
5
+ def make_slug(str, pattern = nil)
6
+ title = str.gsub(/\s/, "-").gsub(/[^\.\w-]/, "").downcase
7
+
8
+ return title unless pattern
9
+
10
+ now = Time.now
11
+
12
+ # If the pattern starts with a colon and is without quotes in the yaml
13
+ # file, Ruby will interpret it as a symbol when it parses the file.
14
+ # Convert it to a string to be safe.
15
+ pattern.to_s
16
+ .gsub(":year", now.strftime("%Y"))
17
+ .gsub(":month", now.strftime("%m"))
18
+ .gsub(":day", now.strftime("%d"))
19
+ .gsub(":title", title)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobinCMS
4
+ class StaticItem
5
+ attr_reader :library
6
+
7
+ def initialize(library, filename, tempfile = nil)
8
+ @library = library
9
+ @filename = filename
10
+ @tempfile = tempfile
11
+ end
12
+
13
+ def save!
14
+ if File.exist?(filepath)
15
+ raise ItemExistsError, "An item with the same name already exists"
16
+ end
17
+
18
+ image_field = @library[:fields].find { |f| f[:type] == "image" }
19
+ dimensions = image_field && image_field[:dimensions]
20
+ filetype = image_field && image_field[:filetype]
21
+
22
+ resize_image!(dimensions) if dimensions
23
+ format_image!(filetype) if filetype
24
+
25
+ FileUtils.mkdir_p(File.dirname(filepath))
26
+ FileUtils.cp(@tempfile, filepath)
27
+ end
28
+
29
+ def delete!
30
+ File.delete(filepath)
31
+ end
32
+
33
+ def filepath
34
+ File.join(@library[:static_location], File.basename(@filename))
35
+ end
36
+
37
+ class << self
38
+ include Sluggable
39
+
40
+ def create!(library, filename, tempfile)
41
+ sluggified_filename = make_slug(filename)
42
+ new(library, sluggified_filename, tempfile).tap do |item|
43
+ item.save!
44
+ end
45
+ end
46
+
47
+ def find_one(library, filename)
48
+ path = File.join(library[:static_location], File.basename(filename))
49
+
50
+ return unless File.exist?(path)
51
+
52
+ new(library, filename)
53
+ end
54
+
55
+ def delete_if_exists!(library, filename)
56
+ find_one(library, filename)&.delete!
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def resize_image!(dimensions)
63
+ # The mogrify command edits images in place. For more info, see
64
+ # mogrify(1).
65
+
66
+ system("mogrify -resize #{dimensions} #{@tempfile.to_path}")
67
+ if $?.exitstatus != 0
68
+ raise ConversionError, "Could not resize image #{@tempfile.to_path}"
69
+ end
70
+ end
71
+
72
+ def format_image!(filetype)
73
+ system("mogrify -format #{filetype} #{@tempfile.to_path}")
74
+ if $?.exitstatus != 0
75
+ raise ConversionError, "Could not format image #{@tempfile.to_path}"
76
+ end
77
+
78
+ # The name of the converted file will be the same as the original but
79
+ # with a new file extension.
80
+ converted = @tempfile.to_path.sub(/#{File.extname(@tempfile)}$/, ".#{filetype}")
81
+
82
+ # Mogrify's format command creates a new file with the new extension.
83
+ # Copy the contents of this file to the tempfile, then delete the newly
84
+ # created file.
85
+ IO.copy_stream(converted, @tempfile.to_path)
86
+ File.delete(converted)
87
+
88
+ # Update the file extension of the uploaded file.
89
+ @filename.sub!(/#{File.extname(@filename)}$/, ".#{filetype}")
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobinCMS
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,22 @@
1
+ <header class="card">
2
+ <h2>Change password</h2>
3
+ </header>
4
+ <form class="card" name="change-password" method="post">
5
+ <div class="field">
6
+ <label for="old-password">Old password</label>
7
+ <input id="old-password" type="password" name="old_password" required />
8
+ </div>
9
+ <div class="field">
10
+ <label for="new-password">New password</label>
11
+ <input id="new-password" type="password" name="new_password" required />
12
+ </div>
13
+ <div class="field">
14
+ <label for="confirm-password">Confirm password</label>
15
+ <input id="confirm-password" type="password" name="confirm_password" required />
16
+ </div>
17
+ <%= erb :flash %>
18
+ <footer class="controls --align-right">
19
+ <a href="/admin">Back</a>
20
+ <button type="submit">Update</button>
21
+ </footer>
22
+ </form>