For a while, my org was using Wordpress to manage a public-facing website, which was reverse-proxied on my Rails application's server to share its DNS (a fairly common setup). However, the cost of supporting Wordpress and the proxy became too great to justify, and we looked into replacing Wordpress with another CMS option. I came across SpinaCMS, an open-source, Rails-powered CMS, which offered a lot of upside to proxying to a seperate server running a separate application. However, there were several gotchas that took some navigating, that I want to share in one place, considering I believe these would all be common situations to anyone trying to accomplish a similar goal. This guide will assume you have completed the basic setup of gem installation and setup rake tasks.
If your application is already running on MySQL, you probably don't want to go through the hurdles of moving to a Postgres database. Fortunately, unless you are using the e-commerce plugin for Spina, the dependency on pg
is unnecessary and can be worked around.
On a development machine, it can be trivial to setup a Postgres install, allowing the gem to install successfully, then just using your MySQL database like normal. However, on a production machine, you can avoid that unnecessary cost by installing the gem individually using the "ignore-dependencies" switch like so: gem install spina --ignore-dependencies
.
But when you pull your development code and use bundle
, it will still try to install its dependencies. Simply find and remove lines referencing pg
from your Gemfile.lock
, then bundle
again. Since the gem was already installed separately, it will be found and not fail trying to install dependencies, but then all its dependencies excluding pg
will be present in the lockfile. This step will have to be repeated every time your Gemfile.lock
is updated.
The last gotcha will be with your migrations. The rake scripts will generate migrations that include add_column :jsonld
, which is a Postgres-enhanced version of a json column that will fail on MySQL databases. Simply replace :jsonld
with :json
and you're all set.
Your existing application probably already has authentication setup. However, SpinaCMS comes with its own user/authentication kit. If you want to keep two different user tables - and it's perfectly reasonable to want to separate these concerns - then by all means use it. However, I wanted Spina to hook into my existing Devise authentication system, which we can accomplish by copying Spina's authentication module, making a single minor change, and calling it in Spina's config as the authentication module to use.
Start by dropping a file, here called devise_sessions.rb
into your lib
folder. You'll need to adjust it slightly (.superadmin?
is custom code to my application's User model), but the contents should look something like this:
# frozen_string_literal: true
# Used as Spina's authentication module
module DeviseSessions
extend ActiveSupport::Concern
included do
helper_method :current_spina_user
helper_method :logged_in?
helper_method :logout_path
end
# Spina user falls back to devise user session in the case there is one and it is of a superadmin.
def current_spina_user
Spina::Current.user ||= current_user if current_user&.superadmin?
end
# Returns falsy unless there is a logged in superadmin
def logged_in?
current_spina_user
end
# Not used
def logout_path
spina.admin_logout_path
end
private
# Redirects user to sign in if not logged in as a superadmin
def authenticate
redirect_to '/sign_in' unless logged_in?
end
end
This module is relatively self-explanatory. The primary change here is that current_spina_user
will return Devise's current_user
should a Spina-authenticated user is not found. Since I have multiple levels of user permissions, I place a conditional that only returns the devise user if there is a devise user, and they are a superadmin.
Next we just need to tell Spina to use this module for authentication. In config/initializers/spina.rb
, scroll down to "Authentication" and replace the commented sample line with config.authentication = 'DeviseSessions'
.
My application already uses WillPaginate, but SpinaCMS uses Kaminari for pagination. Trying to use both along side each other out of the box will result in namespace collision. The solution is an initializer that will create some alias methods. Put this in /config/initializers/will_paginate.rb
if defined?(WillPaginate)
module WillPaginate
module ActiveRecord
module RelationMethods
def per(value = nil) = per_page(value)
def total_count() = count
end
end
module CollectionMethods
alias_method :num_pages, :total_pages
end
end
end
Another issue arises when trying to use WillPaginate with routes defined by the Spina::Engine, which you could encounter when trying to paginate blog posts. Fortunately, we can cast some dark magic that will fix that all up. Back to /lib
, drop the following code in a new file:
# frozen_string_literal: true
# Custom link rendering for will_paginate to use spina engine routes
class CustomLinkRenderer < WillPaginate::ActionView::LinkRenderer
# Dark magic for will_paginate to work with the Spina Engine routes
def url(page)
@base_url_params ||= begin
url_params = merge_get_params(default_url_params)
url_params[:only_path] = true
merge_optional_params(url_params)
end
url_params = @base_url_params.dup
add_current_page_param(url_params, page)
if @options[:url_scope]
@options[:url_scope].url_for(url_params)
else
@template.url_for(url_params)
end
end
end
Now when calling will_paginate
, reference your new LinkRenderer when handling Spina routes, like so:
= will_paginate @posts, renderer: CustomLinkRenderer, url_scope: Spina::Engine.routes.url_helpers, class: "pagination"
We'll also need to change the pagination code at the controller level. In our blog posts example, that would mean copying these controllers into your application (in the same app/controllers/spina/blog
directory), and changing the pagination related lines in the methods defining @posts
from .page(params[:page])
to .paginate(page: params[:page], per_page: 10)
.
Important note: the gem mobility
needs to be locked at 1.2.4. Spina's dependencies will upgrade beyond that, but this workaround breaks past that. So add gem 'mobility', '1.2.4'
to your Gemfile and the above will work.
Maybe your codebase is old. Maybe Tailwind scares you. But for whatever reason, your application uses Bootstrap and you want to stick with it. However, when you are setting up your site's navbar to be controlled by the SpinaCMS's backend, the object structure generated doesn't match the object structure expected by the Bootstrap navbar classes.
So here's an example of the structure we're trying to replicate, as seen here:
%nav.navbar.navbar-expand-lg.navbar-light
.container
= link_to image_tag('brand.png', style: 'height: 60px', alt: 'brand image'), '/'
%button.navbar-toggler{type: 'button', data: {toggle:'collapse', target: '#navbarNav'}}
%span.navbar-toggler-icon
#navbarNav.collapse.navbar-collapse
%ul.navbar-nav.ml-auto
%li.nav-item= link_to 'PAGE', '/page', class: 'nav-link'
%li.nav-item.dropdown
= link_to 'PAGE WITH SUBPAGES', '/page2/', class: 'nav-link dropdown-toggle'
.dropdown-menu
= link_to 'SUBPAGE', '/page2/subpage', class: 'dropdown-item'
The catch is that Bootstrap expects subitems as a
s in a div
, but Spina recursively renders any subpages as a ul
with li
s.
Fear not, we will simply monkey-patch the Spina MenuPresenter. Create the folder app/presenters/spina
and place menu_presenter.rb
in it. Now fill it with this:
module Spina
class MenuPresenter
include ActionView::Helpers::TagHelper
include ActionView::Helpers::UrlHelper
include ActiveSupport::Configurable
attr_accessor :collection, :output_buffer
# Configuration
config_accessor :menu_tag, :menu_css, :menu_id,
:list_tag, :list_css,
:list_item_tag, :list_item_css,
:link_tag_css,
:active_list_item_css,
:current_list_item_css,
:include_drafts,
:depth # root nodes are at depth 0
# Default configuration
self.menu_tag = :nav
self.list_tag = :ul
self.list_item_tag = :li
self.include_drafts = false
def initialize(collection)
@collection = collection
end
def to_html
render_menu(roots)
end
private
def roots
return collection.navigation_items.roots if collection.is_a?(Navigation)
collection.roots
end
def render_menu(collection)
content_tag(menu_tag, class: menu_css, id: menu_id) do
render_items(scoped_collection(collection))
end
end
# The monkeypatch is here - we add a second parameter that defaults to true, that we can set to false only when rendering subitems.
# I just manually assign the subitem tag and classes, but you could add them to the config_accessor list and assign them in the view.
def render_items(collection, render_as_list = true)
content_tag (render_as_list ? list_tag : :div), class: (render_as_list ? list_css : 'dropdown-menu') do
collection.inject(ActiveSupport::SafeBuffer.new) do |buffer, item|
buffer << (render_as_list ? render_item(item) : link_to(item.menu_title, item.materialized_path, class: 'dropdown-item'))
end
end
end
# There is also some monkeypatching going on here - when items have children, we add the 'dropdown' class to the list object and the 'dropdown-toggle' class to the item's link object's class.
def render_item(item)
return nil unless item.materialized_path
children = scoped_collection(item.children)
content_tag(list_item_tag, class: "#{item_css(item)} #{children.any? ? ' dropdown' : ''}", data: { page_id: item.page_id, draft: (true if item.draft?) }) do
buffer = ActiveSupport::SafeBuffer.new
buffer << link_to(item.menu_title.upcase, item.materialized_path, class: "#{link_tag_css} #{children.any? ? ' dropdown-toggle' : ''}")
buffer << render_items(children, false) if render_children?(item) && children.any?
buffer
end
end
def scoped_collection(collection)
scoped = collection.regular_pages.active.in_menu.sorted
include_drafts ? scoped : scoped.live
end
def render_children?(item)
return true unless depth
item.depth < depth
end
def item_css(item)
# return current_list_item_css if apply_current_css?(item)
# return active_list_item_css if apply_active_css?(item)
list_item_css
end
# def apply_current_css?(item)
# return false if current_list_item_css.nil?
# Spina::Current.page == item
# end
# def apply_active_css?(item)
# return false if apply_current_css?(item)
# parent_of_current?(item)
# end
# def parent_of_current?(item)
# return false if item.homepage?
# Spina::Current.page.materialized_path.starts_with? item.materialized_path
# end
end
end
Now, in your theme's layout (or preferably in a partial called by your layout), create your nav like so
- presenter = Spina::MenuPresenter.new Spina::Navigation.find_by(name: 'main')
- presenter.config.menu_tag = 'div'
- presenter.config.menu_css = 'collapse navbar-collapse'
- presenter.config.menu_id = 'navbarNav'
- presenter.config.list_css = 'navbar-nav ml-auto'
- presenter.config.list_item_css = 'nav-item'
- presenter.config.link_tag_css = 'nav-link'
%nav.navbar.navbar-expand-lg.navbar-light
.container
= link_to image_tag('brand.png', style: 'height: 60px', alt: 'brand image'), '/'
%button.navbar-toggler{type: 'button', data: {toggle:'collapse', target: '#navbarNav'}}
%span.navbar-toggler-icon
= presenter.to_html