RubyCMS

Standalone CLI gem for a modular CMS-style admin: content blocks, visual editor, versioning, pluggable dashboard, permissions, analytics, and visitor error tracking.

Your app owns product features (pages, models, business logic). RubyCMS scaffolds the admin layer into your codebase so you can customize everything without a runtime engine dependency.

How it works

RubyCMS is install-only. Running ruby_cms install (the CLI) copies controllers, views, models, helpers, JavaScript, and lib/ruby_cms/ into your host app for whichever modules you select. After install you can remove the gem from your Gemfile — the app runs entirely on copied code.

Layer Location after install
Registries (nav, permissions, dashboard, settings, icons) lib/ruby_cms/
Core configuration (RubyCms.configure, visual-editor preview) config/initializers/admin.rb
Sidebar nav (generated from module fragments; user-editable) config/initializers/admin_nav.rb
Dashboard blocks config/initializers/admin_dashboard.rb
Admin controllers & views app/controllers/admin/, app/views/admin/
UI components (Phlex / RubyUI) app/components/ruby_ui/admin_page/
Rake tasks lib/tasks/ruby_cms.rake
Module manifest / lockfile .ruby_cms.yml

Keep the gem in your Gemfile only when you need install/update commands:

# Uncomment temporarily for install or updates:
# gem "ruby_cms", path: "../gems/ruby_cms"

Features

  • Content blocks — rich text, plain text, images, links, and lists with locale support
  • Content block versioning — automatic version history with rollback and side-by-side diffs
  • Visual editor — inline editing on live page previews
  • Pluggable dashboard — registry-based blocks with permission gating and host-app extensibility
  • Permissions — key-based access control with templates and per-user assignment
  • Admin page generator — one command to scaffold a new admin page with nav, permission, and route
  • Named icon registry — 20+ Heroicons by name, no SVG copy-pasting
  • Settings — DB-backed admin settings with categories and UI
  • Analytics — Ahoy-powered visit/event tracking with dashboard drill-downs
  • Visitor error tracking — automatic 404/500 capture with admin overview
  • Admin UI components — reusable AdminPage, AdminPageHeader, AdminTableContent, and AdminResourceCard (RubyUI / Phlex)

Quick Start

# Add to Gemfile
gem "ruby_cms"

bundle install
ruby_cms install            # interactive module picker
ruby_cms install --modules media,redirects   # non-interactive
rails db:migrate
rails ruby_cms:seed_permissions
rails ruby_cms:setup_admin

The ruby_cms install CLI:

  • Copies admin scaffold into app/ and lib/ruby_cms/ for selected modules
  • Creates config/initializers/admin.rb (RubyCms.configure block + visual-editor preview setup)
  • Generates config/initializers/admin_nav.rb (sidebar nav assembled from per-module fragments)
  • Creates config/initializers/admin_dashboard.rb (dashboard blocks)
  • Seeds module permissions from the manifest — no manual register_permission_keys needed
  • Adds namespace :admin routes (no engine mount)
  • Configures Tailwind, Stimulus, Action Text, and Ahoy
  • Records installed modules in .ruby_cms.yml

Visit /admin and sign in with the admin user you configured.

Adding a module later

ruby_cms install --add media

This copies the module files, seeds its permissions, and appends its nav fragment to config/initializers/admin_nav.rb.

Pulling updates from the gem

# Re-add gem to Gemfile, then:
ruby_cms update                       # updates only modules in .ruby_cms.yml
ruby_cms update --only=mod1,mod2
ruby_cms update --dry-run

Only changed files are overwritten. Review diffs before committing. config/initializers/admin_nav.rb is regenerated on every install/update, so custom sidebar entries belong in config/initializers/admin_pages.rb (where rails g ruby_cms:admin_page writes them) — that file is never touched by the gem.

Admin UI components

Admin views use Phlex components under app/components/ruby_ui/admin_page/ (registered as AdminPage, AdminPageHeader, etc. via the RubyUI kit).

Component Purpose
RubyUI::AdminPage White card wrapper; optional turbo_frame: for full-page Turbo updates
RubyUI::AdminPageHeader Breadcrumbs, title, subtitle, action slot
RubyUI::AdminTableContent Turbo Frame (id: "admin_table_content") for pagination, filters, search
RubyUI::AdminResourceCard Layout constants for show/edit pages (grid, sidebar, form classes)

