Skip to content

Instantly share code, notes, and snippets.

@aseroff
Last active October 18, 2023 19:58
Show Gist options
  • Save aseroff/c6a3e889a793f20dbe3354db1415b8bd to your computer and use it in GitHub Desktop.
Save aseroff/c6a3e889a793f20dbe3354db1415b8bd to your computer and use it in GitHub Desktop.
Customizing a SpinaCMS-powered site/blog

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.

Using MySQL instead of Postgres

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.

Using Devise for authentication

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'.

Using WillPaginate alongside Kaminari

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.

Using Bootstrap (specifically v4) navbar with Spina Navigation controls

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 as in a div, but Spina recursively renders any subpages as a ul with lis.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment