pressroom 0.1.0 → 0.1.1
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 +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +30 -0
- data/app/controllers/pressroom/application_controller.rb +19 -0
- data/app/controllers/pressroom/categories_controller.rb +2 -2
- data/app/controllers/pressroom/posts_controller.rb +2 -2
- data/app/controllers/pressroom/tags_controller.rb +2 -2
- data/app/models/concerns/pressroom/scoped.rb +58 -0
- data/app/models/concerns/pressroom/sluggable.rb +9 -4
- data/app/models/pressroom/category.rb +1 -0
- data/app/models/pressroom/post.rb +1 -0
- data/app/models/pressroom/tag.rb +2 -1
- data/db/migrate/20260826000001_add_publication_scope_to_pressroom.rb +37 -0
- data/lib/pressroom/configuration.rb +13 -1
- data/lib/pressroom/current.rb +14 -0
- data/lib/pressroom/version.rb +1 -1
- data/lib/pressroom.rb +18 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 12fe77175ce5888714de37ba6c8e8cd10515eb641a26e37e72514a7b68f2d3fd
|
|
4
|
+
data.tar.gz: 718322e16e261511549bf9367bc1ba7bf0aa81996dbbd6393febd52d2453a60d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1aef309f2b3a93f37ea6a94939490aefd9e9f39dfe80bd0589cbbb0daaf82828d9df914ec2289d2b9dd643a00f1ded9d5da726fbc208c96849d51b539898ad4b
|
|
7
|
+
data.tar.gz: fd1d8f777bff8723a11e4da697e1b9724c146d0c0bf15eb76d1b9eb536586483a5737211da721ce82866d8ee027e087b70a624a45b9ba906a7c2526b24ffc48a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
- Publications: posts, categories and tags may belong to an owner, so one
|
|
6
|
+
application can host several separate blogs. Slugs are unique within a
|
|
7
|
+
publication rather than globally, which is what lets two of them each
|
|
8
|
+
have a `hello-world`.
|
|
9
|
+
- `Pressroom.with_scope` and `Post.in_scope` for working inside one
|
|
10
|
+
publication, and a `config.current_scope` hook so the admin knows which
|
|
11
|
+
one it is looking at.
|
|
12
|
+
- Nothing changes for an application with a single blog. Existing rows get
|
|
13
|
+
the empty scope, which means exactly what they meant before, and an
|
|
14
|
+
install that never configures `current_scope` behaves as it did in 0.1.0.
|
|
15
|
+
|
|
16
|
+
The empty scope is stored as `""` and `0` rather than nulls on purpose:
|
|
17
|
+
Postgres treats two nulls as distinct, so a nullable pair in the unique
|
|
18
|
+
index would have let two posts share a slug in the unscoped publication.
|
|
19
|
+
|
|
3
20
|
## 0.1.0
|
|
4
21
|
|
|
5
22
|
First release.
|
data/README.md
CHANGED
|
@@ -45,6 +45,36 @@ Pressroom.configure do |config|
|
|
|
45
45
|
end
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
+
## Several publications in one application
|
|
49
|
+
|
|
50
|
+
By default an application has one blog and nothing below applies.
|
|
51
|
+
|
|
52
|
+
To host more than one — a blog per customer site, say — tell Pressroom which
|
|
53
|
+
publication the admin is looking at:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
Pressroom.configure do |config|
|
|
57
|
+
config.current_scope { current_user.website }
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Posts, categories and tags then belong to that owner, and slugs are unique
|
|
62
|
+
within a publication rather than globally, so two of them may each have a
|
|
63
|
+
`hello-world`.
|
|
64
|
+
|
|
65
|
+
Outside the admin, work inside one publication explicitly:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
Pressroom.with_scope(website) do
|
|
69
|
+
Pressroom::Post.create!(title: "Hello")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
Pressroom::Post.in_scope(website).published.recent
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Existing installs need no changes: rows written before publications existed
|
|
76
|
+
carry the empty scope, which is what `in_scope(nil)` returns.
|
|
77
|
+
|
|
48
78
|
Pressroom **fails closed**: an admin with no `authenticate_with` hook raises
|
|
49
79
|
rather than letting anyone in.
|
|
50
80
|
|
|
@@ -11,9 +11,28 @@ module Pressroom
|
|
|
11
11
|
layout "pressroom/admin"
|
|
12
12
|
|
|
13
13
|
before_action :authenticate_pressroom_editor!
|
|
14
|
+
around_action :within_pressroom_scope
|
|
14
15
|
|
|
15
16
|
private
|
|
16
17
|
|
|
18
|
+
# Everything the admin touches happens inside one publication. An install
|
|
19
|
+
# that never configures a scope keeps working exactly as before, because
|
|
20
|
+
# the resolved scope is then nil.
|
|
21
|
+
def within_pressroom_scope(&block)
|
|
22
|
+
Pressroom.with_scope(resolved_pressroom_scope, &block)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def resolved_pressroom_scope
|
|
26
|
+
hook = Pressroom.config.current_scope
|
|
27
|
+
hook ? instance_exec(&hook) : nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Records the admin may act on, narrowed to the publication in hand.
|
|
31
|
+
def pressroom_scoped(relation)
|
|
32
|
+
relation.in_scope(Pressroom.current_scope)
|
|
33
|
+
end
|
|
34
|
+
helper_method :pressroom_scoped
|
|
35
|
+
|
|
17
36
|
def authenticate_pressroom_editor!
|
|
18
37
|
hook = Pressroom.config.authenticate_with
|
|
19
38
|
|
|
@@ -5,7 +5,7 @@ module Pressroom
|
|
|
5
5
|
before_action :set_category, only: %i[edit update destroy]
|
|
6
6
|
|
|
7
7
|
def index
|
|
8
|
-
@categories = Category.order(:name)
|
|
8
|
+
@categories = pressroom_scoped(Category).order(:name)
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def new
|
|
@@ -40,7 +40,7 @@ module Pressroom
|
|
|
40
40
|
private
|
|
41
41
|
|
|
42
42
|
def set_category
|
|
43
|
-
@category = Category.find(params[:id])
|
|
43
|
+
@category = pressroom_scoped(Category).find(params[:id])
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def category_params
|
|
@@ -8,7 +8,7 @@ module Pressroom
|
|
|
8
8
|
@page = [params[:page].to_i, 1].max
|
|
9
9
|
@per_page = Pressroom.config.admin_per_page
|
|
10
10
|
|
|
11
|
-
scope = Post.includes(:category).recent
|
|
11
|
+
scope = pressroom_scoped(Post).includes(:category).recent
|
|
12
12
|
scope = scope.where(status: params[:status]) if Post.statuses.key?(params[:status])
|
|
13
13
|
|
|
14
14
|
@total = scope.count
|
|
@@ -65,7 +65,7 @@ module Pressroom
|
|
|
65
65
|
private
|
|
66
66
|
|
|
67
67
|
def set_post
|
|
68
|
-
@post = Post.find(params[:id])
|
|
68
|
+
@post = pressroom_scoped(Post).find(params[:id])
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
def requested_publish_time
|
|
@@ -5,7 +5,7 @@ module Pressroom
|
|
|
5
5
|
before_action :set_tag, only: %i[edit update destroy]
|
|
6
6
|
|
|
7
7
|
def index
|
|
8
|
-
@tags = Tag.left_joins(:taggings)
|
|
8
|
+
@tags = pressroom_scoped(Tag).left_joins(:taggings)
|
|
9
9
|
.group(:id)
|
|
10
10
|
.select("pressroom_tags.*, COUNT(pressroom_taggings.id) AS posts_count")
|
|
11
11
|
.order(:name)
|
|
@@ -29,7 +29,7 @@ module Pressroom
|
|
|
29
29
|
private
|
|
30
30
|
|
|
31
31
|
def set_tag
|
|
32
|
-
@tag = Tag.find(params[:id])
|
|
32
|
+
@tag = pressroom_scoped(Tag).find(params[:id])
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def tag_params
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pressroom
|
|
4
|
+
# Belonging to one publication among several.
|
|
5
|
+
#
|
|
6
|
+
# An application that hosts a single blog never touches any of this: the
|
|
7
|
+
# empty scope is the default, and every query without an explicit scope
|
|
8
|
+
# behaves exactly as it did before publications existed.
|
|
9
|
+
module Scoped
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
|
|
12
|
+
NO_SCOPE_TYPE = ""
|
|
13
|
+
NO_SCOPE_ID = 0
|
|
14
|
+
|
|
15
|
+
included do
|
|
16
|
+
before_validation :apply_current_scope
|
|
17
|
+
|
|
18
|
+
scope :in_scope, lambda { |owner|
|
|
19
|
+
where(scope_type: Scoped.type_for(owner), scope_id: Scoped.id_for(owner))
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class_methods do
|
|
24
|
+
# Where a new record lands when the caller says nothing.
|
|
25
|
+
def default_scope_owner = Pressroom.current_scope
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.type_for(owner) = owner ? owner.class.polymorphic_name : NO_SCOPE_TYPE
|
|
29
|
+
|
|
30
|
+
def self.id_for(owner) = owner ? owner.id : NO_SCOPE_ID
|
|
31
|
+
|
|
32
|
+
def scope
|
|
33
|
+
return nil unless scoped?
|
|
34
|
+
|
|
35
|
+
scope_type.constantize.find_by(id: scope_id)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def scope=(owner)
|
|
39
|
+
self.scope_type = Scoped.type_for(owner)
|
|
40
|
+
self.scope_id = Scoped.id_for(owner)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def scoped?
|
|
44
|
+
scope_type.present? && scope_id.to_i.positive?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# Only fills in a scope the caller left alone, so an explicit one always
|
|
50
|
+
# wins over the ambient one.
|
|
51
|
+
def apply_current_scope
|
|
52
|
+
return if scoped?
|
|
53
|
+
return if scope_type_changed? || scope_id_changed?
|
|
54
|
+
|
|
55
|
+
self.scope = self.class.default_scope_owner
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -14,7 +14,9 @@ module Pressroom
|
|
|
14
14
|
|
|
15
15
|
included do
|
|
16
16
|
before_validation :assign_slug
|
|
17
|
-
|
|
17
|
+
# Scoped to the publication: two blogs in one application may each have
|
|
18
|
+
# a post called "hello-world".
|
|
19
|
+
validates :slug, presence: true, uniqueness: { scope: %i[scope_type scope_id] }
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
class_methods do
|
|
@@ -57,10 +59,13 @@ module Pressroom
|
|
|
57
59
|
candidate
|
|
58
60
|
end
|
|
59
61
|
|
|
62
|
+
# Collisions only matter inside one publication, so the search is narrowed
|
|
63
|
+
# the same way the uniqueness validation is.
|
|
60
64
|
def slug_taken?(candidate)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
65
|
+
taken = self.class.where(slug: candidate)
|
|
66
|
+
taken = taken.where(scope_type: scope_type, scope_id: scope_id) if respond_to?(:scope_type)
|
|
67
|
+
taken = taken.where.not(id: id) if persisted?
|
|
68
|
+
taken.exists?
|
|
64
69
|
end
|
|
65
70
|
end
|
|
66
71
|
end
|
data/app/models/pressroom/tag.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Pressroom
|
|
4
4
|
class Tag < ApplicationRecord
|
|
5
|
+
include Pressroom::Scoped
|
|
5
6
|
include Pressroom::Sluggable
|
|
6
7
|
|
|
7
8
|
self.table_name = "pressroom_tags"
|
|
@@ -18,7 +19,7 @@ module Pressroom
|
|
|
18
19
|
.map { |name| name.to_s.strip }
|
|
19
20
|
.reject(&:empty?)
|
|
20
21
|
.uniq
|
|
21
|
-
.map { |name| find_or_create_by!(slug: slugify(name)) { |tag| tag.name = name } }
|
|
22
|
+
.map { |name| in_scope(default_scope_owner).find_or_create_by!(slug: slugify(name)) { |tag| tag.name = name } }
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
private
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Lets one application host more than one publication.
|
|
4
|
+
#
|
|
5
|
+
# The empty pair -- "" and 0 rather than nulls -- means "the application's
|
|
6
|
+
# single publication", which is exactly what every 0.1.0 row already is. Nulls
|
|
7
|
+
# would have been the obvious choice and the wrong one: Postgres treats two
|
|
8
|
+
# nulls as distinct, so a nullable pair in the unique index would have allowed
|
|
9
|
+
# two posts to share a slug in that unscoped publication.
|
|
10
|
+
class AddPublicationScopeToPressroom < ActiveRecord::Migration[7.2]
|
|
11
|
+
TABLES = %i[pressroom_posts pressroom_categories pressroom_tags].freeze
|
|
12
|
+
|
|
13
|
+
def up
|
|
14
|
+
TABLES.each do |table|
|
|
15
|
+
add_column table, :scope_type, :string, null: false, default: ""
|
|
16
|
+
add_column table, :scope_id, :bigint, null: false, default: 0
|
|
17
|
+
|
|
18
|
+
remove_index table, :slug
|
|
19
|
+
add_index table, %i[scope_type scope_id slug], unique: true,
|
|
20
|
+
name: "index_#{table}_on_scope_and_slug"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
add_index :pressroom_posts, %i[scope_type scope_id status published_at],
|
|
24
|
+
name: "index_pressroom_posts_on_scope_and_status"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def down
|
|
28
|
+
remove_index :pressroom_posts, name: "index_pressroom_posts_on_scope_and_status"
|
|
29
|
+
|
|
30
|
+
TABLES.each do |table|
|
|
31
|
+
remove_index table, name: "index_#{table}_on_scope_and_slug"
|
|
32
|
+
add_index table, :slug, unique: true
|
|
33
|
+
remove_column table, :scope_type
|
|
34
|
+
remove_column table, :scope_id
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -22,11 +22,12 @@ module Pressroom
|
|
|
22
22
|
:unknown_block_strategy, :admin_per_page,
|
|
23
23
|
:image_content_types, :max_image_bytes
|
|
24
24
|
|
|
25
|
-
attr_writer :authenticate_with
|
|
25
|
+
attr_writer :authenticate_with, :current_scope
|
|
26
26
|
|
|
27
27
|
def initialize
|
|
28
28
|
@parent_controller = "ApplicationController"
|
|
29
29
|
@authenticate_with = nil
|
|
30
|
+
@current_scope = nil
|
|
30
31
|
@author_class = nil
|
|
31
32
|
@author_name_method = :name
|
|
32
33
|
@comments = false
|
|
@@ -49,6 +50,17 @@ module Pressroom
|
|
|
49
50
|
@authenticate_with
|
|
50
51
|
end
|
|
51
52
|
|
|
53
|
+
# Which publication the admin is looking at, evaluated in the controller
|
|
54
|
+
# so it can read params and the session. Left unset, the admin manages the
|
|
55
|
+
# application's single publication -- which is every install that has not
|
|
56
|
+
# asked for more than one.
|
|
57
|
+
#
|
|
58
|
+
# config.current_scope { current_user.website }
|
|
59
|
+
def current_scope(&block)
|
|
60
|
+
@current_scope = block if block
|
|
61
|
+
@current_scope
|
|
62
|
+
end
|
|
63
|
+
|
|
52
64
|
# Explicit configuration wins; otherwise fail loudly where a developer
|
|
53
65
|
# will see it and degrade quietly where users would.
|
|
54
66
|
def resolved_unknown_block_strategy
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/current_attributes"
|
|
4
|
+
|
|
5
|
+
module Pressroom
|
|
6
|
+
# The publication the current unit of work belongs to.
|
|
7
|
+
#
|
|
8
|
+
# CurrentAttributes rather than a thread variable so Rails resets it between
|
|
9
|
+
# requests and jobs on its own, which is the failure mode a hand-rolled
|
|
10
|
+
# thread local always eventually hits.
|
|
11
|
+
class Current < ActiveSupport::CurrentAttributes
|
|
12
|
+
attribute :scope
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/pressroom/version.rb
CHANGED
data/lib/pressroom.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "pressroom/version"
|
|
|
4
4
|
require "pressroom/errors"
|
|
5
5
|
require "pressroom/transliteration"
|
|
6
6
|
require "pressroom/configuration"
|
|
7
|
+
require "pressroom/current"
|
|
7
8
|
require "pressroom/blocks/schema"
|
|
8
9
|
require "pressroom/blocks/definition"
|
|
9
10
|
require "pressroom/blocks/registry"
|
|
@@ -34,6 +35,23 @@ module Pressroom
|
|
|
34
35
|
@registry ||= Blocks::Registry.new
|
|
35
36
|
end
|
|
36
37
|
|
|
38
|
+
# The publication the current unit of work belongs to. Nil means the
|
|
39
|
+
# application's single publication, which is what an app that never uses
|
|
40
|
+
# publications always sees.
|
|
41
|
+
def current_scope
|
|
42
|
+
Current.scope
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Runs a block against one publication. Nests, and restores the previous
|
|
46
|
+
# publication even when the block raises.
|
|
47
|
+
def with_scope(owner)
|
|
48
|
+
previous = Current.scope
|
|
49
|
+
Current.scope = owner
|
|
50
|
+
yield
|
|
51
|
+
ensure
|
|
52
|
+
Current.scope = previous
|
|
53
|
+
end
|
|
54
|
+
|
|
37
55
|
# Public API: registers a block type.
|
|
38
56
|
#
|
|
39
57
|
# Pressroom.register_block :callout do |b|
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pressroom
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex Andreiev
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-08-
|
|
10
|
+
date: 2026-08-26 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: rails
|
|
@@ -92,6 +92,7 @@ files:
|
|
|
92
92
|
- app/jobs/pressroom/application_job.rb
|
|
93
93
|
- app/mailers/pressroom/application_mailer.rb
|
|
94
94
|
- app/models/concerns/pressroom/authorable.rb
|
|
95
|
+
- app/models/concerns/pressroom/scoped.rb
|
|
95
96
|
- app/models/concerns/pressroom/sluggable.rb
|
|
96
97
|
- app/models/pressroom/application_record.rb
|
|
97
98
|
- app/models/pressroom/category.rb
|
|
@@ -127,6 +128,7 @@ files:
|
|
|
127
128
|
- config/routes.rb
|
|
128
129
|
- db/migrate/20260821000001_create_pressroom_posts.rb
|
|
129
130
|
- db/migrate/20260821000002_create_pressroom_taxonomy.rb
|
|
131
|
+
- db/migrate/20260826000001_add_publication_scope_to_pressroom.rb
|
|
130
132
|
- lib/pressroom.rb
|
|
131
133
|
- lib/pressroom/blocks/definition.rb
|
|
132
134
|
- lib/pressroom/blocks/registry.rb
|
|
@@ -136,6 +138,7 @@ files:
|
|
|
136
138
|
- lib/pressroom/blocks/text_extractor.rb
|
|
137
139
|
- lib/pressroom/built_in_blocks.rb
|
|
138
140
|
- lib/pressroom/configuration.rb
|
|
141
|
+
- lib/pressroom/current.rb
|
|
139
142
|
- lib/pressroom/engine.rb
|
|
140
143
|
- lib/pressroom/errors.rb
|
|
141
144
|
- lib/pressroom/feed.rb
|