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, andAdminResourceCard(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/andlib/ruby_cms/for selected modules - Creates
config/initializers/admin.rb(RubyCms.configureblock + 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_keysneeded - Adds
namespace :adminroutes (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 and Permissions
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.
register_page (recommended)
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
nav_register (low-level)
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? }
)
nav_group (accordion)
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.(:manage_orders, :manage_reports)
Permission Templates
RubyCms.(: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. = "/"
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.