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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f4713e4ba3f3eaae896ab1ad8b152749fa9c0be2a66cf11210a57fd43ba5c2d7
4
- data.tar.gz: cde078d6ebe1273874c9da4610b92866489f163e8b5fbeb4cd6feb1e58177640
3
+ metadata.gz: 12fe77175ce5888714de37ba6c8e8cd10515eb641a26e37e72514a7b68f2d3fd
4
+ data.tar.gz: 718322e16e261511549bf9367bc1ba7bf0aa81996dbbd6393febd52d2453a60d
5
5
  SHA512:
6
- metadata.gz: f712eaa9e76b6ffa4bdab60c5fb14e09d2609872d4fdbe2dd5ab8e7e7f247684a4cee01a8de66b3771fbac532381fcf2750fbfde534954ecfc8bbff5d84e2986
7
- data.tar.gz: 37524af128ab5f3d6f27516a41d51a1a5e52086396d3f3f169acf574f981364e20f191b8e0929803acdf0fcf43292d4b3e6c549f0c1ae129ecb069c2ed121d31
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
- validates :slug, presence: true, uniqueness: true
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
- scope = self.class.where(slug: candidate)
62
- scope = scope.where.not(id: id) if persisted?
63
- scope.exists?
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
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Pressroom
4
4
  class Category < ApplicationRecord
5
+ include Pressroom::Scoped
5
6
  include Pressroom::Sluggable
6
7
 
7
8
  self.table_name = "pressroom_categories"
@@ -8,6 +8,7 @@ module Pressroom
8
8
 
9
9
  self.table_name = "pressroom_posts"
10
10
 
11
+ include Pressroom::Scoped
11
12
  include Pressroom::Sluggable
12
13
 
13
14
  attribute :content, :json, default: -> { EMPTY_DOCUMENT.deep_dup }
@@ -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
@@ -1,3 +1,3 @@
1
1
  module Pressroom
2
- VERSION = "0.1.0"
2
+ VERSION = "0.1.1"
3
3
  end
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.0
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-21 00:00:00.000000000 Z
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