robin_cms 0.1.6 → 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: 6a9634aa2592fdf851123491cc53c9b1b347b6e6ca3f7f37d4adbd9e0fb494c7
4
- data.tar.gz: 3885036d121802207eb4ae9cf80c372211ee199f60fa30c7e11f5b916af5c570
3
+ metadata.gz: ee6ad0de352f4750144a35a2b258d5d2da00e8fc345f269be1aeec80a45bf5ee
4
+ data.tar.gz: 5bed86dbdf4f9c4041e324d60284583beb1c65551b3ff15964d3950fed8c9cd3
5
5
  SHA512:
6
- metadata.gz: f38e98b366edb3294b56e2b97c80819149163020d41dd565e893ec3d4d8c84f2332f108de62858a9d55bf135176bf8f042ee28980bd1eb6f6d6ddaf7d330f210
7
- data.tar.gz: 29d90d7896c8366dbe5373bdd89193d4dcd336577cd8c517d2b5b5082b1aa65ffef18c0b733b5387bb9bf53405c8fcf77b8afdb700e1a682c98bc37ae1c51540
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(home_page)
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(home_page)
50
+ redirect to(home)
51
51
  rescue AuthenticationError
52
52
  flash[:error] = "Incorrect username or password"
53
53
  redirect to("/login")
@@ -154,34 +154,44 @@ 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
- if system(@config[:build_command])
173
+ system(@config[:build_command])
174
+ if $?.exitstatus == 0
159
175
  flash[:success_dialog] = "Your site has been successfully published!"
160
176
  else
161
- flash[:errors] = "There was an error publishing the site"
177
+ flash[:error_dialog] = "There was an error publishing the site."
162
178
  end
163
179
 
164
- redirect to(home_page)
180
+ redirect to(home)
165
181
  end
166
182
 
167
- not_found do
168
- # This is a hack for showing previews of images uploaded in libraries.
169
- # It is needed because until you publish the site, the image is not
170
- # copied to the site output, and therefore requests for the file will
171
- # return a 404. Note that if using it as a Jekyll plugin, this is not
172
- # needed as the files will be served by Jekyll's dev server.
173
-
174
- # Need to use `PATH_INFO` for the same reason we need to use the `url`
175
- # and `to` helpers - we want the request URI relative to the path that
176
- # the app is mounted at i.e. the virtual location.
177
- uri = env["PATH_INFO"]
178
-
179
- library = @config[:libraries].find do |c|
180
- uri.start_with?("/#{c[:static_location]}")
181
- 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
182
192
 
183
- if library
184
- 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]))
185
195
  else
186
196
  pass
187
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,13 +37,70 @@ module RobinCMS
37
37
  URI.decode_www_form(request.query_string).to_h
38
38
  end
39
39
 
40
- def current_page
41
- _, libraries, item = request.path_info.split("/")
42
- item || libraries
40
+ def home
41
+ "/libraries/#{@config[:libraries].first[:id]}"
43
42
  end
44
43
 
45
- def home_page
46
- "/libraries/#{@config[:libraries].first[:id]}"
44
+ def path(addr)
45
+ url(addr, absolute = false)
46
+ end
47
+
48
+ def root_url
49
+ path("/")
50
+ end
51
+
52
+ def home_url
53
+ path(home)
54
+ end
55
+
56
+ def profile_url
57
+ path("/profile")
58
+ end
59
+
60
+ def logout_url
61
+ path("/logout")
62
+ end
63
+
64
+ def change_password_url
65
+ path("/change-password")
66
+ end
67
+
68
+ def library_url(library)
69
+ path("/libraries/#{library[:id]}")
70
+ end
71
+
72
+ def library_item_url(library, item = nil)
73
+ if item
74
+ path("/libraries/#{library[:id]}/item/#{item.id}")
75
+ else
76
+ path("/libraries/#{library[:id]}/item")
77
+ end
78
+ end
79
+
80
+ def delete_library_item_url(library, item)
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")
90
+ end
91
+
92
+ def item_image_url(item)
93
+ path(item.image_src)
94
+ end
95
+
96
+ def publish_url
97
+ path("/publish")
98
+ end
99
+
100
+ def current_page?(library)
101
+ _, libraries, item = request.path_info.split("/")
102
+ current_page = item || libraries
103
+ library[:id].to_s == current_page
47
104
  end
48
105
 
49
106
  # For generating safe id's where the id needs to be based on user
@@ -53,6 +110,22 @@ module RobinCMS
53
110
  "__#{prefix}_#{id}"
54
111
  end
55
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
+
56
129
  def flash
57
130
  @flash ||= Flash.new(session ? session[:flash] : {})
58
131
  end
@@ -1,13 +1,25 @@
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
 
