open_blog 0.2.0 → 0.3.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: 7dc9141447562952baf038f0a5a46bd4d6f300d7fd86d2f87f621ac4103ab22b
4
- data.tar.gz: bed8bf059533885de53c23f62ba7de2166f4b529846ef53614dc1d0d90740e4d
3
+ metadata.gz: 0473f44b17fe66c2fc03de0c142fa17bf8b139047b6b8ba1d5d9aff5bfe41a93
4
+ data.tar.gz: 5108c7a099c89c3353499bb2f719bc2e8fa67c9530cf34544e1fc0df11f79deb
5
5
  SHA512:
6
- metadata.gz: '08b3286b42ed0b5ed9ab2879946d7fb122743732b51f5fb7e711cb403bcbbd92e30ae0482fa5ed7629d644962076351563e60a07cc93aa47fc63533a3f3015e8'
7
- data.tar.gz: c81810d3ec0184a8283a3e9e5ce0d124f9bc762350a6d7bb2aa92c406825788dc3bee83384b2a4093f767aca5201835b48cd55ab7f3e813f2cb40cb228d399b8
6
+ metadata.gz: 21a997d93ee9f729af3ec4f14ebb287aca527d59cfa4f425c520156a19da7564f3f3c98c9680875f01afadfccf60fb1efc503a2a305f4274fab1cc75de06b11d
7
+ data.tar.gz: 31dbb1ca9b278348d3cc1405980db89649024b69788bc1983827dca9d8a998570389f96725be45edd83e7f92578cd7bdb2c0cdf2f20230737c2ba570d204ad2a
data/CHANGELOG.md CHANGED
@@ -7,10 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-10-20
11
+
12
+ ### Added
13
+
14
+ - Support hosts whose `active_storage_attachments.record_id` is a UUID. The migrations read that column and give `open_blog_images` and `open_blog_authors` UUID primary keys, with UUID `author_id`, `cover_image_id`, and `social_image_id` post references, when it is a UUID. Integer-keyed hosts keep integer keys. No host table changes and no setting.
15
+ - Add the `AlignOpenBlogRecordKeys` migration. It converts the image and author keys of a 0.2.0 installation on a UUID attachment table and keeps every row and reference; on an integer-keyed host it does nothing.
16
+ - Add the doctor **Record keys** check. It reports blog keys that cannot be attachment records, and, with rich-text bodies enabled, Action Text keys that do not match the post or attachment keys.
17
+ - Serve card thumbnails and author avatars from the engine route `/blog/media/:sha256/:variant/:filename` (`card` or `avatar`), delivered through `config.image_delivery` like the originals.
18
+
19
+ ### Changed
20
+
21
+ - Card and avatar images no longer use the host's Active Storage representation routes, so a host can keep `ActiveStorage::Representations::RedirectController` and `ProxyController` closed.
22
+ - On a UUID-keyed host, `image_id` and author `id` values in the JSON API and MCP tool schemas are UUID strings.
23
+
24
+ ### Upgrade notes
25
+
26
+ - Run `bin/rails railties:install:migrations FROM=open_blog` and `bin/rails db:migrate`; doctor reports an error until `AlignOpenBlogRecordKeys` has run. On an integer-keyed host the migration makes no schema change.
27
+ - Copied reader templates need no change: `open_blog_card_image` and `open_blog_avatar` emit the new URLs. A host template that builds its own variant URL with `main_app.url_for` still needs the host's representation routes.
28
+ - JSON stays below version 3: Rails 8.0 does not work with JSON 3.
29
+
10
30
  ## [0.2.0] - 2026-10-10
11
31
 
12
32
  ### Added
13
33
 
34
+ - Add a Usage guide and an existing-host integration recipe covering shared layouts, application colors, theme controls, image compatibility, and copied-file upgrades; expand the packaged install skill and document agent skill loading.
14
35
  - Add three reader presets, Signal, Editorial, and Ink, selected with `config.theme`. Each has light and dark values that meet WCAG 2.2 AA contrast.
15
36
  - Ship the preset fonts (Outfit, DM Sans, Newsreader, Space Grotesk, and JetBrains Mono) as woff2 files with their license texts. No font loads from another site.
16
37
  - Add `config.theme_colors` for hex color overrides of the preset, for both modes or per mode. Doctor warns when an override takes a text and background pair below 4.5:1; a translucent color is measured over the color under it.
data/README.md CHANGED
@@ -67,6 +67,12 @@ Start `bin/dev` (or `bin/rails server`) and open `/blog`. The generator installs
67
67
 
