robin_cms 0.1.7 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 68c3b702cf90bbb615ebeea6303a3f12e4e5da64b3360b295a198b9347ea11bb
4
- data.tar.gz: d266f45a2863ee190014bcd3174f0413fb70a5f745aaef74780f0a9ca7d6b3eb
3
+ metadata.gz: ee6ad0de352f4750144a35a2b258d5d2da00e8fc345f269be1aeec80a45bf5ee
4
+ data.tar.gz: 5bed86dbdf4f9c4041e324d60284583beb1c65551b3ff15964d3950fed8c9cd3
5
5
  SHA512:
6
- metadata.gz: 12d3f8064c84a26a3e3899ca964e411609ca03de3c193466aab157530d1022ffa0ae45237545260ad9a35377e07ff5cb09325c00c682cfd790247c1879b6dde4
7
- data.tar.gz: d73739c5b1b1c83b4fc7f3d80363353318a0b69164fba07ad784a738d8e5c23c8cea6c73c1d55542279c6a38929dc65ffc673870669781cf9df2a631cb21bebd
6
+ metadata.gz: dc0022fb5043ef5a2a640111ca650926c542cbd0a2a39f5895e6e2f6461edb4c6e4f44d1b8a9fbab01096dfb23ded529bc1458ac6923938eed8a82bcde2e2e71
7
+ data.tar.gz: 7cc63df15ad7619328ac1c9cf0c45c0dddf106e724081ad49a6b60d5f61645dab33dd6d7329e4b8ff39ebbfad0188aef714952e64c100b9ad59827eb0cfc69e1
data/README.md CHANGED
@@ -72,7 +72,8 @@ standalone CMS for any Static Site Generator, you can embed it in a dynamic
72
72
  Sinatra app, or you can use it as a Jekyll plugin. If you're already using
73
73
  Jekyll as your SSG, this is the simplest approach.
74
74
 