Usage in views

<%= AdminPage do %>
  <%= AdminPageHeader(
    title: "Users",
    breadcrumbs: [
      { label: "Admin", url: admin_root_path },
      { label: "Users" }
    ]
  ) do %>
    <%= link_to "New user", new_admin_user_path, class: "..." %>
  <% end %>

  <%= admin_table_content do %>
    <%# table rows, pagination — replaces frame on Turbo navigation %>
  <% end %>
<% end %>

admin_table_content is provided by Admin::AdminPageHelper.

For resource show pages, use RubyUI::AdminResourceCard constants:

<div class="<%= RubyUI::AdminResourceCard::CARD_CLASS %>">
  <div class="<%= RubyUI::AdminResourceCard::GRID_CLASS %>">
    ...
  </div>
</div>

Content Blocks

Use the content_block helper in any view:

<%= content_block("hero_title", default: "Welcome") %>
<%= content_block("footer", cache: true) %>

Content Types

Type Description
text Plain text string
rich_text Action Text rich content (HTML)
image Attached image via Active Storage
link URL string
list JSON array or newline-separated items

Placeholders and Attributes

content_block wraps output for the visual editor. For HTML attributes, use wrap: false:

<%= text_field_tag :name, nil,
  placeholder: content_block("contact.placeholder", wrap: false, fallback: "Your name") %>

Or use content_block_text which never wraps:

<meta name="description" content="<%= content_block_text("meta_desc", fallback: "Default") %>">

List Items

<% content_block_list_items("badges", fallback: ["Ruby", "Rails"]).each do |badge| %>
  <%= tag.span badge, class: "badge" %>
<% end %>

Multi-locale Support

Content blocks have a locale field. The CMS groups blocks by key prefix across locales for easy management.

Content Block Versioning

Versions are created automatically on every meaningful change:

block = ContentBlock.create!(key: "hero", title: "Welcome", content: "Hello",
                              content_type: "text", locale: "en")
block.versions.count  # => 1 (event: "create")

block.update!(title: "Welcome!")
block.versions.count  # => 2 (event: "update")

block.update!(published: false)
block.versions.last.event  # => "unpublish"

Events

Event When
create Block is first created
update Title, content, or content_type changes
publish published changes to true
unpublish published changes to false
rollback rollback_to_version! is called
visual_editor Edit via the visual editor

Rollback

old_version = block.versions.first
block.rollback_to_version!(old_version, user: current_user)
# Creates a new version with event: "rollback"
# Restores title, content, content_type, published, and rich_content

Admin UI

  • Version history link on each content block show page
  • Timeline view with colored event badges
  • Side-by-side diff (old vs new, red/green)
  • Rollback button with confirmation

Routes

GET  /admin/content_blocks/:id/versions        # index (HTML + JSON)
GET  /admin/content_blocks/:id/versions/:vid    # show with diff
POST /admin/content_blocks/:id/versions/:vid/rollback

Visual Editor

Configure preview templates in config/initializers/admin.rb:

RubyCms.configure do |c|
  c.preview_templates = { "home" => "pages/home", "about" => "pages/about" }
  c.preview_data = ->(page_key, view, variant = nil) { { products: Product.limit(5) } }
  c.preview_variants = ->(page_key) { [] }
end

Open Admin > Visual editor, pick a page, and click content blocks to edit them inline.

Dashboard

The dashboard uses a registry-based block system. Each block is a partial with optional permission gating and data injection.

Default Blocks

Register defaults in config/initializers/admin_dashboard.rb (generated at install with CMS stats blocks; customize for your app):

Block Section Permission
Content blocks stats stats manage_content_blocks
Users stats stats manage_permissions
Permissions stats stats manage_permissions
Visitor errors stats stats manage_visitor_errors
Quick actions main
Recent errors main manage_visitor_errors
Analytics overview main manage_analytics

Adding Custom Blocks

# config/initializers/admin_dashboard.rb
RubyCms.dashboard_register(
  key: :orders_stats,
  label: "Orders",
  section: :stats,          # :stats (top row) or :main (bottom grid)
  order: 5,
  partial: "admin/dashboard/blocks/orders_stats",
  permission: :manage_orders,
  span: :single,            # :single or :double (grid width)
  data: ->(controller) {
    { count: Order.count, today: Order.where("created_at > ?", Date.today).count }
  }
)