9
21
  # The keys which we don't want to serialize.
10
- FRONTMATTER_IGNORE_KEYS = [:id, :content, :image, :captures].freeze
22
+ FRONTMATTER_IGNORE_KEYS = [:id, :kind, :content, :image, :captures].freeze
11
23
 
12
24
  def initialize(id, attrs = {}, **schema)
13
25
  if !attrs.empty? && !attrs.has_key?(:title)
@@ -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,17 +119,30 @@ 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
 
127
+ def image_src
128
+ @attributes[:image_src]
129
+ end
130
+
131
+ def image_alt
132
+ @attributes[:image_alt]
133
+ end
134
+
135
+ def can_delete?
136
+ @id && @schema[:can_delete]
137
+ end
138
+
81
139
  # Get the frontmatter as a hash with stringified keys.
82
- def frontmatter
140
+ def frontmatter(ignore_published: false)
83
141
  frontmatter = @attributes.clone
84
142
  FRONTMATTER_IGNORE_KEYS.each { |key| frontmatter.delete(key) }
85
143
 
86
- if published?
144
+ # See comment in the attributes= method.
145
+ if ignore_published || published?
87
146
  frontmatter.delete(:published)
88
147
  else
89
148
  frontmatter[:published] = false
@@ -95,15 +154,5 @@ module RobinCMS
95
154
  # to support earlier versions of Ruby.
96
155
  frontmatter.to_h.transform_keys(&:to_s)
97
156
  end
98
-
99
- def field_value_or_default(field_id)
100
- field = @schema[:fields].find { |f| f[:id] == field_id }
101
- value = @attributes[field[:id].to_sym] || field[:default]
102
- value.to_s
103
- end
104
-
105
- def can_delete?
106
- @id && @schema[:can_delete]
107
- end
108
157
  end
109
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.6"
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="/admin">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,7 @@
9
9
  <button type="submit">Close</button>
10
10
  </form>
11
11
  <form
12
- action='<%= url("/libraries/#{@library.kind}/item/#{@item.id}/delete") %>'
12
+ action="<%= delete_library_item_url(@library, @item) %>"
13
13
  method="post"
14
14
  >
15
15
  <button type="submit" class="--danger">Delete</button>
@@ -13,7 +13,7 @@
13
13
  </select>
14
14
  <% if @library.drafts_enabled? %>
15
15
  <select id="published" name="published">
16
- <% for option in PUBLISHED_OPTIONS%>
16
+ <% for option in PUBLISHED_OPTIONS %>
17
17
  <option
18
18
  value="<%= option[:value] %>"
19
19
  <% if query_params["published"] == option[:value] %>
@@ -33,5 +33,5 @@
33
33
  placeholder="Search <%= @library[:label].downcase %>..."
34
34
  />
35
35
  <button type="submit">Apply</button>
36
- <a href="/admin/libraries/<%= @library.kind %>">Clear filters</a>
36
+ <a href="<%= library_url(@library) %>">Clear filters</a>
37
37
  </form>
@@ -9,10 +9,9 @@
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
- src="<%= url(@item.attributes[:image_src]) %>"
15
- alt="<%= @item.attributes[:image_alt] %>"
13
+ src="<%= item_image_url(@item) %>"
14
+ alt="<%= @item.image_alt %>"
16
15
  />
17
16
  <input
18
17
  id="<%= safe_id('image', 'field') %>"
@@ -9,23 +9,29 @@
9
9
  <%= erb :"stylesheet.css" %>
10
10
  </style>
11
11
  </head>
12
- <body <% if flash[:success_dialog] %>onload="successdialog.showModal()"<% end %>>
12
+ <body
13
+ <% if flash[:success_dialog] %>
14
+ onload="successdialog.showModal()"
15
+ <% elsif flash[:error_dialog] %>
16
+ onload="errordialog.showModal()"
17
+ <% end %>
18
+ >
13
19
  <% if session[:auth_user] %>
14
20
  <header id="site-header">
15
21
  <span class="controls">
16
- <h1><a href="/admin"><%= @config[:title] %> admin</a></h1>
22
+ <h1><a href="<%= home_url %>"><%= @config[:title] %> admin</a></h1>
17
23
  </span>
18
24
  <span class="controls --gap-md">
19
25
  <a href="<%= @config[:url] %>" target="_blank">
20
26
  <%= @config[:url] %><%= erb :"new_tab.svg" %>
21
27
  </a>
22
28
  <% if @config[:build_command] %>
23
- <form id="publish-form" action="/admin/publish" method="post">
29
+ <form id="publish-form" action="<%= publish_url %>" method="post">
24
30
  <button type="submit" form="publish-form">Publish site</button>
25
31
  </form>
26
32
  <% end %>
