pressroom 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 (82) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +22 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +172 -0
  5. data/Rakefile +8 -0
  6. data/app/assets/javascripts/pressroom/application.js +18 -0
  7. data/app/assets/javascripts/pressroom/controllers/editor_controller.js +146 -0
  8. data/app/assets/javascripts/pressroom/vendor/code.js +181 -0
  9. data/app/assets/javascripts/pressroom/vendor/delimiter.js +98 -0
  10. data/app/assets/javascripts/pressroom/vendor/editorjs.js +11217 -0
  11. data/app/assets/javascripts/pressroom/vendor/header.js +321 -0
  12. data/app/assets/javascripts/pressroom/vendor/image.js +1103 -0
  13. data/app/assets/javascripts/pressroom/vendor/inline-code.js +99 -0
  14. data/app/assets/javascripts/pressroom/vendor/list.js +2091 -0
  15. data/app/assets/javascripts/pressroom/vendor/marker.js +95 -0
  16. data/app/assets/javascripts/pressroom/vendor/quote.js +990 -0
  17. data/app/assets/javascripts/pressroom/vendor/stimulus.js +2563 -0
  18. data/app/assets/javascripts/pressroom/vendor/turbo.js +7256 -0
  19. data/app/assets/stylesheets/pressroom/admin.css +216 -0
  20. data/app/assets/stylesheets/pressroom/content.css +18 -0
  21. data/app/controllers/pressroom/application_controller.rb +41 -0
  22. data/app/controllers/pressroom/categories_controller.rb +50 -0
  23. data/app/controllers/pressroom/posts_controller.rb +98 -0
  24. data/app/controllers/pressroom/tags_controller.rb +39 -0
  25. data/app/controllers/pressroom/uploads_controller.rb +52 -0
  26. data/app/helpers/pressroom/application_helper.rb +4 -0
  27. data/app/helpers/pressroom/blocks_helper.rb +13 -0
  28. data/app/jobs/pressroom/application_job.rb +4 -0
  29. data/app/mailers/pressroom/application_mailer.rb +6 -0
  30. data/app/models/concerns/pressroom/authorable.rb +23 -0
  31. data/app/models/concerns/pressroom/sluggable.rb +66 -0
  32. data/app/models/pressroom/application_record.rb +7 -0
  33. data/app/models/pressroom/category.rb +54 -0
  34. data/app/models/pressroom/post.rb +90 -0
  35. data/app/models/pressroom/tag.rb +30 -0
  36. data/app/models/pressroom/tagging.rb +12 -0
  37. data/app/views/layouts/pressroom/admin.html.erb +34 -0
  38. data/app/views/layouts/pressroom/application.html.erb +17 -0
  39. data/app/views/pressroom/blocks/_code.html.erb +2 -0
  40. data/app/views/pressroom/blocks/_delimiter.html.erb +1 -0
  41. data/app/views/pressroom/blocks/_header.html.erb +5 -0
  42. data/app/views/pressroom/blocks/_image.html.erb +18 -0
  43. data/app/views/pressroom/blocks/_list.html.erb +6 -0
  44. data/app/views/pressroom/blocks/_list_items.html.erb +13 -0
  45. data/app/views/pressroom/blocks/_paragraph.html.erb +1 -0
  46. data/app/views/pressroom/blocks/_quote.html.erb +6 -0
  47. data/app/views/pressroom/categories/_form.html.erb +31 -0
  48. data/app/views/pressroom/categories/edit.html.erb +3 -0
  49. data/app/views/pressroom/categories/index.html.erb +34 -0
  50. data/app/views/pressroom/categories/new.html.erb +3 -0
  51. data/app/views/pressroom/posts/_form.html.erb +49 -0
  52. data/app/views/pressroom/posts/_post_row.html.erb +17 -0
  53. data/app/views/pressroom/posts/edit.html.erb +34 -0
  54. data/app/views/pressroom/posts/index.html.erb +38 -0
  55. data/app/views/pressroom/posts/new.html.erb +3 -0
  56. data/app/views/pressroom/tags/edit.html.erb +24 -0
  57. data/app/views/pressroom/tags/index.html.erb +27 -0
  58. data/config/editorjs_manifest.yml +53 -0
  59. data/config/importmap.rb +18 -0
  60. data/config/locales/pressroom.en.yml +16 -0
  61. data/config/locales/transliteration.ru.yml +70 -0
  62. data/config/locales/transliteration.uk.yml +70 -0
  63. data/config/routes.rb +17 -0
  64. data/db/migrate/20260821000001_create_pressroom_posts.rb +42 -0
  65. data/db/migrate/20260821000002_create_pressroom_taxonomy.rb +33 -0
  66. data/lib/pressroom/blocks/definition.rb +103 -0
  67. data/lib/pressroom/blocks/registry.rb +56 -0
  68. data/lib/pressroom/blocks/renderer.rb +97 -0
  69. data/lib/pressroom/blocks/sanitizer.rb +36 -0
  70. data/lib/pressroom/blocks/schema.rb +247 -0
  71. data/lib/pressroom/blocks/text_extractor.rb +46 -0
  72. data/lib/pressroom/built_in_blocks.rb +97 -0
  73. data/lib/pressroom/configuration.rb +64 -0
  74. data/lib/pressroom/engine.rb +33 -0
  75. data/lib/pressroom/errors.rb +11 -0
  76. data/lib/pressroom/feed.rb +69 -0
  77. data/lib/pressroom/transliteration.rb +213 -0
  78. data/lib/pressroom/version.rb +3 -0
  79. data/lib/pressroom.rb +70 -0
  80. data/lib/tasks/pressroom_tasks.rake +4 -0
  81. data/lib/tasks/pressroom_vendor.rake +31 -0
  82. metadata +171 -0
