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,70 @@
1
+ ru:
2
+ i18n:
3
+ transliterate:
4
+ rule:
5
+ а: "a"
6
+ б: "b"
7
+ в: "v"
8
+ г: "g"
9
+ д: "d"
10
+ е: "e"
11
+ ё: "e"
12
+ ж: "zh"
13
+ з: "z"
14
+ и: "i"
15
+ й: "i"
16
+ к: "k"
17
+ л: "l"
18
+ м: "m"
19
+ н: "n"
20
+ о: "o"
21
+ п: "p"
22
+ р: "r"
23
+ с: "s"
24
+ т: "t"
25
+ у: "u"
26
+ ф: "f"
27
+ х: "h"
28
+ ц: "ts"
29
+ ч: "ch"
30
+ ш: "sh"
31
+ щ: "sch"
32
+ ъ: ""
33
+ ы: "y"
34
+ ь: ""
35
+ э: "e"
36
+ ю: "yu"
37
+ я: "ya"
38
+ А: "A"
39
+ Б: "B"
40
+ В: "V"
41
+ Г: "G"
42
+ Д: "D"
43
+ Е: "E"
44
+ Ё: "E"
45
+ Ж: "Zh"
46
+ З: "Z"
47
+ И: "I"
48
+ Й: "I"
49
+ К: "K"
50
+ Л: "L"
51
+ М: "M"
52
+ Н: "N"
53
+ О: "O"
54
+ П: "P"
55
+ Р: "R"
56
+ С: "S"
57
+ Т: "T"
58
+ У: "U"
59
+ Ф: "F"
60
+ Х: "H"
61
+ Ц: "Ts"
62
+ Ч: "Ch"
63
+ Ш: "Sh"
64
+ Щ: "Sch"
65
+ Ъ: ""
66
+ Ы: "Y"
67
+ Ь: ""
68
+ Э: "E"
69
+ Ю: "Yu"
70
+ Я: "Ya"
@@ -0,0 +1,70 @@
1
+ uk:
2
+ i18n:
3
+ transliterate:
4
+ rule:
5
+ а: "a"
6
+ б: "b"
7
+ в: "v"
8
+ г: "h"
9
+ ґ: "g"
10
+ д: "d"
11
+ е: "e"
12
+ є: "ie"
13
+ ж: "zh"
14
+ з: "z"
15
+ и: "y"
16
+ і: "i"
17
+ ї: "i"
18
+ й: "i"
19
+ к: "k"
20
+ л: "l"
21
+ м: "m"
22
+ н: "n"
23
+ о: "o"
24
+ п: "p"
25
+ р: "r"
26
+ с: "s"
27
+ т: "t"
28
+ у: "u"
29
+ ф: "f"
30
+ х: "kh"
31
+ ц: "ts"
32
+ ч: "ch"
33
+ ш: "sh"
34
+ щ: "shch"
35
+ ь: ""
36
+ ю: "iu"
37
+ я: "ia"
38
+ А: "A"
39
+ Б: "B"
40
+ В: "V"
41
+ Г: "H"
42
+ Ґ: "G"
43
+ Д: "D"
44
+ Е: "E"
45
+ Є: "Ie"
46
+ Ж: "Zh"
47
+ З: "Z"
48
+ И: "Y"
49
+ І: "I"
50
+ Ї: "I"
51
+ Й: "I"
52
+ К: "K"
53
+ Л: "L"
54
+ М: "M"
55
+ Н: "N"
56
+ О: "O"
57
+ П: "P"
58
+ Р: "R"
59
+ С: "S"
60
+ Т: "T"
61
+ У: "U"
62
+ Ф: "F"
63
+ Х: "Kh"
64
+ Ц: "Ts"
65
+ Ч: "Ch"
66
+ Ш: "Sh"
67
+ Щ: "Shch"
68
+ Ь: ""
69
+ Ю: "Iu"
70
+ Я: "Ia"
data/config/routes.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ Pressroom::Engine.routes.draw do
4
+ resources :posts, except: :show do
5
+ member do
6
+ patch :publish
7
+ patch :unpublish
8
+ end
9
+ end
10
+
11
+ resources :categories, except: :show
12
+ resources :tags, only: %i[index edit update destroy]
13
+
14
+ resource :upload, only: :create
15
+
16
+ root to: "posts#index"
17
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreatePressroomPosts < ActiveRecord::Migration[7.2]
4
+ def change
5
+ create_table :pressroom_posts do |t|
6
+ t.string :title, null: false
7
+ t.string :slug, null: false
8
+
9
+ # jsonb where it exists, text elsewhere; the model declares
10
+ # attribute :content, :json so both behave identically in Ruby.
11
+ if postgres?
12
+ t.jsonb :content, null: false, default: {}
13
+ else
14
+ t.text :content
15
+ end
16
+
17
+ t.text :plain_text
18
+ t.text :excerpt
19
+ t.string :status, null: false, default: "draft"
20
+ t.datetime :published_at
21
+
22
+ # No foreign key: the author class is configurable, so the target
23
+ # table is unknown at migration time.
24
+ t.bigint :author_id
25
+ t.bigint :category_id
26
+
27
+ t.timestamps
28
+ end
29
+
30
+ add_index :pressroom_posts, :slug, unique: true
31
+ add_index :pressroom_posts, :status
32
+ add_index :pressroom_posts, :published_at
33
+ add_index :pressroom_posts, :author_id
34
+ add_index :pressroom_posts, :category_id
35
+ end
36
+
37
+ private
38
+
39
+ def postgres?
40
+ connection.adapter_name.match?(/postg/i)
41
+ end
42
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreatePressroomTaxonomy < ActiveRecord::Migration[7.2]
4
+ def change
5
+ create_table :pressroom_categories do |t|
6
+ t.string :name, null: false
7
+ t.string :slug, null: false
8
+ t.bigint :parent_id
9
+ t.text :description
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :pressroom_categories, :slug, unique: true
14
+ add_index :pressroom_categories, :parent_id
15
+
16
+ create_table :pressroom_tags do |t|
17
+ t.string :name, null: false
18
+ t.string :slug, null: false
19
+ t.timestamps
20
+ end
21
+
22
+ add_index :pressroom_tags, :slug, unique: true
23
+
24
+ create_table :pressroom_taggings do |t|
25
+ t.bigint :post_id, null: false
26
+ t.bigint :tag_id, null: false
27
+ t.timestamps
28
+ end
29
+
30
+ add_index :pressroom_taggings, %i[post_id tag_id], unique: true
31
+ add_index :pressroom_taggings, :tag_id
32
+ end
33
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails-html-sanitizer"
4
+
5
+ module Pressroom
6
+ module Blocks
7
+ # One registered block type: how it is edited, rendered, and indexed.
8
+ #
9
+ # The reader/writer methods double as the registration DSL, so
10
+ # +b.partial "x"+ sets the value and +definition.partial+ reads it.
11
+ class Definition
12
+ FULL_SANITIZER = Rails::HTML4::FullSanitizer.new
13
+
14
+ attr_reader :type
15
+
16
+ def initialize(type)
17
+ @type = type.to_s
18
+ @schema = Schema.build
19
+ @tool = nil
20
+ @tool_config = {}
21
+ @partial = nil
22
+ @searchable = nil
23
+ end
24
+
25
+ def tool(name = nil, **config)
26
+ if name
27
+ @tool = name.to_s
28
+ @tool_config = config
29
+ end
30
+
31
+ @tool ||= default_tool_name
32
+ end
33
+
34
+ def tool_config
35
+ tool # ensure the default is materialized
36
+ @tool_config
37
+ end
38
+
39
+ def schema(&block)
40
+ @schema = Schema.build(&block) if block
41
+ @schema
42
+ end
43
+
44
+ def partial(path = nil)
45
+ @partial = path.to_s if path
46
+ @partial
47
+ end
48
+
49
+ def searchable(&block)
50
+ @searchable = block if block
51
+ @searchable
52
+ end
53
+
54
+ # Plain text for the host application's search index.
55
+ def text_for(data)
56
+ return @searchable.call(data).to_s if @searchable
57
+
58
+ default_text_for(data)
59
+ end
60
+
61
+ def validate!
62
+ return if @partial
63
+
64
+ raise Pressroom::Error, "block #{type.inspect} must declare a partial"
65
+ end
66
+
67
+ private
68
+
69
+ def default_tool_name
70
+ type.split("_").map { |part| part[0].to_s.upcase + part[1..].to_s }.join
71
+ end
72
+
73
+ def default_text_for(data)
74
+ parts = @schema.fields.each_value.flat_map do |field|
75
+ value = data[field.name]
76
+
77
+ case field.type
78
+ when :string, :html then [value]
79
+ when :array then Array(value)
80
+ when :tree then flatten_tree(value)
81
+ else []
82
+ end
83
+ end
84
+
85
+ parts.filter_map { |part| strip_tags(part) }.reject(&:empty?).join(" ")
86
+ end
87
+
88
+ def flatten_tree(items)
89
+ Array(items).flat_map do |item|
90
+ next [] unless item.is_a?(Hash)
91
+
92
+ [item["content"]] + flatten_tree(item["items"])
93
+ end
94
+ end
95
+
96
+ def strip_tags(value)
97
+ return nil if value.nil?
98
+
99
+ FULL_SANITIZER.sanitize(value.to_s).to_s.strip
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pressroom
4
+ module Blocks
5
+ # Ordered collection of block definitions.
6
+ #
7
+ # Registration order matters: it is the order tools appear in the
8
+ # editor's toolbar.
9
+ class Registry
10
+ include Enumerable
11
+
12
+ def initialize
13
+ @definitions = {}
14
+ end
15
+
16
+ def register(type, &block)
17
+ key = type.to_s
18
+
19
+ if @definitions.key?(key)
20
+ raise Pressroom::Error, "block #{key.inspect} is already registered"
21
+ end
22
+
23
+ definition = Definition.new(key)
24
+ block&.call(definition)
25
+ definition.validate!
26
+
27
+ @definitions[key] = definition
28
+ end
29
+
30
+ def fetch(type)
31
+ @definitions[type.to_s]
32
+ end
33
+ alias [] fetch
34
+
35
+ def key?(type)
36
+ @definitions.key?(type.to_s)
37
+ end
38
+
39
+ def types
40
+ @definitions.keys
41
+ end
42
+
43
+ def each(&block)
44
+ @definitions.each_value(&block)
45
+ end
46
+
47
+ def delete(type)
48
+ @definitions.delete(type.to_s)
49
+ end
50
+
51
+ def clear!
52
+ @definitions = {}
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/output_safety"
4
+
5
+ module Pressroom
6
+ module Blocks
7
+ # Walks a stored document and renders each block through its partial.
8
+ #
9
+ # The +strategy+ is injected rather than read from configuration so this
10
+ # layer stays independent of the engine. Callers pass
11
+ # Pressroom.config.resolved_unknown_block_strategy.
12
+ class Renderer
13
+ STRATEGIES = %i[raise skip].freeze
14
+
15
+ def initialize(registry:, sanitizer:, strategy: :raise, logger: nil)
16
+ @registry = registry
17
+ @sanitizer = sanitizer
18
+ @strategy = strategy
19
+ @logger = logger
20
+ end
21
+
22
+ def render(document, view_context)
23
+ blocks_of(document)
24
+ .filter_map { |block| render_block(block, view_context) }
25
+ .join
26
+ .html_safe
27
+ end
28
+
29
+ def blocks_of(document)
30
+ case document
31
+ when Array then document.select { |item| item.is_a?(Hash) }
32
+ when Hash then Array(document["blocks"] || document[:blocks])
33
+ .select { |item| item.is_a?(Hash) }
34
+ else []
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def render_block(block, view_context)
41
+ type = (block["type"] || block[:type]).to_s
42
+ definition = @registry.fetch(type)
43
+ return unknown(type) unless definition
44
+
45
+ data = safe_data(definition, block["data"] || block[:data])
46
+ view_context.render(partial: definition.partial,
47
+ locals: { data: data, block: block })
48
+ rescue Pressroom::UnknownBlockError
49
+ raise
50
+ rescue StandardError => e
51
+ raise e if @strategy == :raise
52
+
53
+ log("pressroom: failed to render block #{type.inspect}: #{e.message}")
54
+ nil
55
+ end
56
+
57
+ def safe_data(definition, raw)
58
+ data, errors = definition.schema.coerce(raw)
59
+
60
+ if errors.any?
61
+ message = "pressroom: invalid data for block #{definition.type.inspect}: " \
62
+ "#{errors.join(', ')}"
63
+ raise Pressroom::Error, message if @strategy == :raise
64
+
65
+ log(message)
66
+ end
67
+
68
+ # The schema owns sanitization dispatch: only it knows whether a
69
+ # field is a flat string, an array, a nested object or a tree.
70
+ definition.schema.sanitize(data, @sanitizer)
71
+ end
72
+
73
+ def unknown(type)
74
+ if @strategy == :raise
75
+ raise Pressroom::UnknownBlockError,
76
+ "no block registered for type #{type.inspect}"
77
+ end
78
+
79
+ log("pressroom: skipping unknown block type #{type.inspect}")
80
+ nil
81
+ end
82
+
83
+ # Accepts a real Rails logger or anything that appends, which keeps
84
+ # this layer free of a Rails.logger dependency and keeps the unit
85
+ # tests able to pass a plain Array.
86
+ def log(message)
87
+ if @logger.respond_to?(:warn)
88
+ @logger.warn(message)
89
+ elsif @logger.respond_to?(:<<)
90
+ @logger << message
91
+ end
92
+
93
+ nil
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails-html-sanitizer"
4
+ require "active_support/core_ext/string/output_safety"
5
+
6
+ module Pressroom
7
+ module Blocks
8
+ # Allow-list sanitizer applied to every field the schema marks as +html+.
9
+ #
10
+ # This is the single trust boundary between editor output and rendered
11
+ # pages. Everything else is escaped by ERB.
12
+ class Sanitizer
13
+ def self.backend_class
14
+ if Rails::HTML::Sanitizer.respond_to?(:html5_support?) &&
15
+ Rails::HTML::Sanitizer.html5_support?
16
+ Rails::HTML5::SafeListSanitizer
17
+ else
18
+ Rails::HTML4::SafeListSanitizer
19
+ end
20
+ end
21
+
22
+ def initialize(tags:, attributes:)
23
+ @tags = tags
24
+ @attributes = attributes
25
+ @backend = self.class.backend_class.new
26
+ end
27
+
28
+ def sanitize(value)
29
+ return "".html_safe if value.nil?
30
+
31
+ @backend.sanitize(value.to_s, tags: @tags, attributes: @attributes)
32
+ .to_s.html_safe
33
+ end
34
+ end
35
+ end
36
+ end