Then create the partial app/views/admin/dashboard/blocks/_orders_stats.html.erb. The block local contains the registration data and any computed data.

Overriding Default Blocks

Re-register with the same key to override (in config/initializers/admin_dashboard.rb):

RubyCms.dashboard_register(
  key: :quick_actions,
  label: "Quick actions",
  section: :main,
  partial: "admin/dashboard/my_quick_actions"
)

Navigation registration lives in config/initializers/admin_nav.rb (generated at install/add from per-module fragments; safe to edit manually). Permission keys are declared in the gem's manifest (lib/ruby_cms/manifest.rb) and seeded automatically at install — you do not need to call register_permission_keys for built-in modules. Register additional app-specific keys in config/initializers/admin_nav.rb or any initializer.

One call to register a nav item and its permission. Place in config/initializers/admin_nav.rb (or any initializer):

Rails.application.config.to_prepare do
  RubyCms.register_page(
    key: :backups,
    label: "Backups",
    path: ->(view) { view.admin_backups_path },
    icon: :archive_box,
    section: :main,
    permission: :manage_backups,
    order: 10
  )
end

For more control (custom visibility gates, non-standard paths):

RubyCms.nav_register(
  key: :reports,
  label: "Reports",
  path: ->(view) { view.main_app.reports_path },
  icon: :chart_bar,
  section: "main",
  permission: :manage_reports,
  if: ->(view) { view.current_user_cms&.admin? }
)
RubyCms.nav_group(
  key: :operations,
  label: "Operations",
  icon: :folder,
  section: "main",
  order: 20,
  path: ->(view) { view.admin_operations_path },
  children: %i[backups reports]
)

Groups are hidden automatically when they have no visible children and no path.

Path Options

Format Example Behavior
Symbol :admin_backups_path Auto-wrapped: view.main_app.send(:admin_backups_path)
Lambda ->(view) { view.admin_backups_path } Called with view context
String "/admin/backups" Used as-is

Named Icons

Use symbol keys from RubyCms::Icons:

RubyCms::Icons.available
# => [:home, :pencil_square, :document_duplicate, :chart_bar, :shield_check, ...]

Raw SVG strings are also accepted for custom icons.

Permission Keys

Built-in module permission keys (e.g. manage_admin, manage_permissions, manage_content_blocks, manage_visitor_errors, manage_analytics) are declared in the gem manifest and seeded automatically at install.

Register additional app-specific keys in any initializer (e.g. config/initializers/admin_nav.rb):

RubyCms.register_permission_keys(:manage_orders, :manage_reports)

Permission Templates

RubyCms.register_permission_template(:editor,
  label: "Editor",
  keys: %w[manage_admin manage_content_blocks],
  description: "Can manage content but not users"
)

RubyCms::Permission.apply_template!(user, :editor)

cms_page Macro

In controllers under Admin::ApplicationController, use cms_page to enforce the permission from register_page:

class Admin::BackupsController < Admin::ApplicationController
  cms_page :backups

  def index
    @backups = Backup.recent
  end
end

Admin Page Generator

Scaffold a complete admin page — controller, data-table index, form and routes — that follows the same conventions as the built-in modules. Pass optional field:type pairs (type defaults to string) to scaffold the columns:

rails g ruby_cms:admin_page product name:string price:decimal published:boolean

This generates:

File Description
app/controllers/admin/products_controller.rb Full CRUD controller on the shared concern stack (pagination, turbo tables, bulk delete, audit)
app/views/admin/products/index.html.erb AdminPage + AdminPageHeader with a data_table
app/views/admin/products/_admin_table_content.html.erb The data-table partial (headers from your fields)
app/views/admin/products/_row.html.erb Row cells per field
app/views/admin/products/_form.html.erb, new, edit Form scaffolded from your fields
config/routes.rb resources :products (with bulk_delete) in namespace :admin
config/initializers/admin_pages.rb Sidebar registration via RubyCms.register_page (host-owned — never overwritten by ruby_cms update)

Options

rails g ruby_cms:admin_page report --read-only --section=settings --icon=chart_bar --order=15
Option Default Description
--permission manage_<plural> Permission key
--icon folder Icon from RubyCms::Icons (RubyCms::Icons.available)
--section main main or settings
--order 10 Sort order in nav
--read-only false Index-only page (no form/create/update/destroy)
--skip-route false Don't touch config/routes.rb
--skip-nav false Don't register the page in the sidebar