68
68
  Set your site name, publisher, and public origin in the initializer, then run `bin/rails open_blog:doctor` to check the setup. The [installation guide](https://techwright-lab.github.io/open-blog/installation/) lists every generator option, and the [configuration reference](https://techwright-lab.github.io/open-blog/configuration/) lists every setting with its default.
69
69
 
70
+ ## Usage
71
+
72
+ The [usage guide](https://techwright-lab.github.io/open-blog/usage/) explains the publishing interfaces, article lifecycle, and where to change each part of the blog. For an existing application, follow [host integration](https://techwright-lab.github.io/open-blog/host-integration/) to reuse its navbar/footer, map Signal to its colors, share its theme control, and verify images and upgrades. These visual customizations use host files and existing gem settings.
73
+
74
+ For an agent, locate the packaged `skills/open-blog-install/SKILL.md` with `bundle show open_blog`. The [agent workflow guide](https://techwright-lab.github.io/open-blog/mcp/#packaged-agent-workflows) explains how to load all six skills; connecting MCP alone does not install them.
75
+
70
76
  ## Publish a post
71
77
 
72
78
  **From Ruby:**
@@ -97,6 +103,7 @@ A later edit of a public post must carry `change: "substantive"`, `"correction"`
97
103
 
98
104
  | Guide | Covers |
99
105
  | --- | --- |
106
+ | [Usage](https://techwright-lab.github.io/open-blog/usage/) and [Host integration](https://techwright-lab.github.io/open-blog/host-integration/) | End-to-end workflow, file map, shared layout/colors/theme control, compatibility, and upgrades |
100
107
  | [Configuration](https://techwright-lab.github.io/open-blog/configuration/) | Identity, routes, content gates, images, limits, and page views |
101
108
  | [Publishing](https://techwright-lab.github.io/open-blog/publishing/) | Operations, previews, scheduling, approvals, and findings |
102
109
  | [JSON API](https://techwright-lab.github.io/open-blog/api/) and [MCP](https://techwright-lab.github.io/open-blog/mcp/) | Endpoints, tools, examples, and error codes |
@@ -50,7 +50,7 @@ module OpenBlog
50
50
  raise Error::UnknownField.new(details: unknown.map { |field| "avatar.#{field}" }) if unknown.any?
51
51
  invalid!(:avatar) unless input.size == 1
52
52
  if input.key?(:image_id)
53
- invalid!(:avatar) unless input[:image_id].is_a?(Integer)
53
+ invalid!(:avatar) unless RecordKeys.id?(Image, input[:image_id])
54
54
  [ nil, Image.find(input[:image_id]) ]
55
55
  else
56
56
  [ ImageImport.prepare(input), nil ]
@@ -4,16 +4,33 @@ module OpenBlog
4
4
  rescue_from ActiveStorage::FileNotFoundError, with: :render_not_found
5
5
 
6
6
  def show
7
+ image = attached_image
8
+ deliver(image.file, content_type: image.content_type, filename: image.filename)
9
+ end
10
+
11
+ def variant
12
+ image = attached_image
13
+ representation = image.file.variant(Image::VARIANTS.fetch(params[:variant])).processed
14
+ deliver(representation, content_type: representation.content_type, filename: image.filename)
15
+ end
16
+
17
+ private
18
+
19
+ def attached_image
7
20
  image = Image.find_by!(sha256: params[:sha256])
8
21
  raise NotFound unless image.file.attached?
22
+ image
23
+ end
24
+
25
+ def deliver(file, content_type:, filename:)
9
26
  if OpenBlog.config.image_delivery == :proxy
10
- bytes = image.file.download
27
+ bytes = file.download
11
28
  expires_in 365.days, public: true, immutable: true
12
- send_data bytes, type: image.content_type, filename: image.filename, disposition: "inline"
29
+ send_data bytes, type: content_type, filename: filename, disposition: "inline"
13
30
  else
14
31
  lifetime = [ ActiveStorage.service_urls_expire_in.to_i, 365.days.to_i ].min
15
32
  expires_in lifetime, public: true
16
- redirect_to image.file.blob.url(expires_in: lifetime, disposition: "inline"), allow_other_host: true
33
+ redirect_to file.url(expires_in: lifetime, disposition: "inline"), allow_other_host: true
17
34
  end
18
35
  end
19
36
  end
@@ -27,19 +27,26 @@ module OpenBlog
27
27
  def open_blog_card_image(post)
28
28
  image = post.cover_image
29
29
  return tag.div(class: "ob-card-placeholder", aria: { hidden: true }) unless image
30
- source = image.file.attached? ? main_app.url_for(image.file.variant(resize_to_fill: [ 640, 360 ])) : image.path
30
+ source = image.file.attached? ? image.variant_path(:card) : image.path
31
31
  tag.img(src: source, alt: post.cover_alt.to_s, width: 640, height: 360, loading: "lazy", class: "ob-card-image")
32
32
  end
33
33
 
34
34
  def open_blog_avatar(author, name: nil)
35
35
  name ||= author.name
36
36
  if author.avatar.attached?
37
- tag.img(src: main_app.url_for(author.avatar.variant(resize_to_fill: [ 80, 80 ])), alt: name, width: 40, height: 40, class: "ob-avatar", loading: "lazy")
37
+ tag.img(src: open_blog_avatar_source(author), alt: name, width: 40, height: 40, class: "ob-avatar", loading: "lazy")
38
38
  else
39
39
  tag.span(name.to_s.chars.first(2).join.upcase, class: "ob-avatar ob-avatar--initials", aria: { hidden: true })
40
40
  end
41
41
  end
42
42
 
43
+ def open_blog_avatar_source(author)
44
+ @open_blog_avatar_images ||= {}
45
+ blob = author.avatar.blob
46
+ image = @open_blog_avatar_images.fetch(blob.id) { @open_blog_avatar_images[blob.id] = ImageResolution.image_for_attachment(blob) }
47
+ image&.persisted? ? image.variant_path(:avatar) : main_app.url_for(author.avatar.variant(Image::VARIANTS.fetch("avatar")))
48
+ end
49
+
43
50
  def open_blog_body(post)
44
51
  tag.div(Renderer.render(post, cache: @open_blog_page&.kind != :preview), class: "ob-prose", data: { open_blog_body: true })
45
52
  end
@@ -1,6 +1,7 @@
1
1
  module OpenBlog
2
2
  class Image < ApplicationRecord
3
3
  include Immutable
4
+ VARIANTS = { "card" => { resize_to_fill: [ 640, 360 ] }, "avatar" => { resize_to_fill: [ 80, 80 ] } }.freeze
4
5
  has_one_attached :file, service: ->(_) { OpenBlog.config.storage_service || Rails.application.config.active_storage.service }
5
6
 
6
7
  validates :sha256, presence: true, uniqueness: true, format: { with: /\A[0-9a-f]{64}\z/ }
@@ -33,6 +34,11 @@ module OpenBlog
33
34
  "#{OpenBlog.mount_path.chomp("/")}/media/#{sha256}/#{ERB::Util.url_encode(filename)}"
34
35
  end
35
36
 
37
+ def variant_path(name)
38
+ raise ArgumentError, "Unknown image variant: #{name}" unless VARIANTS.key?(name.to_s)
39
+ "#{OpenBlog.mount_path.chomp("/")}/media/#{sha256}/#{name}/#{ERB::Util.url_encode(filename)}"
40
+ end
41
+
36
42
  def file
37
43
  super.extend(ImmutableFile)
38
44
  end
@@ -24,7 +24,11 @@ module OpenBlog
24
24
  invalid(field) unless value.is_a?(String) && value.present?
25
25
  model = Post.reflect_on_association(association).klass
26
26
  matches = model.where(slug: value).or(model.where(name: value))
27
- matches = matches.or(model.where(id: value.to_i)) if value.match?(/\A[1-9]\d*\z/)
27
+ if RecordKeys.uuid?(model)
28
+ matches = matches.or(model.where(id: value)) if value.match?(RecordKeys::UUID)
29
+ elsif value.match?(/\A[1-9]\d*\z/)
30
+ matches = matches.or(model.where(id: value.to_i))
31
+ end
28
32
  relation = relation.where(id: Post.joins(association).merge(matches).select(:id))
29
33
  end
30
34
  if @filters.key?(:q)
data/config/routes.rb CHANGED
@@ -38,6 +38,8 @@ OpenBlog::Engine.routes.draw do
38
38
  get "/feed.json", to: "feeds#show", as: :feed_json, format: false, defaults: { format: :json }
39
39
  get "/feed.xml", to: "feeds#show", as: :feed, format: false, defaults: { format: :xml }
40
40
  get "/sitemap.xml", to: "sitemaps#show", as: :sitemap, format: false, defaults: { format: :xml }
41
+ get "/media/:sha256/:variant/:filename", to: "media#variant", as: :media_variant, format: false,
42
+ constraints: { sha256: /[0-9a-f]{64}/, variant: /card|avatar/, filename: /[^\/]+/ }
41
43
  get "/media/:sha256/:filename", to: "media#show", as: :media, format: false, constraints: { sha256: /[0-9a-f]{64}/, filename: /[^\/]+/ }
42
44
  get "/#{OpenBlog.config.route_segments.fetch(:category)}/:slug/feed.json", to: "feeds#show", as: :category_feed_json, format: false, defaults: { format: :json }
43
45
  get "/#{OpenBlog.config.route_segments.fetch(:category)}/:slug/feed.xml", to: "feeds#show", as: :category_feed, format: false, defaults: { format: :xml }
@@ -1,6 +1,6 @@
1
1
  class CreateOpenBlogAuthors < ActiveRecord::Migration[8.0]
2
2
  def change
3
- create_table :open_blog_authors do |t|
3
+ create_table :open_blog_authors, **OpenBlog::RecordKeys.table_options(connection) do |t|
4
4
  t.string :name, null: false
5
5
  t.string :slug, null: false
6
6
  t.string :author_type, null: false, default: "person"
@@ -1,6 +1,6 @@
1
1
  class CreateOpenBlogImages < ActiveRecord::Migration[8.0]
2
2
  def change
3
- create_table :open_blog_images do |t|
3
+ create_table :open_blog_images, **OpenBlog::RecordKeys.table_options(connection) do |t|
4
4
  t.string :sha256, limit: 64, null: false
5
5
  t.string :filename, null: false
6
6
  t.string :content_type, null: false
@@ -1,5 +1,6 @@
1
1
  class CreateOpenBlogPosts < ActiveRecord::Migration[8.0]
2
2
  def change
3
+ key = OpenBlog::RecordKeys.attachment_type(connection)
3
4
  create_table :open_blog_posts do |t|
4
5
  t.string :slug, null: false
5
6
  t.string :title, null: false
@@ -8,7 +9,7 @@ class CreateOpenBlogPosts < ActiveRecord::Migration[8.0]
8
9
  t.text :search_description, null: false, default: ""
9
10
  t.string :body_format, null: false
10
11
  t.text :body_markdown
11
- t.references :author, null: false, foreign_key: { to_table: :open_blog_authors }
12
+ t.references :author, null: false, type: key, foreign_key: { to_table: :open_blog_authors }
12
13
  t.string :author_name, null: false
13
14
  t.references :category, foreign_key: { to_table: :open_blog_categories }
14
15
  t.references :series, foreign_key: { to_table: :open_blog_series }
@@ -22,9 +23,9 @@ class CreateOpenBlogPosts < ActiveRecord::Migration[8.0]
22
23
  t.boolean :featured, null: false, default: false
23
24
  t.string :canonical_url, limit: 2048
24
25
  t.string :external_id
25
- t.references :cover_image, foreign_key: { to_table: :open_blog_images }
26
+ t.references :cover_image, type: key, foreign_key: { to_table: :open_blog_images }
26
27
  t.string :cover_alt, null: false, default: ""
27
- t.references :social_image, foreign_key: { to_table: :open_blog_images }
28
+ t.references :social_image, type: key, foreign_key: { to_table: :open_blog_images }
28
29
  t.string :current_revision_identifier, limit: 64
29
30
  t.references :public_revision
30
31
  t.integer :word_count, null: false, default: 0
@@ -0,0 +1,41 @@
1
+ class AlignOpenBlogRecordKeys < ActiveRecord::Migration[8.0]
2
+ REFERENCES = {
3
+ open_blog_authors: { author_id: false },
4
+ open_blog_images: { cover_image_id: true, social_image_id: true }
5
+ }.freeze
6
+
7
+ def up
8
+ return unless OpenBlog::RecordKeys.attachment_type(connection) == :uuid
9
+ REFERENCES.each { |table, columns| convert(table, columns) unless key_type(table) == :uuid }
10
+ end
11
+
12
+ # Keys converted to UUID match the attachment table, as the create migrations now make them.
13
+ def down
14
+ end
15
+
16
+ private
17
+
18
+ def key_type(table)
19
+ connection.columns(table).find { |column| column.name == "id" }.type
20
+ end
21
+
22
+ def convert(table, columns)
23
+ add_column table, :uuid_id, :uuid, null: false, default: -> { "gen_random_uuid()" }
24
+ columns.each do |column, nullable|
25
+ remove_foreign_key :open_blog_posts, column: column
26
+ add_column :open_blog_posts, "#{column}_uuid", :uuid
27
+ execute <<~SQL.squish
28
+ UPDATE open_blog_posts SET #{column}_uuid = #{table}.uuid_id
29
+ FROM #{table} WHERE #{table}.id = open_blog_posts.#{column}
30
+ SQL
31
+ remove_column :open_blog_posts, column
32
+ rename_column :open_blog_posts, "#{column}_uuid", column
33
+ change_column_null :open_blog_posts, column, false unless nullable
34
+ add_index :open_blog_posts, column
35
+ end
36
+ remove_column table, :id
37
+ rename_column table, :uuid_id, :id
38
+ execute "ALTER TABLE #{table} ADD PRIMARY KEY (id)"
39
+ columns.each_key { |column| add_foreign_key :open_blog_posts, table, column: column }
40
+ end
41
+ end
@@ -29,7 +29,7 @@ module OpenBlog
29
29
  next if value.nil?
30
30
  raise Error::ImageNotPermitted unless value.is_a?(Hash) && value.size == 1
31
31
  if value.key?(:image_id)
32
- raise Error::ImageNotPermitted unless value[:image_id].is_a?(Integer) && value[:image_id].positive?
32
+ raise Error::ImageNotPermitted unless RecordKeys.id?(Image, value[:image_id])
33
33
  else
34
34
  key = value.keys.first
35
35
  raise Error::ImageNotPermitted unless %i[signed_id url].include?(key) && value[key].is_a?(String)
@@ -11,7 +11,7 @@ module OpenBlog
11
11
 
12
12
  CHECKS = { "Configuration" => :configuration, "Migrations" => :migrations, "Route" => :route,
13
13
  "Views" => :views, "Theme" => :theme, "JavaScript" => :javascript, "Authentication" => :authentication,
14
- "Rate limit" => :rate_limit, "Jobs" => :jobs, "Storage" => :storage, "Policy pages" => :policy_pages,
14
+ "Rate limit" => :rate_limit, "Jobs" => :jobs, "Storage" => :storage, "Record keys" => :record_keys, "Policy pages" => :policy_pages,
15
15
  "Records" => :records, "Declarations" => :declarations }.freeze
16
16
  TABLES = %w[open_blog_authors open_blog_categories open_blog_series open_blog_images open_blog_posts
17
17
  open_blog_api_tokens open_blog_pages open_blog_page_views open_blog_faqs open_blog_tags open_blog_taggings open_blog_redirects open_blog_revisions
@@ -126,6 +126,35 @@ module OpenBlog
126
126
  [ "ok", "Active Storage and the configured image processor are available." ]
127
127
  end
128
128
 
129
+ def record_keys
130
+ if (missing_tables & %w[active_storage_attachments open_blog_images open_blog_authors open_blog_posts action_text_rich_texts]).any?
131
+ return [ "warning", "Record keys cannot be checked until all content tables are installed." ]
132
+ end
133
+ attachment = key_kind("active_storage_attachments", "record_id")
134
+ owners = %w[open_blog_images open_blog_authors].reject { |table| key_kind(table, "id") == attachment }
135
+ if owners.any?
136
+ return [ "error", "active_storage_attachments.record_id is #{attachment}, but #{owners.map { |table| "#{table}.id is #{key_kind(table, 'id')}" }.join(' and ')}, " \
137
+ "so blog images and avatars cannot attach. Install and run the latest migrations: " \
138
+ "bin/rails railties:install:migrations FROM=open_blog, then bin/rails db:migrate." ]
139
+ end
140
+ if @config.body_formats.include?(:rich_text)
141
+ problems = []
142
+ if key_kind("action_text_rich_texts", "record_id") != key_kind("open_blog_posts", "id")
143
+ problems << "action_text_rich_texts.record_id is #{key_kind('action_text_rich_texts', 'record_id')}, but open_blog_posts.id is #{key_kind('open_blog_posts', 'id')}, so rich-text bodies cannot save"
144
+ end
145
+ if key_kind("action_text_rich_texts", "id") != attachment
146
+ problems << "action_text_rich_texts.id is #{key_kind('action_text_rich_texts', 'id')}, but active_storage_attachments.record_id is #{attachment}, so rich-text bodies cannot embed images"
147
+ end
148
+ return [ "error", "#{problems.join('; ')}. Use config.body_formats = [ :markdown ] with these host tables." ] if problems.any?
149
+ end
150
+ [ "ok", "Blog image and author IDs match active_storage_attachments.record_id (#{attachment})." ]
151
+ end
152
+
153
+ def key_kind(table, column)
154
+ found = ActiveRecord::Base.connection.columns(table).find { |candidate| candidate.name == column }
155
+ { uuid: "uuid", integer: "integer" }.fetch(found.type, found.sql_type)
156
+ end
157
+
129
158
  def policy_pages
130
159
  problems = %i[responsible_party corrections editorial ai_use].filter_map do |key|
131
160
  url = @config.policy_urls[key]
@@ -22,12 +22,12 @@ module OpenBlog
22
22
  endpoint("remove_post", :publish, "posts", :destroy, fields: ApiFields::REMOVE, id: :required),
23
23
  upload_definition,
24
24
  endpoint("list_categories", :read, "categories", :index, fields: ApiFields::PAGE),
25
- endpoint("save_category", :write, "categories", :create, fields: ApiFields::CATEGORY, id: :optional, upsert: true),
25
+ endpoint("save_category", :write, "categories", :create, fields: ApiFields::CATEGORY, id: :optional, upsert: Category),
26
26
  endpoint("list_tags", :read, "tags", :index, fields: ApiFields::PAGE),
27
27
  endpoint("list_authors", :read, "authors", :index, fields: ApiFields::PAGE),
28
- endpoint("save_author", :write, "authors", :create, fields: ApiFields::AUTHOR, id: :optional, upsert: true),
28
+ endpoint("save_author", :write, "authors", :create, fields: ApiFields::AUTHOR, id: :optional, upsert: Author),
29
29
  endpoint("list_series", :read, "series", :index, fields: ApiFields::PAGE),
30
- endpoint("save_series", :write, "series", :create, fields: ApiFields::SERIES, id: :optional, upsert: true),
30
+ endpoint("save_series", :write, "series", :create, fields: ApiFields::SERIES, id: :optional, upsert: Series),
31
31
  endpoint("list_redirects", :read, "redirects", :index, fields: ApiFields::PAGE),
32
32
  endpoint("save_redirect", :publish, "redirects", :create, fields: ApiFields::REDIRECT, required: [ :old_path ]),
33
33
  site_page_definition("get_site_page", :read),
@@ -43,7 +43,7 @@ module OpenBlog
43
43
 
44
44
  def endpoint(name, scope, controller, action, fields: [], id: nil, required: [], force: {}, upsert: false, create_publish: false, properties: nil)
45
45
  properties ||= Schemas.fields(fields)
46
- properties = properties.merge("id" => upsert ? { type: "integer", minimum: 1 } : Schemas.identifier) if id
46
+ properties = properties.merge("id" => upsert ? Schemas.key(upsert) : Schemas.identifier) if id
47
47
  required = required + (id == :required ? [ :id ] : [])
48
48
  force.each { |key, value| properties[key.to_s] = properties.fetch(key.to_s).merge(const: value) if properties.key?(key.to_s) }
49
49
  definition(name, scope, Schemas.object(properties, required: required)) do |arguments, actor:, base_url:|
@@ -15,6 +15,10 @@ module OpenBlog
15
15
  { anyOf: [ { type: "integer", minimum: 1 }, { type: "string", minLength: 1 } ] }
16
16
  end
17
17
 
18
+ def key(model)
19
+ RecordKeys.uuid?(model) ? { type: "string", format: "uuid" } : { type: "integer", minimum: 1 }
20
+ end
21
+
18
22
  def fields(names)
19
23
  names.to_h { |name| [ name.to_s, field(name) ] }
20
24
  end
@@ -44,7 +48,7 @@ module OpenBlog
44
48
  object(fields(%i[reviewer_name approved_at facts_checked evidence confirmed_by]), required: %w[reviewer_name approved_at facts_checked evidence confirmed_by])
45
49
  when :old_slugs
46
50
  { type: "array", items: { anyOf: [ text, object(fields(%i[slug moved_on]), required: [ "slug" ]) ] } }
47
- when :image_id then { type: "integer", minimum: 1 }
51
+ when :image_id then key(Image)
48
52
  when :type then { type: "string", enum: %w[person organization] }
49
53
  when :body_format then { type: "string", enum: %w[markdown rich_text] }
50
54
  when :provenance then { type: "string", enum: %w[ai_assisted human_written unknown] }
@@ -106,7 +106,7 @@ module OpenBlog
106
106
  when Integer then Author.find(input)
107
107
  when String
108
108
  invalid(:author) if input.blank?
109
- if input.match?(/\A[1-9]\d*\z/)
109
+ if input.match?(/\A[1-9]\d*\z/) || RecordKeys.uuid_id?(Author, input)
110
110
  Author.find(input)
111
111
  else
112
112
  Author.find_by(slug: input) || Author.find_by(name: input) || Author.create!(name: input)
@@ -154,7 +154,7 @@ module OpenBlog
154
154
  return Image.find(input) if input.is_a?(Integer)
155
155
  if input.is_a?(Hash) && input.keys.map(&:to_s) == [ "image_id" ]
156
156
  id = input[:image_id] || input["image_id"]
157
- return Image.find(id) if id.is_a?(Integer) || (id.is_a?(String) && id.match?(/\A[1-9]\d*\z/))
157
+ return Image.find(id) if id.is_a?(Integer) || (id.is_a?(String) && id.match?(/\A[1-9]\d*\z/)) || RecordKeys.uuid_id?(Image, id)
158
158
  end
159
159
  raise Error::ImageNotPermitted
160
160
  end
@@ -0,0 +1,35 @@
1
+ module OpenBlog
2
+ # Copied host migrations call these methods, so their names and arguments must stay stable.
3
+ module RecordKeys
4
+ UUID = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
5
+ ATTACHING_TABLES = %w[open_blog_images open_blog_authors].freeze
6
+
7
+ module_function
8
+
9
+ def attachment_type(connection)
10
+ if connection.data_source_exists?("active_storage_attachments")
11
+ column = connection.columns("active_storage_attachments").find { |candidate| candidate.name == "record_id" }
12
+ column&.type == :uuid ? :uuid : :bigint
13
+ else
14
+ generators = Rails.configuration.generators
15
+ generators.options.dig(generators.orm, :primary_key_type)&.to_sym == :uuid ? :uuid : :bigint
16
+ end
17
+ end
18
+
19
+ def table_options(connection)
20
+ attachment_type(connection) == :uuid ? { id: :uuid } : {}
21
+ end
22
+
23
+ def uuid?(model)
24
+ model.type_for_attribute(model.primary_key).type == :uuid
25
+ end
26
+
27
+ def id?(model, value)
28
+ uuid?(model) ? value.is_a?(String) && value.match?(UUID) : value.is_a?(Integer) && value.positive?
29
+ end
30
+
31
+ def uuid_id?(model, value)
32
+ uuid?(model) && value.is_a?(String) && value.match?(UUID)
33
+ end
34
+ end
35
+ end
@@ -1,3 +1,3 @@
1
1
  module OpenBlog
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/open_blog.rb CHANGED
@@ -58,6 +58,7 @@ module OpenBlog
58
58
  autoload :Mcp, "open_blog/mcp"
59
59
  autoload :ApiFields, "open_blog/api_fields"
60
60
  autoload :Authentication, "open_blog/authentication"
61
+ autoload :RecordKeys, "open_blog/record_keys"
61
62
 
62
63
  class << self
63
64
  def config
@@ -1,10 +1,58 @@
1
1
  ---
2
2
  name: open-blog-install
3
- description: Install and configure Open Blog in a Rails host, customize its reader theme, and check setup.
3
+ description: Install or upgrade Open Blog in a Rails host, reuse application navigation and colors, integrate the theme control, and diagnose setup compatibility.
4
4
  ---
5
5
 
6
- Install the gem in the requested Rails application, then run `bin/rails generate open_blog:install`. Inspect the host's JavaScript, Tailwind, image processor, mount path, and authentication setup before selecting options. Use `--skip-tailwind` when the host should keep its CSS configuration; select a reader preset with `--theme=signal`, `--theme=editorial`, or `--theme=ink`. For branding, set hex colors in `config.theme_colors`, and use the generated theme override for fonts and shape.
6
+ ## Locate the host and package
7
7
 
8
- Run `bin/rails open_blog:doctor` or `blog_doctor` and resolve relevant errors. Configure the actual publisher, author, public base URL, and policy links. Replace placeholders with user-supplied identities. Keep the initial API token secret; installation prints it once and repeat runs do not recover it. A host authentication hook takes precedence over tokens.
8
+ Work from the requested application's Rails root. Inspect its public layout and shared navbar/footer, CSS pipeline and palette variables, JavaScript entrypoint and Stimulus registration, existing theme preference, mount path, and editorial authentication. For a pilot, select isolated development/test databases before migrating.
9
9
 
10
- Check `tools/list` before using MCP tools. Finish with an actual reader request and authenticated `blog_list_posts` call. Do not publish unrelated content, change production routing, or deploy merely because installation was requested.
10
+ Use the requested gem release; inspect `Gemfile.lock` and `bundle show open_blog`. The installed package is the authority for its version. Inside it, layout and reader templates are under `lib/generators/open_blog/install/templates/views/`, theme sources under `lib/generators/open_blog/install/templates/theme/`, and browser controllers under `app/assets/javascripts/open_blog/controllers/`.
11
+
12
+ For examples and explanations, read [host integration](https://techwright-lab.github.io/open-blog/host-integration/); for task/file routing, read [usage](https://techwright-lab.github.io/open-blog/usage/). In a source checkout these are `docs/host-integration.md` and `docs/usage.md`. The steps below remain usable from the installed gem without the documentation site.
13
+
14
+ ## Check images before calling setup complete
15
+
16
+ - Inspect `active_storage_attachments.record_id` in the schema or with `ActiveStorage::Attachment.columns_hash.fetch("record_id").sql_type`. From 0.3.0 the migrations give Image and Author UUID keys when this column is `uuid`, and integer keys otherwise; doctor's Record keys check must report ok. A host that installed 0.2.0 on a UUID column needs `bin/rails railties:install:migrations FROM=open_blog` and `db:migrate` (the `AlignOpenBlogRecordKeys` migration). Keep the existing host attachment schema intact. With rich-text bodies, also follow doctor's Action Text key result; use Markdown when it reports a mismatch.
17
+ - Inspect the host variant processor before running the installer. It accepts `:vips` or `:mini_magick`; a disabled processor requires host configuration. Verify Ruby image-processing dependencies and native libvips/ImageMagick in the deployment image and production gem groups.
18
+ - Card and avatar variants use the engine's `/blog/media/:sha256/:variant/:filename` route and `config.image_delivery`, not the host's representation routes, so a host can keep those closed. Test originals and both variant URLs independently. Use `:proxy` when the host also blocks the storage service URLs that `:redirect` sends readers to. Preserve existing host access restrictions.
19
+
20
+ ## Install and configure
21
+
22
+ Add a missing gem with `bundle add open_blog`, passing the requested version constraint when specified. For a fresh application, use `bin/rails generate open_blog:install`. For an existing application retaining its CSS pipeline, use `--skip-tailwind`. A staged local pilot can use:
23
+
24
+ ```sh
25
+ bin/rails generate open_blog:install --theme=signal --skip-tailwind --skip-migrate --skip-sample
26
+ ```
27
+
28
+ Choose the requested preset. These flags still write files, add dependencies, register JavaScript, and mount routes. Review migrations and the host diff, migrate the intended database, then run `bin/rails open_blog:doctor`. Create the initial token with `open_blog:install_token` only when the selected access method needs it; keep the one-time secret private. A configured authentication hook takes precedence over tokens. Run `open_blog:sample` only when sample publishing is wanted and image setup works.
29
+
30
+ Set actual publisher/author identities, public origin (including the local port for a pilot), mount path, and policy links in `config/initializers/open_blog.rb`. The public origin excludes the mount prefix. Keep `config.mount_path` and `config/routes.rb` consistent. Restart after initializer changes.
31
+
32
+ ## Reuse application chrome
33
+
34
+ When shared navigation is requested, extract any inline public header/footer to host partials and render them in both the homepage and blog. Prefer a host-owned `app/views/layouts/blog.html.erb` with `config.layout = "blog"` when the main layout needs different behavior; `open_blog:views` does not overwrite that filename. Direct `config.layout = "application"` is also supported: branch on `@open_blog_page` for blog-specific head/assets. Layout selection is separate from `parent_controller`, whose inherited callbacks may require authentication or unavailable host state.
35
+
36
+ Adapt the installed layout while preserving `open_blog_theme_attributes`, `open_blog_head(@open_blog_page)`, early theme restoration, `open_blog_structured_data(@open_blog_page)`, `main#open-blog-main`, and the reader yield. Replace equivalent host head tags on reader requests so title/viewport/canonical are not duplicated. Keep host CSRF/CSP, icons, and required assets. Retain the reader's content/notices/byline/date helpers and policy/feed links, using a shared footer slot if needed. Host links in engine-rendered shared partials use `main_app`, for example `main_app.root_path`.
37
+
38
+ For `--skip-tailwind`/Tailwind 3, load the host's CSS explicitly and then `open_blog_stylesheets` (preset, compiled reader CSS, host override). For the Tailwind 4 component build, use `open_blog_theme_stylesheets` before the host build instead, keeping its generated `open_blog/theme.css` import. Register the `open-blog--` controllers in the public JavaScript entrypoint. Avoid `stylesheet_link_tag :all`; the compiled reader stylesheet contains reset rules. Scope `.ob-page` to the reader and inspect shared chrome after loading it.
39
+
40
+ ## Share palette and appearance state
41
+
42
+ Use `config.theme_colors` for fixed hex colors. For host CSS variables, edit `app/assets/stylesheets/open_blog_theme.css` (fallback CSS) or `app/assets/tailwind/open_blog/theme.css` (Tailwind 4). Map semantic `--ob-*` colors to host tokens; `var()` is invalid in `theme_colors`. Override both explicit and system-mode specificity, for example the selector list `:root[data-ob-theme="signal"][data-theme], :root[data-ob-theme="signal"]:not([data-theme])`, loaded after preset rules. Cover surfaces, text, borders, accents, code, notices, and alerts. Keep Signal typography/shape if requested. Use opaque surface mixes where translucent host colors expose unwanted layers. Doctor does not check CSS-derived contrast.
43
+
44
+ Place a single theme button in the shared navbar if requested; it is not a separate nav. Reuse the existing `open-blog--theme` controller and `cycle` action, its `systemLabel`/`lightLabel`/`darkLabel` values, and optional `label` target. Keep the initial `hidden` attribute, accessible mode label, keyboard focus, and mobile target size. The installed `_theme_toggle.html.erb` shows its markup; a host partial can use host styles.
45
+
46
+ The controller stores `open-blog-theme` and sets `data-theme="light"` or `"dark"` on `<html>`; System removes both and follows the OS. Restore that same key before CSS loads on homepage and blog using the gem helper or one shared equivalent script with the host's CSP nonce. Keep one preference owner when the host already has a theme controller. Honor a fixed `config.color_scheme` by retaining its root attribute and omitting the toggle. Check Turbo transitions if used, full reloads, explicit light/dark, and system mode.
47
+
48
+ ## Upgrade copied files deliberately
49
+
50
+ Read the target changelog, update any version constraint, then `bundle update open_blog --conservative`; review dependency changes too (Open Blog constrains JSON below 3). Bundler does not refresh copied host files.
51
+
52
+ `bin/rails generate open_blog:views` refreshes reader templates and `layouts/open_blog.html.erb` only. Compare controllers, locales, and theme sources with the installed package separately. Conflicting files are kept in a noninteractive rerun; a successful generator exit does not mean all copies are current. Merge required changes and use replacement only for reviewed files. A blanket `open_blog:install --force` also replaces the initializer. Review a host-owned layout against new helper/asset contracts manually. When moving from 0.1.0 to presets, replace old full-palette definitions with intentional overrides so they do not mask the new preset. Rebuild affected assets and restart.
53
+
54
+ ## Verify and report
55
+
56
+ Run doctor, relevant host regression tests, and real reader requests. Verify shared links/chrome, a single head/landmark, fonts and computed palette in all modes, preference persistence, keyboard access, and desktop/mobile overflow. Exercise a real upload and variant retrieval separately from text-only rendering; preserve host attachment regressions. Verify feeds/sitemap and any promised scheduling with the actual worker, not only a direct job invocation.
57
+
58
+ For MCP use, check `tools/list` and make an authenticated `blog_list_posts` call when that access is configured. Exercise draft privacy and signed previews; perform publication/edit checks within authorized local/test scope. Report observed passes, blockers, and unverified checks separately. Installation does not authorize unrelated content publication or production deployment.
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: open_blog
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - TechWright Labs
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-10-10 00:00:00.000000000 Z
10
+ date: 2026-10-21 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: json
@@ -365,6 +365,7 @@ files:
365
365
  - db/migrate/20261003164418_create_open_blog_api_tokens.rb
366
366
  - db/migrate/20261003175454_create_open_blog_pages.rb
367
367
  - db/migrate/20261003182919_create_open_blog_page_views.rb
368
+ - db/migrate/20261020120000_align_open_blog_record_keys.rb
368
369
  - lib/generators/open_blog/admin_suite/admin_suite_generator.rb
369
370
  - lib/generators/open_blog/admin_suite/templates/author_resource.rb.tt
370
371
  - lib/generators/open_blog/admin_suite/templates/blog_portal.rb.tt
@@ -469,6 +470,7 @@ files:
469
470
  - lib/open_blog/reader_dates.rb
470
471
  - lib/open_blog/reader_page.rb
471
472
  - lib/open_blog/reader_queries.rb
473
+ - lib/open_blog/record_keys.rb
472
474
  - lib/open_blog/record_release.rb
473
475
  - lib/open_blog/redirect_target.rb
474
476
  - lib/open_blog/remove.rb