75
- See the [documentation](./docs/index.adoc) for further information on usage.
75
+ See the [documentation](https://robincms.org/docs.html) for further information
76
+ on usage.
76
77
 
77
78
  ## Configuring
78
79
 
@@ -100,8 +101,8 @@ schema for your content. An example YAML file looks like this:
100
101
  - { label: Title, id: title, type: input }
101
102
  - { label: Author, id: author_name, type: input }
102
103
 
103
- See the [docs](./docs/index.adoc) for more details on configuration. You can
104
- also find some examples [here](./examples).
104
+ See the [docs](https://robincms.org/docs.html) for more details on
105
+ configuration. You can also find some examples [here](./examples).
105
106
 
106
107
  ## Testing
107
108
 
@@ -109,6 +110,10 @@ Unit test are written in RSpec. To run them:
109
110
 
110
111
  rake test
111
112
 
113
+ ## Screenshot
114
+
115
+ ![Robin CMS screenshot](./assets/screenshot.png)
116
+
112
117
  ## Roadmap
113
118
 
114
119
  * [ ] Support markdown content
data/lib/robin_cms/cms.rb CHANGED
@@ -37,7 +37,7 @@ module RobinCMS
37
37
  end
38
38
 
39
39
  get "/" do
40
- redirect to(root_url)
40
+ redirect to(home)
41
41
  end
42
42
 
43
43
  get "/login" do
@@ -47,7 +47,7 @@ module RobinCMS
47
47
  post "/login" do
48
48
  authenticate!(params[:username], params[:password])
49
49
  session[:auth_user] = params[:username]
50
- redirect to(root_url)
50
+ redirect to(home)
51
51
  rescue AuthenticationError
52
52
  flash[:error] = "Incorrect username or password"
53
53
  redirect to("/login")
@@ -154,6 +154,21 @@ module RobinCMS
154
154
  redirect to("/libraries/#{params[:kind]}")
155
155
  end
156
156
 
157
+ # Handle uploading attachments from the trix editor. Responds with the path
158
+ # of the uploaded file in JSON format.
159
+ post "/libraries/:kind/attachment" do
160
+ static_path = @library.create_static(
161
+ params[:key],
162
+ params[:file][:tempfile].to_path
163
+ )
164
+
165
+ { :url => "/" + static_path }.to_json
166
+ end
167
+
168
+ post "/libraries/:kind/attachment/delete" do
169
+ @library.delete_static(params[:key])
170
+ end
171
+
157
172
  post "/publish" do
158
173
  system(@config[:build_command])
159
174
  if $?.exitstatus == 0
@@ -162,27 +177,21 @@ module RobinCMS
162
177
  flash[:error_dialog] = "There was an error publishing the site."
163
178
  end
164
179
 
165
- redirect to(root_url)
180
+ redirect to(home)
166
181
  end
167
182
 
168
- not_found do
169
- # This is a hack for showing previews of images uploaded in libraries.
170
- # It is needed because until you publish the site, the image is not
171
- # copied to the site output, and therefore requests for the file will
172
- # return a 404. Note that if using it as a Jekyll plugin, this is not
173
- # needed as the files will be served by Jekyll's dev server.
174
-
175
- # Need to use `PATH_INFO` for the same reason we need to use the `url`
176
- # and `to` helpers - we want the request URI relative to the path that
177
- # the app is mounted at i.e. the virtual location.
178
- uri = env["PATH_INFO"]
179
-
180
- library = @config[:libraries].find do |c|
181
- uri.start_with?("/#{c[:static_location]}")
182
- end
183
+ # Explicitly serve static files for previewing in the CMS. This is
184
+ # necessary because until you publish the site, static files are not copied
185
+ # to the site output, and therefore requests for the file will return a
186
+ # 404. Note that if using it as a Jekyll plugin, this is not needed as the
187
+ # files will be served by Jekyll's dev server.
188
+ #
189
+ # This route needs to be defined last since it catches a wide range of URLs.
190
+ get "/:static_location/:key" do
191
+ static_locations = @config[:libraries].map { |l| l[:static_location] }.uniq
183
192
 
184
- if library
185
- send_file(File.join(library[:static_location], File.basename(uri)))
193
+ if static_locations.include?(params[:static_location])
194
+ send_file(File.join(params[:static_location], params[:key]))
186
195
  else
187
196
  pass
188
197
  end
@@ -9,7 +9,7 @@
9
9
  "build_command": { "type": "string" },
10
10
  "accent_color": {
11
11
  "type": "string",
12
- "pattern": "^\#{1}[a-fA-F0-9]{6}"
12
+ "pattern": "^\\#{1}[a-fA-F0-9]{6}"
13
13
  },
14
14
  "libraries": {
15
15
  "type": "array",
@@ -61,7 +61,7 @@
61
61
  "label": { "type": "string" },
62
62
  "type": {
63
63
  "type": "string",
64
- "enum": ["text", "richtext", "date", "hidden", "number", "color", "email", "url", "select", "image"]
64
+ "enum": ["text", "richtext", "date", "hidden", "number", "color", "email", "url", "select", "image", "array"]
65
65
  },
66
66
  "default": { "type": "string" },
67
67
  "required": { "type": "boolean" },
@@ -10,9 +10,10 @@ module RobinCMS
10
10
 
11
11
  COLLECTION_DEFAULTS = {
12
12
  type: "collection",
13
- location: ".",
14
- static_location: "assets",
13
+ location: "_posts",
14
+ static_location: "uploads",
15
15
  filetype: "html",
16
+ pattern: ":year-:month-:day-:title",
16
17
  description: "",
17
18
  can_create: true,
18
19
  can_delete: true,
@@ -21,8 +22,8 @@ module RobinCMS
21
22
 
22
23
  DATA_DEFAULTS = {
23
24
  type: "data",
24
- location: ".",
25
- static_location: "assets",
25
+ location: "_data",
26
+ static_location: "uploads",
26
27
  filetype: "yml",
27
28
  description: "",
28
29
  can_create: true,
@@ -58,7 +59,6 @@ module RobinCMS
58
59
  label: "Published",
59
60
  id: "published",
60
61
  type: "select",
61
- default: false,
62
62
  options: [{
63
63
  label: "Draft",
64
64
  value: false
@@ -94,9 +94,16 @@ module RobinCMS
94
94
  YAML.load_file(config_file, symbolize_names: true)
95
95
  end
96
96
 
97
+ # TODO: use a ruby schema instead of a json file. Makes it easier to
98
+ # reuse elements and what not.
97
99
  schema_file = File.join(__dir__, "configuration-schema.json")
98
100
  schema = JSON.parse(File.read(schema_file))
99
- JSON::Validator.validate!(schema, data)
101
+ errors = JSON::Validator.fully_validate(schema, data)
102
+
103
+ unless errors.empty?
104
+ puts errors
105
+ raise ValidationError, "There is an error in your configuration file"
106
+ end
100
107
 
101
108
  config = from_h(DEFAULTS).merge_config(data).merge_automatic_fields
102
109
  config.validate!
@@ -115,10 +122,6 @@ module RobinCMS
115
122
  library[:type].nil? || library[:type] == "collection"
116
123
  end
117
124
 
118
- def has_image?(library)
119
- library[:fields].find { |f| f[:type] == "image" }
120
- end
121
-
122
125
  def merge_config(config)
123
126
  merge(config, {
124
127
  libraries: config[:libraries].map do |library|
@@ -144,8 +147,11 @@ module RobinCMS
144
147
  library[:fields].append(*AUTOMATIC_DRAFT_FIELDS)
145
148
  end
146
149
 
147
- if has_image?(library)
148
- library[:fields].append(*AUTOMATIC_IMAGE_FIELDS)
150
+ # Ensure the automatic image fields are inserted directly after the
151
+ # declared image field.
152
+ image_index = library[:fields].find_index { |f| f[:type] == "image" }
153
+ if image_index
154
+ library[:fields].insert(image_index + 1, *AUTOMATIC_IMAGE_FIELDS)
149
155
  end
150
156
 
151
157
  library[:fields].uniq! { |f| f[:id] }
@@ -156,6 +162,8 @@ module RobinCMS
156
162
 
157
163
  # This method provides custom validation which can't be expressed (or is
158
164
  # too difficult to express) using the Json Schema standard.
165
+ #
166
+ # TODO: validate that options are given if the field type is select.
159
167
  def validate!
160
168
  self[:libraries].each do |library|
161
169
  next if library[:fields].nil?
@@ -14,10 +14,11 @@ module RobinCMS
14
14
  def all
15
15
  return [] unless File.exist?(filepath)
16
16
 
17
- items = YAML.load_file(filepath, symbolize_names: true)
17
+ items = YAML.load_file(filepath, symbolize_names: true, permitted_classes: [Date])
18
18
 
19
19
  return [] unless items
20
20
 
21
+ # Use the index of the item as it's id.
21
22
  items.each_with_index.map do |attrs, i|
22
23
  Item.new(i, attrs, **@schema)
23
24
  end
@@ -29,11 +30,17 @@ module RobinCMS
29
30
 
30
31
  item.update_timestamps!
31
32
 
33
+ # The item's id is it's index in the YAML array. If the id is nil, we are
34
+ # creating a new item; append it to the end of the array. If the id is
35
+ # not nil, we are editing an item; swap out the item at the index
36
+ # corresponding to it's id.
32
37
  all_items = YAML.load_file(filepath) || []
33
38
  if item.id.nil?
34
- all_items.append(item.frontmatter)
39
+ # Data items don't have a published state.
40
+ all_items.append(item.frontmatter(ignore_published: true))
35
41
  else
36
- all_items[item.id.to_i] = item.frontmatter
42
+ # As above.
43
+ all_items[item.id.to_i] = item.frontmatter(ignore_published: true)
37
44
  end
38
45
 
39
46
  serialized = YAML.dump(all_items, stringify_names: true)
@@ -37,44 +37,64 @@ module RobinCMS
37
37
  URI.decode_www_form(request.query_string).to_h
38
38
  end
39
39
 
40
+ def home
41
+ "/libraries/#{@config[:libraries].first[:id]}"
42
+ end
43
+
44
+ def path(addr)
45
+ url(addr, absolute = false)
46
+ end
47
+
40
48
  def root_url
41
- url("/libraries/#{@config[:libraries].first[:id]}")
49
+ path("/")
50
+ end
51
+
52
+ def home_url
53
+ path(home)
42
54
  end
43
55
 
44
56
  def profile_url
45
- url("/profile")
57
+ path("/profile")
46
58
  end
47
59
 
48
60
  def logout_url
49
- url("/logout")
61
+ path("/logout")
50
62
  end
51
63
 
52
64
  def change_password_url
53
- url("/change-password")
65
+ path("/change-password")
54
66
  end
55
67
 
56
68
  def library_url(library)
57
- url("/libraries/#{library[:id]}")
69
+ path("/libraries/#{library[:id]}")
58
70
  end
59
71
 
60
72
  def library_item_url(library, item = nil)
61
73
  if item
62
- url("/libraries/#{library[:id]}/item/#{item.id}")
74
+ path("/libraries/#{library[:id]}/item/#{item.id}")
63
75
  else
64
- url("/libraries/#{library[:id]}/item")
76
+ path("/libraries/#{library[:id]}/item")
65
77
  end
66
78
  end
67
79
 
68
80
  def delete_library_item_url(library, item)
69
- url("/libraries/#{library[:id]}/item/#{item.id}/delete")
81
+ path("/libraries/#{library[:id]}/item/#{item.id}/delete")
82
+ end
83
+
84
+ def library_attachment_url(library)
85
+ path("/libraries/#{library[:id]}/attachment")
86
+ end
87
+
88
+ def delete_library_attachment_url(library)
89
+ path("/libraries/#{library[:id]}/attachment/delete")
70
90
  end
71
91
 
72
92
  def item_image_url(item)
73
- url(item.image_src)
93
+ path(item.image_src)
74
94
  end
75
95
 
76
96
  def publish_url
77
- url("/publish")
97
+ path("/publish")
78
98
  end
79
99
 
80
100
  def current_page?(library)
@@ -90,6 +110,22 @@ module RobinCMS
90
110
  "__#{prefix}_#{id}"
91
111
  end
92
112
 
113
+ # The attachments for Trix need to be served from the admin site root
114
+ # (usually "/admin") in order for the catch-all route handler to be able
115
+ # to serve them.
116
+ #
117
+ # TODO: You can set a "preview URL" for Trix (see
118
+ # https://github.com/basecamp/trix#previewing-attached-files). But I
119
+ # couldn't get it to work.
120
+ def escape_for_trix(value)
121
+ value = value
122
+ .sub(""href":"/uploads", ""href":"#{root_url}uploads")
123
+ .sub(""url":"/uploads", ""url":"#{root_url}uploads")
124
+ .sub("href=\"/uploads", "href=\"#{root_url}uploads")
125
+ .sub("url=\"/uploads", "url=\"#{root_url}uploads")
126
+ CGI::escapeHTML(value)
127
+ end
128
+
93
129
  def flash
94
130
  @flash ||= Flash.new(session ? session[:flash] : {})
95
131
  end
@@ -1,8 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RobinCMS
4
- class Item
5
- attr_reader :id, :attributes, :schema
4
+ # The in-memory data model for all items.
5
+ #
6
+ # The Item objects model the domain, while the Library objects model disk
7
+ # IO/persistence. Since the distinction between Data and Collection items is
8
+ # purely a distinction of how they are written to disk, this is abstracted
9
+ # entirely by the Library objects and therefore Item's don't care what type
10
+ # they are.
11
+ #
12
+ # There are two boundaries for serialization/deserialization of items. One is
13
+ # to/from the file used to store the data on disk. This happens in the
14
+ # find_one, all, and write methods of the Library classes. The other is
15
+ # to/from the html representation of the item. This happens in the
16
+ # attributes= and field_value_or_default methods in this class.
17
+ class Item attr_reader :id, :attributes, :schema
6
18
 
7
19
  DATETIME_FORMAT = "%Y-%m-%d"
8
20
 
@@ -17,15 +29,49 @@ module RobinCMS
17
29
  @id = id
18
30
  @schema = schema
19
31
 
20
- # Be sure to use the setter here so the keys get converted to symbols.
32
+ # Be sure to use the setter here.
21
33
  self.attributes = attrs
22
34
  end
23
35
 
24
36
  def attributes=(attributes)
25
37
  @attributes = attributes.to_h.transform_keys(&:to_sym)
26
38
 
27
- if attributes.has_key?(:published)
28
- @attributes[:published] = attributes[:published].to_s == "true"
39
+ # Parse array fields.
40
+ @schema[:fields].each do |f|
41
+ next unless f[:type] == "array"
42
+
43
+ field_id = f[:id].to_sym
44
+ if @attributes[field_id] && !@attributes[field_id].is_a?(Array)
45
+ @attributes[field_id] = @attributes[field_id].split(",").map(&:strip)
46
+ end
47
+ end
48
+
49
+ # Parse published status. The logic here is a bit weird, but basically an
50
+ # item without a 'published' key is considered published. Draft times are
51
+ # marked with 'published: false'. This is in line with how it works in
52
+ # Jekyll.
53
+ if blank?
54
+ @attributes[:published] = false # New items always default to draft.
55
+ elsif attributes.has_key?(:published)
56
+ @attributes[:published] = attributes[:published].to_s != "false"
57
+ else
58
+ @attributes[:published] = true
59
+ end
60
+ end
61
+
62
+ def field_value_or_default(field_id)
63
+ field = @schema[:fields].find { |f| f[:id] == field_id }
64
+ value = @attributes[field[:id].to_sym]
65
+
66
+ # Explicitly check for nil here because a field might have a value of
67
+ # false.
68
+ value = field[:default] if value.nil?
69
+
70
+ case field[:type]
71
+ when "array"
72
+ value&.join(",")
73
+ else
74
+ value.to_s
29
75
  end
30
76
  end
31
77
 
@@ -34,11 +80,11 @@ module RobinCMS
34
80
  end
35
81
 
36
82
  def published?
37
- if @attributes.has_key?(:published)
38
- @attributes[:published]
39
- else
40
- true
41
- end
83
+ @attributes[:published]
84
+ end
85
+
86
+ def blank?
87
+ @id.nil?
42
88
  end
43
89
 
44
90
  def published_label
@@ -73,8 +119,8 @@ module RobinCMS
73
119
  def display_name
74
120
  return @attributes[:title] unless @schema[:display_name_pattern]
75
121
 
76
- @schema[:display_name_pattern].clone.tap do |name|
77
- @attributes.each { |key, value| name.gsub!(":#{key}", value) }
122
+ @schema[:display_name_pattern].dup.tap do |name|
123
+ @attributes.each { |key, value| name.gsub!(":#{key}", value.to_s) }
78
124
  end
79
125
  end
80
126
 
@@ -86,12 +132,17 @@ module RobinCMS
86
132
  @attributes[:image_alt]
87
133
  end
88
134
 
135
+ def can_delete?
136
+ @id && @schema[:can_delete]
137
+ end
138
+
89
139
  # Get the frontmatter as a hash with stringified keys.
90
- def frontmatter
140
+ def frontmatter(ignore_published: false)
91
141
  frontmatter = @attributes.clone
92
142
  FRONTMATTER_IGNORE_KEYS.each { |key| frontmatter.delete(key) }
93
143
 
94
- if published?
144
+ # See comment in the attributes= method.
145
+ if ignore_published || published?
95
146
  frontmatter.delete(:published)
96
147
  else
97
148
  frontmatter[:published] = false
@@ -103,15 +154,5 @@ module RobinCMS
103
154
  # to support earlier versions of Ruby.
104
155
  frontmatter.to_h.transform_keys(&:to_s)
105
156
  end
106
-
107
- def field_value_or_default(field_id)
108
- field = @schema[:fields].find { |f| f[:id] == field_id }
109
- value = @attributes[field[:id].to_sym] || field[:default]
110
- value.to_s
111
- end
112
-
113
- def can_delete?
114
- @id && @schema[:can_delete]
115
- end
116
157
  end
117
158
  end
@@ -1,6 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RobinCMS
4
+ # The data access layer for Items.
5
+ #
6
+ # This class implements a Data Mapper pattern for persisting items to disk.
7
+ # It works well for this domain because there are two ways that items are
8
+ # written to disk, depending on whether they are a Data or Collection item.
9
+ # Since the Data Mapper is independent of the in-memory representation of
10
+ # Items, Items don't need to know about which 'type' they are.
4
11
  class Library
5
12
  include Editable
6
13
  include Sluggable
@@ -26,7 +33,7 @@ module RobinCMS
26
33
  end
27
34
 
28
35
  def blank
29
- Item.new(nil, **@schema)
36
+ Item.new(nil, {}, **@schema)
30
37
  end
31
38
 
32
39
  def create_static(filename, tempfile)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RobinCMS
4
- VERSION = "0.1.7"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -16,7 +16,7 @@
16
16
  </div>
17
17
  <%= erb :flash %>
18
18
  <footer class="controls --align-right">
19
- <a href="<%= root_url %>">Back</a>
19
+ <a href="<%= home_url %>">Back</a>
20
20
  <button type="submit">Update</button>
21
21
  </footer>
22
22
  </form>
@@ -9,7 +9,6 @@
9
9
  initial values. This one probably needs to be fixed.
10
10
  %>
11
11
  <img
12
- id="image-preview"
13
12
  class="image-preview"
14
13
  src="<%= item_image_url(@item) %>"
15
14
  alt="<%= @item.image_alt %>"
@@ -19,7 +19,7 @@
19
19
  <% if session[:auth_user] %>
20
20
  <header id="site-header">
21
21
  <span class="controls">
22
- <h1><a href="<%= root_url %>"><%= @config[:title] %> admin</a></h1>
22
+ <h1><a href="<%= home_url %>"><%= @config[:title] %> admin</a></h1>
23
23
  </span>
24
24
  <span class="controls --gap-md">
25
25
  <a href="<%= @config[:url] %>" target="_blank">
@@ -55,12 +55,12 @@
55
55
  <% end %>
56
56
  <td class="date">
57
57
  <a href="<%= library_item_url(@library, item) %>">
58
- <%= item.created_at.strftime("%b %d, %Y") %>
58
+ <%= item.created_at&.strftime("%b %d, %Y") %>
59
59
  </a>
60
60
  </td>
61
61
  <td class="date">
62
62
  <a href="<%= library_item_url(@library, item) %>">
63
- <%= item.updated_at.strftime("%b %d, %Y") %>
63
+ <%= item.updated_at&.strftime("%b %d, %Y") %>
64
64
  </a>
65
65
  </td>
66
66
  </tr>
@@ -19,7 +19,7 @@
19
19
  <div class="field">
20
20
  <%=
21
21
  case field[:type]
22
- when "text", "date", "number", "color", "email", "url"
22
+ when "text", "date", "number", "color", "email", "url", "array"
23
23
  erb :input_field, locals: { field: field }
24
24
  when "hidden"
25
25
  erb :hidden_field, locals: { field: field }
@@ -28,7 +28,7 @@
28
28
  when "image"
29
29
  erb :image_field, locals: { field: field }
30
30
  when "richtext"
31
- erb :richtext_field, locals: { field: field }
31
+ erb :richtext_field, locals: { field: field, library: @library }
32
32
  else
33
33
  ""
34
34
  end
@@ -1,4 +1,3 @@
1
- <?xml version="1.0" encoding="utf-8"?>
2
1
  <svg fill="#000000" width="12px" height="12px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
3
2
  <path fill-rule="evenodd" d="M5,2 L7,2 C7.55228475,2 8,2.44771525 8,3 C8,3.51283584 7.61395981,3.93550716 7.11662113,3.99327227 L7,4 L5,4 C4.48716416,4 4.06449284,4.38604019 4.00672773,4.88337887 L4,5 L4,19 C4,19.5128358 4.38604019,19.9355072 4.88337887,19.9932723 L5,20 L19,20 C19.5128358,20 19.9355072,19.6139598 19.9932723,19.1166211 L20,19 L20,17 C20,16.4477153 20.4477153,16 21,16 C21.5128358,16 21.9355072,16.3860402 21.9932723,16.8833789 L22,17 L22,19 C22,20.5976809 20.75108,21.9036609 19.1762728,21.9949073 L19,22 L5,22 C3.40231912,22 2.09633912,20.75108 2.00509269,19.1762728 L2,19 L2,5 C2,3.40231912 3.24891996,2.09633912 4.82372721,2.00509269 L5,2 L7,2 L5,2 Z M21,2 L21.081,2.003 L21.2007258,2.02024007 L21.2007258,2.02024007 L21.3121425,2.04973809 L21.3121425,2.04973809 L21.4232215,2.09367336 L21.5207088,2.14599545 L21.5207088,2.14599545 L21.6167501,2.21278596 L21.7071068,2.29289322 L21.7071068,2.29289322 L21.8036654,2.40469339 L21.8036654,2.40469339 L21.8753288,2.5159379 L21.9063462,2.57690085 L21.9063462,2.57690085 L21.9401141,2.65834962 L21.9401141,2.65834962 L21.9641549,2.73400703 L21.9641549,2.73400703 L21.9930928,2.8819045 L21.9930928,2.8819045 L22,3 L22,3 L22,9 C22,9.55228475 21.5522847,10 21,10 C20.4477153,10 20,9.55228475 20,9 L20,5.414 L13.7071068,11.7071068 C13.3466228,12.0675907 12.7793918,12.0953203 12.3871006,11.7902954 L12.2928932,11.7071068 C11.9324093,11.3466228 11.9046797,10.7793918 12.2097046,10.3871006 L12.2928932,10.2928932 L18.584,4 L15,4 C14.4477153,4 14,3.55228475 14,3 C14,2.44771525 14.4477153,2 15,2 L21,2 Z"/>
4
3
  </svg>
@@ -4,7 +4,7 @@
4
4
  <section class="card">
5
5
  <p>You are currently logged in as <b><%= @username %></b>.</p>
6
6
  <div class="controls --align-right">
7
- <a href="<%= root_url %>">Back</a>
7
+ <a href="<%= home_url %>">Back</a>
8
8
  <form action="<%= change_password_url %>">
9
9
  <button type="submit">Change password</button>
10
10
  </form>
@@ -1,10 +1,96 @@
1
1
  <link rel="stylesheet" type="text/css" href="https://unpkg.com/trix@2.0.8/dist/trix.css">
2
+
3
+ <style>
4
+ <%= erb :"trix.css" %>
5
+ </style>
6
+
2
7
  <script type="text/javascript" src="https://unpkg.com/trix@2.0.8/dist/trix.umd.min.js"></script>
8
+ <script>
9
+ addEventListener("trix-before-initialize", function () {
10
+ Trix.config.attachments.preview.caption = false;
11
+ Trix.config.attachments.file.caption = false;
12
+ });
13
+
14
+ addEventListener("trix-attachment-add", function (e) {
15
+ if (e.attachment.file) {
16
+ uploadFileAttachment(e.attachment);
17
+ }
18
+ });
19
+
20
+ addEventListener("trix-attachment-remove", function (e) {
21
+ if (e.attachment.file) {
22
+ removeFile(e.attachment);
23
+ }
24
+ });
25
+
26
+ function uploadFileAttachment(attachment) {
27
+ uploadFile(attachment.file, setProgress, setAttributes);
28
+
29
+ function setProgress(progress) {
30
+ attachment.setUploadProgress(progress);
31
+ }
32
+
33
+ function setAttributes(attributes) {
34
+ attachment.setAttributes(attributes);
35
+ }
36
+ }
37
+
38
+ function uploadFile(file, progressCallback, successCallback) {
39
+ var formData = new FormData();
40
+ formData.append("key", createStorageKey(file));
41
+ formData.append("Content-Type", file.type);
42
+ formData.append("file", file);
43
+
44
+ var xhr = new XMLHttpRequest();
45
+
46
+ xhr.open("POST", "<%= library_attachment_url(library) %>", true);
47
+
48
+ xhr.upload.addEventListener("progress", function (e) {
49
+ var progress = e.loaded / e.total * 100;
50
+ progressCallback(progress);
51
+ });
52
+
53
+ xhr.addEventListener("load", function (e) {
54
+ if (xhr.status == 200) {
55
+ var res = JSON.parse(xhr.response);
56
+ var attributes = {
57
+ url: res.url,
58
+ href: res.url + "?content-disposition=attachment"
59
+ };
60
+ successCallback(attributes);
61
+ }
62
+ });
63
+
64
+ xhr.send(formData);
65
+ }
66
+
67
+ function removeFile(attachment) {
68
+ var url = attachment.getAttribute("url");
69
+ var parts = url.split("/");
70
+ var key = parts[parts.length - 1];
71
+
72
+ var formData = new FormData();
73
+ formData.append("key", key);
74
+
75
+ var xhr = new XMLHttpRequest();
76
+
77
+ xhr.open("POST", "<%= delete_library_attachment_url(library) %>", true);
78
+ xhr.send(formData);
79
+ }
80
+
81
+ function createStorageKey(file) {
82
+ var date = new Date();
83
+ var day = date.toISOString().slice(0, 10);
84
+ return date.getTime() + "-" + file.name;
85
+ }
86
+ </script>
87
+
3
88
  <%= erb :field_label, locals: { field: field } %>
89
+ <%# TODO: label for attribute doesn't reference correct id %>
4
90
  <input
5
91
  id="richtext-content"
6
92
  type="hidden"
7
93
  name="<%= field[:id] %>"
8
- value="<%= @item.field_value_or_default(field[:id]) %>"
94
+ value="<%= escape_for_trix(@item.field_value_or_default(field[:id])) %>"
9
95
  >
10
- <trix-editor input="richtext-content"></trix-editor>
96
+ <trix-editor class="trix-content" input="richtext-content"></trix-editor>
@@ -7,7 +7,7 @@
7
7
  <% for option in field[:options] %>
8
8
  <option
9
9
  value="<%= option[:value] %>"
10
- <% if option[:value].to_s == @item.published?.to_s %>
10
+ <% if option[:value].to_s == @item.field_value_or_default(field[:id]) %>
11
11
  selected
12
12
  <% end %>
13
13
  >
@@ -194,7 +194,7 @@ select {
194
194
  input,
195
195
  select,
196
196
  trix-toolbar,
197
- img#image-preview {
197
+ img.image-preview {
198
198
  display: block;
199
199
  margin-top: var(--padding-xs);
200
200
  }
@@ -425,7 +425,6 @@ hr {
425
425
  /* trix editor overrides */
426
426
 
427
427
  trix-editor {
428
- height: 16rem;
429
428
  border: 1px solid var(--border-color) !important;
430
429
  border-radius: var(--border-radius) !important;
431
430
  box-shadow: var(--box-shadow-input);
@@ -446,3 +445,4 @@ trix-editor {
446
445
  .trix-button.trix-active {
447
446
  background: var(--accent-color-light) !important;
448
447
  }
448
+
@@ -0,0 +1,75 @@
1
+ .trix-content {
2
+ line-height: 1.5; }
3
+ .trix-content * {
4
+ box-sizing: border-box;
5
+ margin: 0;
6
+ padding: 0; }
7
+ .trix-content h1 {
8
+ font-size: 1.2em;
9
+ line-height: 1.2; }
10
+ .trix-content blockquote {
11
+ border: 0 solid #ccc;
12
+ border-left-width: 0.3em;
13
+ margin-left: 0.3em;
14
+ padding-left: 0.6em; }
15
+ .trix-content [dir=rtl] blockquote,
16
+ .trix-content blockquote[dir=rtl] {
17
+ border-width: 0;
18
+ border-right-width: 0.3em;
19
+ margin-right: 0.3em;
20
+ padding-right: 0.6em; }
21
+ .trix-content li {
22
+ margin-left: 1em; }
23
+ .trix-content [dir=rtl] li {
24
+ margin-right: 1em; }
25
+ .trix-content pre {
26
+ display: inline-block;
27
+ width: 100%;
28
+ vertical-align: top;
29
+ font-family: monospace;
30
+ font-size: 0.9em;
31
+ padding: 0.5em;
32
+ white-space: pre;
33
+ background-color: #eee;
34
+ overflow-x: auto; }
35
+ .trix-content img {
36
+ max-width: 100%;
37
+ height: auto; }
38
+ .trix-content .attachment {
39
+ display: inline-block;
40
+ position: relative;
41
+ max-width: 100%; }
42
+ .trix-content .attachment a {
43
+ color: inherit;
44
+ text-decoration: none; }
45
+ .trix-content .attachment a:hover, .trix-content .attachment a:visited:hover {
46
+ color: inherit; }
47
+ .trix-content .attachment__caption {
48
+ text-align: center; }
49
+ .trix-content .attachment__caption .attachment__name + .attachment__size::before {
50
+ content: ' \2022 '; }
51
+ .trix-content .attachment--preview {
52
+ width: 50%;
53
+ text-align: center; }
54
+ .trix-content .attachment--preview .attachment__caption {
55
+ color: #666;
56
+ font-size: 0.9em;
57
+ line-height: 1.2; }
58
+ .trix-content .attachment--file {
59
+ color: #333;
60
+ line-height: 1;
61
+ margin: 0 2px 2px 2px;
62
+ padding: 0.4em 1em;
63
+ border: 1px solid #bbb;
64
+ border-radius: 5px; }
65
+ .trix-content .attachment-gallery {
66
+ display: flex;
67
+ flex-wrap: wrap;
68
+ position: relative; }
69
+ .trix-content .attachment-gallery .attachment {
70
+ flex: 1 0 33%;
71
+ padding: 0 0.5em;
72
+ max-width: 33%; }
73
+ .trix-content .attachment-gallery.attachment-gallery--2 .attachment, .trix-content .attachment-gallery.attachment-gallery--4 .attachment {
74
+ flex-basis: 50%;
75
+ max-width: 50%; }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: robin_cms
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.7
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aron Lebani
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-11-25 00:00:00.000000000 Z
11
+ date: 2026-09-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bcrypt
@@ -112,6 +112,7 @@ files:
112
112
  - lib/robin_cms/views/richtext_field.erb
113
113
  - lib/robin_cms/views/select_field.erb
114
114
  - lib/robin_cms/views/stylesheet.css.erb
115
+ - lib/robin_cms/views/trix.css.erb
115
116
  homepage: https://robincms.org
116
117
  licenses:
117
118
  - MIT
@@ -133,7 +134,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
133
134
  - !ruby/object:Gem::Version
134
135
  version: '0'
135
136
  requirements: []
136
- rubygems_version: 3.5.16
137
+ rubygems_version: 3.5.22
137
138
  signing_key:
138
139
  specification_version: 4
139
140
  summary: A simple, flat-file CMS for Static Site Generators.