27
- <a href='<%= url("/profile") %>'>Account</a>
28
- <a href='<%= url("/logout") %>'>Log out</a>
33
+ <a href="<%= profile_url %>">Account</a>
34
+ <a href="<%= logout_url %>">Log out</a>
29
35
  </span>
30
36
  </header>
31
37
  <% end %>
@@ -44,8 +50,17 @@
44
50
  <h2>Success!</h2>
45
51
  <p><%= flash[:success_dialog] %></p>
46
52
  <div class="controls">
47
- <form id="dialog-close" method="dialog">
48
- <button type="submit" form="dialog-close">Continue</button>
53
+ <form id="success-dialog-close" method="dialog">
54
+ <button type="submit" form="success-dialog-close">Continue</button>
55
+ </form>
56
+ </div>
57
+ </dialog>
58
+ <dialog id="errordialog" class="card">
59
+ <h2>Oops, something went wrong.</h2>
60
+ <p><%= flash[:error_dialog] %></p>
61
+ <div class="controls">
62
+ <form id="err-dialog-close" method="dialog">
63
+ <button type="submit" form="err-dialog-close">Continue</button>
49
64
  </form>
50
65
  </div>
51
66
  </dialog>
@@ -6,7 +6,7 @@
6
6
  <p><%= @library[:description] %></p>
7
7
  <% end %>
8
8
  <% if @library[:can_create] %>
9
- <form action='<%= url("/libraries/#{@library.kind}/item") %>'>
9
+ <form action="<%= library_item_url(@library) %>">
10
10
  <button type="submit">
11
11
  New <%= @library[:label_singular].downcase %>
12
12
  </button>
@@ -20,8 +20,8 @@
20
20
  <br />
21
21
  <br />
22
22
  <% if @library[:can_create] %>
23
- Create a <a href='<%= url("/libraries/#{@library.kind}/item") %>'>new <%= @library[:label_singular].downcase %></a>
24
- or try <a href='<%= url("/libraries/#{@library.kind}") %>'>clearing</a> your search filters.
23
+ Create a <a href="<%= library_item_url(@library) %>">new <%= @library[:label_singular].downcase %></a>
24
+ or try <a href="<%= library_url(@library) %>">clearing</a> your search filters.
25
25
  <% end %>
26
26
  </div>
27
27
  <% else %>
@@ -30,7 +30,7 @@
30
30
  <tr>
31
31
  <th>Name</th>
32
32
  <% if @library.drafts_enabled? %>
33
- <th>Status</th>
33
+ <th class="status">Status</th>
34
34
  <% end %>
35
35
  <th class="date">Created</th>
36
36
  <th class="date">Last updated</th>
@@ -40,27 +40,27 @@
40
40
  <% for item in @items %>
41
41
  <tr>
42
42
  <td>
43
- <a href='<%= url("/libraries/#{@library.kind}/item/#{item.id}") %>'>
43
+ <a href="<%= library_item_url(@library, item) %>">
44
44
  <%= item.display_name %>
45
45
  </a>
46
46
  </td>
47
47
  <% if @library.drafts_enabled? %>
48
48
  <td>
49
- <a href='<%= url("/libraries/#{@library.kind}/item/#{item.id}") %>'>
50
- <span class="badge --<%= item.published? ? 'published' : 'draft' %>">
49
+ <a href="<%= library_item_url(@library, item) %>">
50
+ <span class="badge --<%= item.published_label.downcase %>">
51
51
  <%= item.published_label %>
52
52
  </span>
53
53
  </a>
54
54
  </td>
55
55
  <% end %>
56
56
  <td class="date">
57
- <a href='<%= url("/libraries/#{@library.kind}/item/#{item.id}") %>'>
58
- <%= item.created_at.strftime("%b %d, %Y") %>
57
+ <a href="<%= library_item_url(@library, item) %>">
58
+ <%= item.created_at&.strftime("%b %d, %Y") %>
59
59
  </a>
60
60
  </td>
61
61
  <td class="date">
62
- <a href='<%= url("/libraries/#{@library.kind}/item/#{item.id}") %>'>
63
- <%= item.updated_at.strftime("%b %d, %Y") %>
62
+ <a href="<%= library_item_url(@library, item) %>">
63
+ <%= item.updated_at&.strftime("%b %d, %Y") %>
64
64
  </a>
65
65
  </td>
66
66
  </tr>
@@ -1,7 +1,5 @@
1
1
  <span class="controls">
2
- <a href='<%= url("/libraries/#{@library.kind}") %>'>Back</a>
2
+ <a href="<%= library_url(@library) %>">Back</a>
3
3
  <button form="<%= safe_id(@item.id, 'edit_item') %>" type="submit">Save</button>