After generating, run rails ruby_cms:seed_permissions to create the permission row in the database.

Settings

DB-backed settings with a registry for defaults and types:

RubyCms::Settings.get(:analytics_default_period, default: "week")
RubyCms::Settings.set(:analytics_default_period, "month")

Registering Custom Settings

RubyCms::SettingsRegistry.register(
  key: :my_custom_setting,
  type: :string,
  default: "hello",
  category: :general,
  description: "A custom setting"
)

Settings are managed in Admin > Settings with tabs per category.

Visitor Error Tracking

Public exceptions are captured when VisitorErrorCapture is included in your ApplicationController (added by the install generator).

View captured errors in Admin > Visitor errors with status codes, paths, timestamps, and resolution tracking.

Analytics (Ahoy)

RubyCMS integrates with Ahoy for server-side page view and event tracking.

Include PageTracking in public controllers:

class PagesController < ApplicationController
  include PageTracking
end

View analytics in Admin > Analytics with visit/event counts, popular pages, top visitors, and configurable date ranges.

Customization Hooks

Place these in config/initializers/admin.rb inside the RubyCms.configure block:

RubyCms.configure do |c|
  c.analytics_visit_scope = ->(scope) { scope.where.not(ip: ["127.0.0.1"]) }
  c.analytics_event_scope = ->(scope) { scope }
  c.analytics_extra_cards = lambda { |start_date:, end_date:, period:, visits_scope:, events_scope:|
    [{ title: "Custom KPI", value: visits_scope.count }]
  }
end

Seeding Content Blocks from YAML

# config/initializers/admin.rb
RubyCms.configure do |c|
  c.content_blocks_translation_namespace = "content_blocks"
end
# config/locales/en.yml
en:
  content_blocks:
    hero_title: "Welcome to my site"
rails ruby_cms:content_blocks:import
rails ruby_cms:content_blocks:export

Configuration

Configuration is split across three generated files. All are plain Ruby initializers — edit them freely.

config/initializers/admin.rb

RubyCms.configure options plus visual-editor preview setup:

# config/initializers/admin.rb

RubyCms.configure do |c|
  c.admin_base_controller = "ApplicationController"
  c.admin_layout = "admin/admin"
  c.user_class_name = "User"
  c.bootstrap_admin_with_role = true
  c.unauthorized_redirect_path = "/"
  c.preview_templates = { "home" => "pages/home" }
  c.preview_data = ->(page_key, _view, _variant = nil) { {} }
  c.content_blocks_translation_namespace = "content_blocks"
  c.image_content_types = %w[image/png image/jpeg image/gif image/webp]
  c.image_max_size = 5 * 1024 * 1024
end

config/initializers/admin_nav.rb

Sidebar nav entries. Generated from per-module nav fragments at ruby_cms install / ruby_cms install --add. Safe to edit by hand; note it is regenerated (fragments appended) when you add a new module.

# config/initializers/admin_nav.rb

Rails.application.config.to_prepare do
  RubyCms.register_page(key: :dashboard, ...)
  RubyCms.nav_group(key: :content, ...)
  # app-specific extra pages:
  RubyCms.register_permission_keys(:manage_orders)
  RubyCms.register_page(key: :orders, ...)
end

config/initializers/admin_dashboard.rb

Dashboard block registrations:

# config/initializers/admin_dashboard.rb

RubyCms.dashboard_register(key: :orders_stats, ...)

Boot initializers

File Role
config/initializers/admin.rb RubyCms.configure + visual-editor preview (generated at install)
config/initializers/admin_nav.rb Sidebar nav (generated from module fragments at install/add)
config/initializers/admin_dashboard.rb Dashboard block registrations
config/initializers/ruby_cms_core.rb Boot hooks: versionable concern, settings import, permission rows

config/application.rb

After install, require the runtime module before the application class:

require_relative "../lib/ruby_cms"