@@ -0,0 +1,216 @@
1
+ /* Pressroom admin. BEM: block, block__element, block--modifier. */
2
+
3
+ .pressroom-admin {
4
+ --pressroom-bg: #ffffff;
5
+ --pressroom-fg: #16181d;
6
+ --pressroom-muted: #6b7280;
7
+ --pressroom-line: #e5e7eb;
8
+ --pressroom-accent: #2563eb;
9
+ --pressroom-danger: #b91c1c;
10
+ --pressroom-surface: #f9fafb;
11
+ --pressroom-radius: 6px;
12
+
13
+ margin: 0;
14
+ background: var(--pressroom-bg);
15
+ color: var(--pressroom-fg);
16
+ font: 15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
17
+ }
18
+
19
+ @media (prefers-color-scheme: dark) {
20
+ .pressroom-admin {
21
+ --pressroom-bg: #101215;
22
+ --pressroom-fg: #e8eaed;
23
+ --pressroom-muted: #9aa1ab;
24
+ --pressroom-line: #2a2f37;
25
+ --pressroom-accent: #6ea8fe;
26
+ --pressroom-danger: #f87171;
27
+ --pressroom-surface: #171a1f;
28
+ }
29
+ }
30
+
31
+ .pressroom-admin__header {
32
+ display: flex;
33
+ align-items: center;
34
+ gap: 24px;
35
+ padding: 12px 24px;
36
+ border-bottom: 1px solid var(--pressroom-line);
37
+ }
38
+
39
+ .pressroom-admin__brand {
40
+ font-weight: 600;
41
+ text-decoration: none;
42
+ color: inherit;
43
+ }
44
+
45
+ .pressroom-admin__nav { display: flex; gap: 16px; }
46
+
47
+ .pressroom-admin__nav-link {
48
+ color: var(--pressroom-muted);
49
+ text-decoration: none;
50
+ }
51
+
52
+ .pressroom-admin__nav-link:hover { color: var(--pressroom-fg); }
53
+
54
+ .pressroom-admin__main {
55
+ max-width: 860px;
56
+ margin: 0 auto;
57
+ padding: 24px;
58
+ }
59
+
60
+ .pressroom-toolbar {
61
+ display: flex;
62
+ align-items: center;
63
+ justify-content: space-between;
64
+ gap: 16px;
65
+ margin-bottom: 16px;
66
+ }
67
+
68
+ .pressroom-toolbar__title { margin: 0; font-size: 22px; }
69
+ .pressroom-toolbar__actions { display: flex; gap: 8px; }
70
+
71
+ .pressroom-button {
72
+ display: inline-block;
73
+ padding: 7px 14px;
74
+ border: 1px solid var(--pressroom-line);
75
+ border-radius: var(--pressroom-radius);
76
+ background: var(--pressroom-surface);
77
+ color: inherit;
78
+ font: inherit;
79
+ text-decoration: none;
80
+ cursor: pointer;
81
+ }
82
+
83
+ .pressroom-button:hover { border-color: var(--pressroom-muted); }
84
+
85
+ .pressroom-button--primary {
86
+ background: var(--pressroom-accent);
87
+ border-color: var(--pressroom-accent);
88
+ color: #fff;
89
+ }
90
+
91
+ .pressroom-button--danger { color: var(--pressroom-danger); }
92
+
93
+ .pressroom-filters { display: flex; gap: 12px; margin-bottom: 16px; }
94
+
95
+ .pressroom-filters__link {
96
+ color: var(--pressroom-muted);
97
+ text-decoration: none;
98
+ text-transform: capitalize;
99
+ }
100
+
101
+ .pressroom-table { width: 100%; border-collapse: collapse; }
102
+
103
+ .pressroom-table__heading {
104
+ text-align: left;
105
+ padding: 8px 10px;
106
+ border-bottom: 1px solid var(--pressroom-line);
107
+ font-size: 12px;
108
+ text-transform: uppercase;
109
+ letter-spacing: 0.04em;
110
+ color: var(--pressroom-muted);
111
+ }
112
+
113
+ .pressroom-table__cell {
114
+ padding: 10px;
115
+ border-bottom: 1px solid var(--pressroom-line);
116
+ vertical-align: middle;
117
+ }
118
+
119
+ .pressroom-badge {
120
+ display: inline-block;
121
+ padding: 1px 8px;
122
+ border-radius: 999px;
123
+ background: var(--pressroom-surface);
124
+ border: 1px solid var(--pressroom-line);
125
+ font-size: 12px;
126
+ }
127
+
128
+ .pressroom-badge--published { border-color: var(--pressroom-accent); }
129
+ .pressroom-badge--scheduled { border-style: dashed; }
130
+
131
+ .pressroom-form { display: flex; flex-direction: column; gap: 16px; }
132
+
133
+ .pressroom-field { display: flex; flex-direction: column; gap: 6px; }
134
+
135
+ .pressroom-field__label { font-size: 13px; color: var(--pressroom-muted); }
136
+
137
+ .pressroom-field__input {
138
+ width: 100%;
139
+ padding: 8px 10px;
140
+ border: 1px solid var(--pressroom-line);
141
+ border-radius: var(--pressroom-radius);
142
+ background: var(--pressroom-bg);
143
+ color: inherit;
144
+ font: inherit;
145
+ box-sizing: border-box;
146
+ }
147
+
148
+ .pressroom-editor {
149
+ border: 1px solid var(--pressroom-line);
150
+ border-radius: var(--pressroom-radius);
151
+ padding: 8px 16px;
152
+ background: var(--pressroom-bg);
153
+ }
154
+
155
+ /* Editor.js injects its own styles; this only sizes the container. */
156
+ .pressroom-editor__holder { min-height: 240px; }
157
+
158
+ .pressroom-schedule {
159
+ margin-bottom: 20px;
160
+ padding: 12px 16px;
161
+ border: 1px solid var(--pressroom-line);
162
+ border-radius: var(--pressroom-radius);
163
+ background: var(--pressroom-surface);
164
+ }
165
+
166
+ .pressroom-schedule__summary { cursor: pointer; }
167
+
168
+ .pressroom-schedule__form {
169
+ display: flex;
170
+ gap: 8px;
171
+ align-items: center;
172
+ margin-top: 10px;
173
+ }
174
+
175
+ .pressroom-schedule__hint {
176
+ margin: 8px 0 0;
177
+ font-size: 13px;
178
+ color: var(--pressroom-muted);
179
+ }
180
+
181
+ .pressroom-errors {
182
+ margin: 0;
183
+ padding: 12px 16px 12px 32px;
184
+ border: 1px solid var(--pressroom-danger);
185
+ border-radius: var(--pressroom-radius);
186
+ color: var(--pressroom-danger);
187
+ }
188
+
189
+ .pressroom-errors__item { margin: 2px 0; }
190
+
191
+ .pressroom-flash { max-width: 860px; margin: 12px auto 0; padding: 0 24px; }
192
+
193
+ .pressroom-flash__message {
194
+ margin: 0 0 8px;
195
+ padding: 8px 12px;
196
+ border-radius: var(--pressroom-radius);
197
+ background: var(--pressroom-surface);
198
+ border: 1px solid var(--pressroom-line);
199
+ }
200
+
201
+ .pressroom-flash__message--alert { border-color: var(--pressroom-danger); }
202
+
203
+ .pressroom-pagination { display: flex; gap: 6px; margin-top: 16px; }
204
+
205
+ .pressroom-pagination__link {
206
+ padding: 4px 9px;
207
+ border: 1px solid var(--pressroom-line);
208
+ border-radius: var(--pressroom-radius);
209
+ color: inherit;
210
+ text-decoration: none;
211
+ }
212
+
213
+ .pressroom-pagination__link--current {
214
+ border-color: var(--pressroom-accent);
215
+ font-weight: 600;
216
+ }
@@ -0,0 +1,18 @@
1
+ /* Optional. Structural rules only -- the ones without which a rendered
2
+ block is broken rather than merely unstyled. Typography, colour and
3
+ spacing belong to the host application's design, not to this gem. */
4
+
5
+ .pressroom-image__img {
6
+ max-width: 100%;
7
+ height: auto;
8
+ display: block;
9
+ }
10
+
11
+ .pressroom-image--stretched .pressroom-image__img { width: 100%; }
12
+
13
+ .pressroom-code {
14
+ overflow-x: auto;
15
+ max-width: 100%;
16
+ }
17
+
18
+ .pressroom-list__nested { margin-left: 1.25em; }
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pressroom
4
+ # Base controller for the admin.
5
+ #
6
+ # Inherits from the host application's controller so that its layout,
7
+ # helpers, current_user and middleware apply here too. The constant is
8
+ # resolved in the class body: initializers run before controllers are
9
+ # autoloaded, so configuration is already in place.
10
+ class ApplicationController < Pressroom.config.parent_controller.constantize
11
+ layout "pressroom/admin"
12
+
13
+ before_action :authenticate_pressroom_editor!
14
+
15
+ private
16
+
17
+ def authenticate_pressroom_editor!
18
+ hook = Pressroom.config.authenticate_with
19
+
20
+ raise Pressroom::ConfigurationError, missing_hook_message if hook.nil?
21
+
22
+ instance_exec(&hook)
23
+ end
24
+
25
+ # Failing closed is deliberate: an admin that opened to everyone until
26
+ # someone remembered to configure it would be a publishing panel left
27
+ # unlocked in production.
28
+ def missing_hook_message
29
+ <<~MESSAGE
30
+ Pressroom refuses to open its admin because no authentication hook
31
+ is configured. Add one to config/initializers/pressroom.rb:
32
+
33
+ Pressroom.configure do |config|
34
+ config.authenticate_with do
35
+ redirect_to main_app.root_path unless current_user&.editor?
36
+ end
37
+ end
38
+ MESSAGE
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pressroom
4
+ class CategoriesController < ApplicationController
5
+ before_action :set_category, only: %i[edit update destroy]
6
+
7
+ def index
8
+ @categories = Category.order(:name)
9
+ end
10
+
11
+ def new
12
+ @category = Category.new
13
+ end
14
+
15
+ def edit; end
16
+
17
+ def create
18
+ @category = Category.new(category_params)
19
+
20
+ if @category.save
21
+ redirect_to categories_path, notice: t("pressroom.categories.created")
22
+ else
23
+ render :new, status: 422
24
+ end
25
+ end
26
+
27
+ def update
28
+ if @category.update(category_params)
29
+ redirect_to categories_path, notice: t("pressroom.categories.updated")
30
+ else
31
+ render :edit, status: 422
32
+ end
33
+ end
34
+
35
+ def destroy
36
+ @category.destroy!
37
+ redirect_to categories_path, notice: t("pressroom.categories.destroyed")
38
+ end
39
+
40
+ private
41
+
42
+ def set_category
43
+ @category = Category.find(params[:id])
44
+ end
45
+
46
+ def category_params
47
+ params.require(:category).permit(:name, :slug, :parent_id, :description)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pressroom
4
+ class PostsController < ApplicationController
5
+ before_action :set_post, only: %i[edit update destroy publish unpublish]
6
+
7
+ def index
8
+ @page = [params[:page].to_i, 1].max
9
+ @per_page = Pressroom.config.admin_per_page
10
+
11
+ scope = Post.includes(:category).recent
12
+ scope = scope.where(status: params[:status]) if Post.statuses.key?(params[:status])
13
+
14
+ @total = scope.count
15
+ @posts = scope.offset((@page - 1) * @per_page).limit(@per_page)
16
+ end
17
+
18
+ def new
19
+ @post = Post.new
20
+ end
21
+
22
+ def edit; end
23
+
24
+ def create
25
+ @post = Post.new(post_params)
26
+
27
+ if @post.save
28
+ redirect_to edit_post_path(@post), notice: t("pressroom.posts.created")
29
+ else
30
+ # Numeric rather than :unprocessable_entity: Rack deprecated that
31
+ # name, and its replacement is not available on every Rack version
32
+ # this gem supports.
33
+ render :new, status: 422
34
+ end
35
+ end
36
+
37
+ def update
38
+ if @post.update(post_params)
39
+ redirect_to edit_post_path(@post), notice: t("pressroom.posts.updated")
40
+ else
41
+ render :edit, status: 422
42
+ end
43
+ end
44
+
45
+ def destroy
46
+ @post.destroy!
47
+ redirect_to posts_path, notice: t("pressroom.posts.destroyed")
48
+ end
49
+
50
+ # Manual publication, and scheduling, are the same action: the only
51
+ # difference is whether the timestamp is in the future. Nothing polls
52
+ # and nothing is enqueued -- Post.published compares against the clock.
53
+ def publish
54
+ @post.update!(status: :published, published_at: requested_publish_time)
55
+
56
+ notice = @post.published? ? t("pressroom.posts.published") : t("pressroom.posts.scheduled")
57
+ redirect_to edit_post_path(@post), notice: notice
58
+ end
59
+
60
+ def unpublish
61
+ @post.update!(status: :draft, published_at: nil)
62
+ redirect_to edit_post_path(@post), notice: t("pressroom.posts.unpublished")
63
+ end
64
+
65
+ private
66
+
67
+ def set_post
68
+ @post = Post.find(params[:id])
69
+ end
70
+
71
+ def requested_publish_time
72
+ raw = params[:published_at]
73
+ return Time.current if raw.blank?
74
+
75
+ Time.zone.parse(raw.to_s) || Time.current
76
+ rescue ArgumentError
77
+ Time.current
78
+ end
79
+
80
+ def post_params
81
+ permitted = params.require(:post)
82
+ .permit(:title, :slug, :excerpt, :category_id, :status,
83
+ :published_at, :tag_names, :content)
84
+
85
+ permitted.merge(content: parse_content(permitted[:content]))
86
+ end
87
+
88
+ # The editor submits its document as a JSON string in a hidden field.
89
+ # A malformed payload must not lose the rest of the form.
90
+ def parse_content(raw)
91
+ return Post::EMPTY_DOCUMENT.deep_dup if raw.blank?
92
+
93
+ JSON.parse(raw)
94
+ rescue JSON::ParserError
95
+ Post::EMPTY_DOCUMENT.deep_dup
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pressroom
4
+ class TagsController < ApplicationController
5
+ before_action :set_tag, only: %i[edit update destroy]
6
+
7
+ def index
8
+ @tags = Tag.left_joins(:taggings)
9
+ .group(:id)
10
+ .select("pressroom_tags.*, COUNT(pressroom_taggings.id) AS posts_count")
11
+ .order(:name)
12
+ end
13
+
14
+ def edit; end
15
+
16
+ def update
17
+ if @tag.update(tag_params)
18
+ redirect_to tags_path, notice: t("pressroom.tags.updated")
19
+ else
20
+ render :edit, status: 422
21
+ end
22
+ end
23
+
24
+ def destroy
25
+ @tag.destroy!
26
+ redirect_to tags_path, notice: t("pressroom.tags.destroyed")
27
+ end
28
+
29
+ private
30
+
31
+ def set_tag
32
+ @tag = Tag.find(params[:id])
33
+ end
34
+
35
+ def tag_params
36
+ params.require(:tag).permit(:name, :slug)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pressroom
4
+ # Upload endpoint for @editorjs/image.
5
+ #
6
+ # Answers the tool's contract: { success: 1, file: { url: ... } } on
7
+ # success, { success: 0, message: ... } on refusal.
8
+ class UploadsController < ApplicationController
9
+ def create
10
+ return head :not_found unless Pressroom.active_storage?
11
+
12
+ file = params[:image]
13
+ return head :bad_request if file.blank?
14
+
15
+ error = rejection_reason(file)
16
+ return refuse(error) if error
17
+
18
+ blob = ActiveStorage::Blob.create_and_upload!(
19
+ io: file.tempfile,
20
+ filename: file.original_filename,
21
+ content_type: file.content_type
22
+ )
23
+
24
+ render json: { success: 1, file: { url: blob_url(blob) } }
25
+ rescue StandardError => e
26
+ Rails.logger.warn("pressroom: upload failed: #{e.message}")
27
+ refuse("upload failed")
28
+ end
29
+
30
+ private
31
+
32
+ def rejection_reason(file)
33
+ unless Pressroom.config.image_content_types.include?(file.content_type)
34
+ return "#{file.content_type} is not an allowed image type"
35
+ end
36
+
37
+ return "file is too large" if file.size > Pressroom.config.max_image_bytes
38
+
39
+ nil
40
+ end
41
+
42
+ def refuse(message)
43
+ render json: { success: 0, message: message }, status: 422
44
+ end
45
+
46
+ # Built through the host's routes: ActiveStorage lives in the main
47
+ # application, not in this engine.
48
+ def blob_url(blob)
49
+ Rails.application.routes.url_helpers.rails_blob_path(blob, only_path: true)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,4 @@
1
+ module Pressroom
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pressroom
4
+ # Public API for host applications: renders a stored document.
5
+ #
6
+ # <%= render_blocks @post %>
7
+ module BlocksHelper
8
+ def render_blocks(source)
9
+ document = source.respond_to?(:content) ? source.content : source
10
+ Pressroom.renderer.render(document, self)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,4 @@
1
+ module Pressroom
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module Pressroom
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pressroom
4
+ # Attaches the configurable author association.
5
+ #
6
+ # The author class is the host application's, so it may not be configured
7
+ # at all. Declaring belongs_to with a blank class_name in that case leaves
8
+ # a landmine: the association resolves lazily and blows up with a
9
+ # NoMethodError from deep inside ActiveRecord the first time anything
10
+ # touches post.author.
11
+ module Authorable
12
+ extend ActiveSupport::Concern
13
+
14
+ included do
15
+ if Pressroom.config.author_class.present?
16
+ belongs_to :author, class_name: Pressroom.config.author_class, optional: true
17
+ else
18
+ define_method(:author) { nil }
19
+ define_method(:author=) { |_record| nil }
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pressroom
4
+ # Slug generation that survives non-Latin titles.
5
+ #
6
+ # ActiveSupport's parameterize drops Cyrillic entirely, so a Russian
7
+ # title would otherwise yield an empty slug. Transliteration runs first,
8
+ # through a configurable hook, using the rules the gem carries for :ru
9
+ # and :uk.
10
+ module Sluggable
11
+ extend ActiveSupport::Concern
12
+
13
+ FALLBACK_SLUG = "post"
14
+
15
+ included do
16
+ before_validation :assign_slug
17
+ validates :slug, presence: true, uniqueness: true
18
+ end
19
+
20
+ class_methods do
21
+ def slugify(string)
22
+ locale = Pressroom.config.transliteration_locale || I18n.locale
23
+ latin = Pressroom.config.transliterator.call(string.to_s, locale)
24
+
25
+ latin.downcase
26
+ .gsub(/[^a-z0-9]+/, "-")
27
+ .gsub(/\A-+|-+\z/, "")
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ # The source column for the slug. Models including this concern override
34
+ # it if they are not titled.
35
+ def slug_source
36
+ title
37
+ end
38
+
39
+ def assign_slug
40
+ return if slug.present?
41
+
42
+ base = self.class.slugify(slug_source)
43
+ base = FALLBACK_SLUG if base.empty?
44
+
45
+ self.slug = unique_slug(base)
46
+ end
47
+
48
+ def unique_slug(base)
49
+ candidate = base
50
+ suffix = 1
51
+
52
+ while slug_taken?(candidate)
53
+ suffix += 1
54
+ candidate = "#{base}-#{suffix}"
55
+ end
56
+
57
+ candidate
58
+ end
59
+
60
+ def slug_taken?(candidate)
61
+ scope = self.class.where(slug: candidate)
62
+ scope = scope.where.not(id: id) if persisted?
63
+ scope.exists?
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pressroom
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pressroom
4
+ class Category < ApplicationRecord
5
+ include Pressroom::Sluggable
6
+
7
+ self.table_name = "pressroom_categories"
8
+
9
+ belongs_to :parent, class_name: "Pressroom::Category", optional: true
10
+ has_many :children, class_name: "Pressroom::Category", foreign_key: :parent_id,
11
+ dependent: :nullify, inverse_of: :parent
12
+ has_many :posts, class_name: "Pressroom::Post", dependent: :nullify,
13
+ inverse_of: :category
14
+
15
+ validates :name, presence: true
16
+ validate :parent_is_not_a_cycle
17
+
18
+ scope :roots, -> { where(parent_id: nil) }
19
+
20
+ # Nearest ancestor first, root last.
21
+ def ancestors
22
+ result = []
23
+ node = parent
24
+
25
+ while node && !result.include?(node)
26
+ result << node
27
+ node = node.parent
28
+ end
29
+
30
+ result
31
+ end
32
+
33
+ def depth
34
+ ancestors.size
35
+ end
36
+
37
+ private
38
+
39
+ def slug_source
40
+ name
41
+ end
42
+
43
+ def parent_is_not_a_cycle
44
+ return if parent_id.nil?
45
+
46
+ if parent_id == id
47
+ errors.add(:parent_id, "cannot be the category itself")
48
+ return
49
+ end
50
+
51
+ errors.add(:parent_id, "would create a cycle") if parent&.ancestors&.include?(self)
52
+ end
53
+ end
54
+ end