4
- <% if has_delete %>
5
- <%= erb :delete_dialog %>
6
- <% end %>
4
+ <%= erb :delete_dialog if has_delete %>
7
5
  </span>
@@ -8,29 +8,31 @@
8
8
  id="<%= safe_id(@item.id, 'edit_item') %>"
9
9
  class="card"
10
10
  <% if @item.id %>
11
- action='<%= url("/libraries/#{@library.kind}/item/#{@item.id}") %>'
11
+ action="<%= library_item_url(@library, @item) %>"
12
12
  <% else %>
13
- action='<%= url("/libraries/#{@library.kind}/item") %>'
13
+ action="<%= library_item_url(@library) %>"
14
14
  <% end %>
15
15
  method="post"
16
16
  enctype="multipart/form-data"
17
17
  >
18
18
  <% @library.sorted_fields.each do |field| %>
19
19
  <div class="field">
20
- <% case field[:type]
21
- when "text", "date", "number", "color", "email", "url" %>
22
- <%= erb :input_field, locals: { field: field } %>
23
- <% when "hidden" %>
24
- <%= erb :hidden_field, locals: { field: field } %>
25
- <% when "select" %>
26
- <%= erb :select_field, locals: { field: field } %>
27
- <% when "image" %>
28
- <%= erb :image_field, locals: { field: field } %>
29
- <% when "richtext" %>
30
- <%= erb :richtext_field, locals: { field: field } %>
31
- <% else %>
32
- <div></div>
33
- <% end %>
20
+ <%=
21
+ case field[:type]
22
+ when "text", "date", "number", "color", "email", "url", "array"
23
+ erb :input_field, locals: { field: field }
24
+ when "hidden"
25
+ erb :hidden_field, locals: { field: field }
26
+ when "select"
27
+ erb :select_field, locals: { field: field }
28
+ when "image"
29
+ erb :image_field, locals: { field: field }
30
+ when "richtext"
31
+ erb :richtext_field, locals: { field: field, library: @library }
32
+ else
33
+ ""
34
+ end
35
+ %>
34
36
  </div>
35
37
  <% end %>
36
38
  <%= erb :flash %>
@@ -1,10 +1,8 @@
1
1
  <nav id="site-nav">
2
2
  <ul class="controls">
3
3
  <% for library in @config[:libraries] %>
4
- <li <% if current_page == library[:id].to_s %>class="current"<% end %>>
5
- <a href='<%= url("/libraries/#{library[:id]}") %>'>
6
- <%= library[:label] %>
7
- </a>
4
+ <li <% if current_page?(library) %>class="current"<% end %>>
5
+ <a href="<%= library_url(library) %>"><%= library[:label] %></a>
8
6
  </li>
9
7
  <% end %>
10
8
  </ul>
@@ -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,8 +4,8 @@
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="/admin">Back</a>
8
- <form action="<%= url('/change-password') %>">
7
+ <a href="<%= home_url %>">Back</a>
8
+ <form action="<%= change_password_url %>">
9
9
  <button type="submit">Change password</button>
10
10
  </form>
11
11
  </div>
@@ -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,14 +7,8 @@
7
7
  <% for option in field[:options] %>
8
8
  <option
9
9
  value="<%= option[:value] %>"
10
- <% if field[:id] == "published" %>
11
- <% if option[:value] == true && @item.published? %>
12
- selected
13
- <% end %>
14
- <% else %>
15
- <% if option[:value].to_s == @item.field_value_or_default(field[:id]) %>
16
- selected
17
- <% end %>
10
+ <% if option[:value].to_s == @item.field_value_or_default(field[:id]) %>
11
+ selected
18
12
  <% end %>
19
13
  >
20
14
  <%= option[:label] %>
@@ -78,7 +78,9 @@ th {
78
78
  }
79
79
 
80
80
  th.date,
81
- td.date > * {
81
+ td.date > *,
82
+ th.status,
83
+ td.status > * {
82
84
  width: 7rem;
83
85
  text-align: right;
84
86
  }
@@ -192,7 +194,7 @@ select {
192
194
  input,
193
195
  select,
194
196
  trix-toolbar,
195
- img#image-preview {
197
+ img.image-preview {
196
198
  display: block;
197
199
  margin-top: var(--padding-xs);
198
200
  }
@@ -423,7 +425,6 @@ hr {
423
425
  /* trix editor overrides */
424
426
 
425
427
  trix-editor {
426
- height: 16rem;
427
428
  border: 1px solid var(--border-color) !important;
428
429
  border-radius: var(--border-radius) !important;
429
430
  box-shadow: var(--box-shadow-input);
@@ -444,3 +445,4 @@ trix-editor {
444
445
  .trix-button.trix-active {
445
446
  background: var(--accent-color-light) !important;
446
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.6
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-03 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.