lib/ruby_cms.rb eager-loads lib/ruby_cms/*.rb so registries are available during boot.

Rake Tasks

Copied to lib/tasks/ruby_cms.rake in the host app:

Task Description
ruby_cms:seed_permissions Create default permission rows + settings
ruby_cms:setup_admin Interactive first admin user setup
ruby_cms:grant_manage_admin[email] Grant all permissions to a user by email
ruby_cms:content_blocks:export Export DB content blocks to YAML
ruby_cms:content_blocks:import Import content blocks from YAML
ruby_cms:content_blocks:sync Export + optional import
ruby_cms:import_initializer_settings Import initializer values into DB settings

Development

Gem repository

This repository holds generator templates under lib/generators/ruby_cms/templates/. The published gem defines only RubyCms::VERSION in lib/ruby_cms.rb — no runtime engine.

git clone https://github.com/jobhammer00/ruby_cms.git
cd ruby_cms
bundle install
bundle exec rspec

Syncing changes from a host app

When developing against a reference app (e.g. portfolio), push host-app edits back into gem templates using the ruby_cms sync CLI (run from the gem repo):

ruby_cms sync                           # default source: ../portfolio
ruby_cms sync --from ../gvexcelsior
ruby_cms sync --from ../gvexcelsior --dry-run
ruby_cms sync --from ../gvexcelsior --include-new   # also add brand-new gem files

This is the reverse of ruby_cms update. By default sync only updates files the gem already ships — manifest globs like helpers/**/* also match app-specific files (a public-site helper, a marketing Stimulus controller) that the gem doesn't own, and absorbing those would pollute it. New matches are skipped and listed; pass --include-new to pull them in deliberately when you really are adding a file to the gem.

Architecture (after install)

config/initializers/
  admin.rb                      # RubyCms.configure + visual-editor preview
  admin_nav.rb                  # Sidebar nav (generated from module fragments)
  admin_dashboard.rb            # Dashboard block registrations
  ruby_cms_core.rb              # Boot hooks (versionable, settings, permissions)

lib/ruby_cms.rb                 # Module API: nav_register, register_page, configure
lib/ruby_cms/
  settings.rb                   # DB-backed settings
  settings_registry.rb          # Setting definitions
  dashboard_blocks.rb           # Dashboard registry
  commands_registry.rb          # Admin commands registry
  icons.rb                      # Named Heroicon SVG registry
  content_blocks_sync.rb        # YAML ↔ DB import/export
  content_blocks_grouping.rb    # Multi-locale grouping
  cli.rb                        # setup_admin interactive CLI

app/controllers/admin/          # Dashboard, content blocks, analytics, etc.
app/views/admin/                # Admin ERB views
app/components/ruby_ui/admin_page/
  admin_page.rb                 # Card wrapper (+ optional turbo_frame)
  admin_page_header.rb          # Breadcrumbs, title, actions
  admin_table_content.rb        # Turbo frame for tables
  admin_resource_card.rb        # Show-page layout constants
app/models/                     # ContentBlock, Permission, Preference, etc.
lib/tasks/ruby_cms.rake         # Seed, content blocks, setup_admin

Key extension points

What How
New admin page rails g ruby_cms:admin_page <name> [field:type ...] — registers itself in config/initializers/admin_pages.rb
New nav item RubyCms.register_page(...) or RubyCms.nav_register(...) in config/initializers/admin_pages.rb (host-owned; admin_nav.rb is regenerated)
New app-specific permission RubyCms.register_permission_keys(:key) in config/initializers/admin_pages.rb
New dashboard block RubyCms.dashboard_register(...) in config/initializers/admin_dashboard.rb
New setting RubyCms::SettingsRegistry.register(...)
New icon Pass raw SVG string to icon: parameter
Add optional module ruby_cms install --add <module>
Pull gem updates ruby_cms update
Push changes to gem ruby_cms sync [--from ../app] (gem repo)

Module reference

Base modules (always installed)

core, auth, permissions, users, content_blocks, visual_editor

Optional modules (ruby_cms install picker or --add)

analytics, media, redirects, audit_log, trash, security, system_health, passkeys, commands

App-specific features (contact forms, news, products, …) do not belong in the gem. Use rails g ruby_cms:admin_page to scaffold a CRUD admin page that plugs into nav, permissions, routes, and the shared table/UI stack — then add your model, mailers, and public controllers in the host app.

Permissions for each module are declared in the gem manifest (lib/ruby_cms/manifest.rb + manifest_data.rb) and seeded at install. You do not need to register them manually.

License

The gem is available as open source under the terms of the MIT License.