Trio - a Casper Derived Ghost Theme

Notes on creating multi-volume "trio", a Casper derived, Ghost Theme

Trio - a Casper Derived Ghost Theme
Beethoven's Piano Trio Opus 70, No 1, 2nd movement, bars 19–25, violin

This blog / site uses Ghost Content Management System (CMS) for content and Ghost Casper theme for renderering. When initially created each of "Just Enough Architecture", "Just Enough Architecture - Industry Pages" and "Just Enough Architecture - Technical Tips" were hosted as seperate Ghost instances.

This worked as commenting platform (based on Commento) was shared across each of the seperate instance, but it had the downside of:

  • Seperate Authors registration - authers needs register to each site separately
  • Seperate Member registration - cannot easy change from external commentry via Ghost native membership support
  • More resource intensive - as each instance is its own VM
  • Operational complexity - more hosts (VMs) adds to greater operational complexity.

I had elected to go with seperate instances as:

  • Ghost did not support multiple "Title Pages", providing only a single "Theme" per instance,
  • creating more complex site required deeper understanding of Ghost routing and collections configuration and
  • at the time Ghost did not have any membership support so commentary had to be incorporated via external commeting platform.

The time has come to revisit this deployment architecture as:

  • "Commento" commentary project is essentially dead (a lesson in having an Open Source solution where the "owner" does not accept any push/merge requests)
  • Ghost native membership can replace the external commetary
  • I now have enough knowledge of JavaScript to be able to create theme extension required to address original need for multiple "Title Pages"
  • Need to add shared features across "sites"

Status - May 2023: Trio is go, it is theme providing this multi-volume site & Commento comments migrated to native Ghost.


The Parts

While the "Casper" theme metaphor is to create a "magazine" the goal of "trio" theme is provide a "binder" to put the "magazines" into. The result is a "multi-volume" magazine.

To build a custom theme you need to have:

  1. Ghost Development Environment - the custom theme being developed should be either linked to or placed directly under <GHOST-DEV>/content/theme/<CUSTOM-THEME>. Setup is as per ghost installation instructions
  2. Custom Theme repository - ideally should manage your theme via git. As stretch is based on "Casper" I forked this, renamed the repository and then created a new branch, and then put symbolic link from "<GHOST-DEV>/content/theme -> <GIT-THEME-REPOSITORY"
  3. Install gscan as per "Ghost Handlebar Themes" (this assumes that Ghost/npm are already installed)

NOTE: Following the Casper instructions when running "yarn install" it comes up with instruction to install "cmdtest" via "apt install cmdtest". Do not follow this "hint".  Installing cmdtest will result is subsequent yarn builds failing with message that "No such file or directory: 'install'" where "install" can be be any of the yarn build targets: [dev | install | zip]. If you get to this then do "apt remove cmdtest".

Now you have all the "bits" you can pre-validate the custom theme using gscan via command line or change theme within you development environment via UI and any error in your theme will be reported during the activation.

NOTE: I do my development on Ubuntu (now 22.04). I recommend that you have each of your various development project in dedicated virtual machine (VM).


Designing Around Ghost & HandleBars Containts

Custom Attributes Limits

To create a "multi-volume" site means that you need to have:

  • Title & Description - details for each of the "volumes". This will be available on the "volumes" index landing paage.
  • Blog Logo - for each of the different "volumes". This will be visible on the "volume" index page and also on the menu bar for the "volume" post pages.

Looking at the "Casper" theme this uses attributes defined in "package.json" for the definition of "Site Title". "Site Description", "Site Logo" and "Site Icon".

Ideally I would just add additional customer attributes into the "package.json" file. However Ghost restricts the custom attributes to a maximum of 15 attributes and there are already 10 being used for existing Casper theme attributes. This leaves just 5 available for additional "volumes".

As each image requires an attribute, I decided to:

  • Use 4 attributes for volumes "Logo" image definitions, as each logo needs to have dedicated attribute.
  • Use 1 attribute to hold a JSON array string of the alternate titles and descriptions. This workaround allows you to "squeeze" information into a single attribute and so save on available custom attributes.

This will allow my theme to host 5 volumes, the main volume and 4 sub-volumes.

Handlebars Template Processing

Currently the Casper theme has a single index handlebars template file:

  • index.hbr - this is the main site landing page. This displays the site cover image, navigation menu, title logo and description. There is Handlebars template logic which tests to see if there is cover image and site logo defined and optionally just display Site Title text rather than the logo.

To allow for multi-volumes you can, either:

  • Change the "Route" yaml file - to uses different "index" templates and control the rendered index post contents using "tag" filters The result would use a template for each " volume" which explicity used the particular custom attributes for the image file and JSON array index (for the Title / Description). An example route,yaml for this would be something like this (for 4 volumes):
routes:

collections:
  /volumeii/:
    permalink: /volumeii/{slug}/
    template: indexii
    filter: tag:hash-volumeii

  /volumeiii/:
    permalink: /volumeiii/{slug}/
    template: indexiii
    filter: tag:hash-volumeiii
 
   /volumeiv/:
    permalink: /volumeiv/{slug}/
    template: indexiv
    filter: tag:hash-volumeiv
    
  /:
    permalink: /{slug}/
    template: index
    filter: tag:hash-volumei

taxonomies:
  tag: /tag/{slug}/
  author: /author/{slug}/

For this to work you would need to use internal tags: "#volumei", "#volumeii", "#volumeiii" & "#volumeiv" to seperate the posts into different groups (or "volumes"). There is a problem though...

Due to the limitation on the number of custom attributes the Title & Description attributes have been (optimistically) stored in a JSON string like this: "[['Volume 2', 'Volume II'], ['Volume 3', 'Volume III'], ['Volume 4','Volume IV']]".

I say optimisically as I was hoping to beable refer to a given title or description within the HandleBars template using something like: "@custom.volume_details[2][1]" ==>> "Volume IV". This is not the case as the attribute data is treated as a string and to convert it to JSON array and extract a particular element requires that you create a new HandleBars Helper function, to get the required item out of the array. The HandleBar Helper could be invoked like this: "{{volume_detail @custom.volume_details volume=4 item='description'}}".

  • Other option was to change the route file as above, but now use a single index.hbr (or two, one for main volume and one for the sub-volumes) file which uses the page "url" to determine which volume it is on. The route.yaml file would still look as above but now the HandleBars Helper would use the the url to select the right detailed information. This again uses the internal tags to seperate out the volumes and uses the overloaded JSON string with title and description information.

The JSON description/titles array would now be: "[['volumeii', 'Volume 2', 'Volume II'], ["volumeiii", 'Volume 3', 'Volume III'], ['volumeiv",'Volume 4','Volume IV']]". So each array element now contains 3 elements with the first element being the "Route" url prefix (note that the example template now has either "index" = main volume or "indexii" =  sub-volumes):

routes:

collections:
  /volumeii/:
    permalink: /volumeii/{slug}/
    template: indexii
    filter: tag:hash-volumeii

  /volumeiii/:
    permalink: /volumeiii/{slug}/
    template: indexii
    filter: tag:hash-volumeiii
 
   /volumeiv/:
    permalink: /volumeiv/{slug}/
    template: indexii
    filter: tag:hash-volumeiv
    
  /:
    permalink: /{slug}/
    template: index
    filter: tag:hash-volumei

taxonomies:
  tag: /tag/{slug}/
  author: /author/{slug}/

Again optimistically I thought that this could just use a Helper which would be invoked using: "{{volume_detail @custom.volume_details @url item='description'}}".

My optimism, was not helpful ;-) as:

  • HandleBars does not allow "nested" templates and the url extract helper is invoked using: {{url absolute='true'|'false'}}. So you cannot have a HandleBar template like this: "{{volume_detail @custom.volume_details {{url}} item='description'}}"
  • While the title and description Helpers could be used, the logic within the index template for the image includes: (1) a check to see if the image is defined and then (2) if so then use the image, otherwise (3) use the title (text):
    {{#match @custom.header_style "!=" "Hidden"}}
        <div class="site-header-inner inner">
            {{#match @custom.navigation_layout "Logo on cover"}}
                {{#if @site.logo}}
                    <img class="site-logo" src="{{@site.logo}}" alt="{{@site.title}}">
                {{else}}
                    <h1 class="site-title">{{@site.title}}</h1>
                {{/if}}
            {{/match}}
            {{#if @site.description}}
                <p class="site-description">{{@site.description}}</p>
            {{/if}}
        </div>
    {{/match}}

This logic cannot be writen using simple HandleBars Helper that just returns a JavaScript object based on index/url, rather the logic needs use a HandleBars "Block" helper like:

  • {{match condition}}/{{if true/false/defined}}
  • {{else}}
  • {{/match}}/{{/if}} "Block Helper".

So to create a "multi-volume" theme cannot be done just using HandleBars template and existing Helpers. Rather it requires some new helper functions and you also need to ensure that the route.yaml file is updated as well.

Multi-Volume Theme Needs

Allowing for Ghost custom attribute and HandleBars constraints to create a Ghost theme that looked and felt essentially the same as Casper but with Multiple Volumes, would need to:

  • Use Custom route.yaml file - so blog volumes got broken into separate collections
  • Use "internal" #tags to control which volume the blog post go into
  • Overload the custom attributes to allow for extra titles/descriptions to preserve the other attributes for sub-volume images
  • Have volumes images for main and the four sub-volumes
  • Write additional HandleBars Helpers to provide url aware selection of titles/descriptions and determine which logo image to use

The result is that the theme will be impacted by Ghost updates as this will overwrite the custom HandleBars Helpers and these will need to be resinstalled after upgrades. However having multiple volume Ghost Blog make this worth it.


Ghost HandleBars Helpers

To provide "multi-volume" support requires awareness of the "volume context" (which translates to awareness of the "route" url for the volume). The mapping for this is provided via the "custom.volume_details" (as above). The current Ghost Helpers are "contexts" aware, for the following contexts: index, page, post, author, tag, error. Each of these "contexts" has an associated different rendered page type (using index.hbs, page.hbs, post.hbs, author.hbs, tag.hbs & error,hbs) and to manage this the Helpers return different result depending on the detected context.  The concept of "Multi-Volume" introduces a new "Context" and hence the Helpers need to be aware of this additional context to return the correct results. The critical Helper is: "{{body_class}}". This returns different styles for navigation menu display and this behaviour needs to factor in the "volume",

Currently Ghost does  not support adding your own HandleBars Helpers. So  writing of Helpers is not covered within Ghost documentation. To create new Helpers and make these "like" the existing Ghost Theme Helpers I reviewed the existing helpers (code is: "TryGhost/ghost/core/core/frontend/helpers") and the ways these are invoked, which is via "TryGhost/express-hbs". The core template parsing and invocation appears to be within "express-hbs/lib/hbs.js".

The "reverse engineering" was needed to understand the standard inputs that are passed to a HandleBars Helper so the can determine the context. By using the automatically passed context information, you can avoid passing in custom attribute data via the Helper arguments that is already available through the invoking context object.

Here is my summary of the Helper function inputs:

//
// simple helper: {{helper}}
//
module.exports = function helper() {
  let res = "";
  let len = arguments.length;
  // NOTE: "arguments" is standard input into every JavaScript function
  console.log(`len = ${len}`);
  // len = 1
  // Where arguments[0] == context
  return res;
};
//
// The helper has at least 1 argument, typically idenfied as "options"
// In above simple simple helper example:
//
// let options = arguments[0];
// options = {
//   lookupProperty: <function> - undefined for simple case
//   name: 'helper' >> name of helper
//   hash: {} >> empty for simple helper - see block helper
//   data: {} >> context data including: site && custom properties
//   loc: {} >> details location within template (line / column)
//  };

//
// Block Helper: {{#volume_helper 1}}Volume 1{{else}}Volume 2{{/volume_helper}}
//
module.exports = function volume_helper(volnum) {
  let res = null;
  let test = false;
  let options = arguments[arguments.length - 1];
  let isBlock = options.fn != undefined;
  
  test = inputcheck(volnum);
  
  if (isBlock) {
    if (test) {
      res = options.fn(this); >> result would be 'Volume 1'
    } else {
      res = options.inverse(this); >> result would be 'Volume 2'
    }
  }
  return res;
};
//
// let options = arguments[arguments.length - 1];
// options = {
//  lookupProperty:
//  name: 'volume_helper'  >> name of helper
//  hash: {}
//  fn: <if function>         >> only Block Helper has this..
//  inverse: <else function>  >> only Block Helper has this..
//  data: {} >> context data
//  loc: {}
};

While adding new Helpers is not part of official Ghost API or HandleBars support, it is pretty easy to do this. The helpers are keep in: <GHOST-DIR>/current/core/frontend/helpers. So adding new helpers is just a matter of copying them into the same directory and Ghost will load them automatically on startup.

If you add your own helper you will also have to update the gscan specification version file to include the names of the new helpers you have added. Otherwise you will get Ghost errors in the Admin UI. The current version of the gscan specification file can be found in the Ghost node_modules respository at: <GHOST-DIR>/current/node_modules/gscan/lib/specs/v4.js . The "knownhelpers" can be added by editing: "let knownHelpers = ['match', 'tiers'];"

For the "Trio" theme I create three new helpers:

  • {{volume_item ['t'|'d'|'u'|'l'|'in'|'ic'] [img-class> title-class]}} - to return the correct: title, description, url, logo location, image navigation html element, image cover page html element with classes
  • {{if_volume n}} - test to see of this page is for a particular volume (n=1,2,3,4,5) or is a volume landing page (n=0)
  • {{volume_class}} - as multi-volume alternate to {{base_class}}, returning the css class depending on the volume/page type.

The Code...

To learn about the behaviour of Casper, Ghost and HandleBars I did quite a bit of testing with changes in route.yaml and copied the Casper index.hbs to indexii.hbs "Trio" HandleBars template and also wrote a few debugging HandleBars Helpers.

My intial implementation uses two index files:

  • Original Casper Index - index.hbs
  • Trio Index - indexii.hbs

In conjunction with url based selection via HandleBars Helpers.  This reduces the amount of code, but result is more "computation".

The alternate would be to have: indexii.hbs, indexiii.hbs, indexiv.hbs and indexv.hbs.

I choose the more computation (i.e. lower performance) option first as this minimised the amount of code and better to get one piece of code working first and then optimise rather than have bugs across multiple bits of code. It is easy to add extra index<ver>.hbs files later to improve performance.

The main "trick" with understanding how Ghost/Casper interact is in the "{{base_class}}" HandleBars helper. This controls which style is applied to the page based on the context. There was no choice but to write a new helper to get multi-volume theme going.

NOTE: To do's on code  include:

  • Write a little script to make install easier
  • Add some logic that does test of theme Custom variables to see if these and route.yaml are all configured ok

Migrating Instance Content to Multi-Volume Site

Now with Trio "multi-volume" theme installed the next step is to migrate the content from the seperate sites to the new consolidated one.

The steps to do this are:

  1. If you have ghost fronted by proxy server (very likely as this is recommended deployment pattern) then add URL rewrite rules that reflect your target server. In my case this meant that: "<SUB-VOL>.domain.com/" redirects to: "<MAIN-VOL>.domain.com/<SUB-VOL>/"
  2. Add new "internal" tags that you wish all the the content from the target instance to be put under. This should be done with the target multi-volume site and the source instance sites
  3. For each of the source sites tar/zip up all the content files that are in <GHOST-DIR>/content/images
  4. Transfer the image tar/zip and expand into new target <GHOST-DIR>/contents directory
  5. On the source site tag all the content against with its target "internal" tag
  6. Ignore - On target site make sure you have got author for target posts added (note - if not already added the authors come across as part of import)
  7. Via Ghost UI Settings "Lab - Export" export all the content as JSON
  8. Using Web UI "Lab Import" load exported JSON dump
  9. Repeat 5 -> 8 for each individual site...
  10. Adjust the route.yaml and custom configurations to align and have correct titles, code injections and other configuration settings.

NOTE: Each import via the Ghost Web UI will clobber the site global settings, so do this in order of sub-volumes first and then the main volume to ensure site has branding from the main site.

Test and set free ...


Extracting Commento Comments and Reimporting into Ghost ...

Now that the multiple instances of Ghost have been consolidated into single "multi-volume" site, next challenge is to turn on Ghost member commentry and import historical "Commento" commentary into this.

The process to achieve this was:

  • Dump Commento "commenters" table to CSV
  • Load into Ghost Members CSV import template
  • Load via Ghost Members import UI
  • Create denormalised "comments_migrate" tabe in PostgreSQL db
  • Populate via SQL
  • Dump this and load into Ghost MySQL
  • Copy contents into Ghost native members tables..

As this was a little bit more complicated than initially expected details are covered in "Migrating Commento Comments into Ghost".

The approach used should be able to be readily adapted to loading comments from other Comment and Blogging platforms (Disqus & WordPress come to mind).


Summary

Trio multi-volume theme is up and running and Commento is retired. This has quite a few benefits:

  • Simpler to manage - Blog and commentry in one place
  • Retired five machines (VMs) - By consolidating four machine into one  and shutting down Commento and its PostgreSQL Database Server.

To implement Trio on your Ghost blog preceed to trio github repository - please fork and push improvements

On to the next to do ...

Trio - A Multi-Volume Ghost Theme

Use and like Trio, say thank you with payment (suggest $30 - $50)

Trio - Thank you

References & Links:

Handlebar Themes - Ghost documentation overview documentation on Themes

Ghost Theme Context - Ghost documentation on Theme Contexts...

Ghost Theme Helpers - Ghost docunmentation on Theme helpers, which includes support for user defined attributes (which are presented as part of site design UI) and how these can be extended and acessed from the Theme templates.

Custom Configuration -Ghost documentation on the configuration file and parameters

Building CSS Files -

Custom Settings - Ghost documentation on using Custom Setting which defined in Theme's package.json file. Documentations states that there is limit of 15 custom settings which is rather arbitrary and Casper alreadly uses 10 of these ... so future feature request

Multiple Themes - Comminity posting on whether Ghost can support mutlipel themes (it cannot) and way to work around this, useful as into into "stretch" design

trio github repository - please fork and push improvements

Adding Custom Homepage - the need for the ability to have better landing pages is covered here with "hacked" indexes and custom routes

"Theme Development - The Ultimate Guide" - Another guide on theme development but without concrete example

"Adding HandleBar Helpers" - this community post provides guideance on how to add your own HandlerBar Helpers to Ghost, so they can be used in your theme processing. Note that import for "SafeString" has:

  • "requires('../services/rendering');" which should now be:
  • "requires('../services/handlebars');"

"Updating gscan helper list" - if you add new HandleBar Helpers then these will not be recognised by "gscan", resulting in errors. To update gscan you need to edit "lib/specs/vXX.js" file to add your new custom Helpers.

xid for mysql - this my sql version of MongoDB 12 bit binary uid generator. After installing you can use "select hex(xid_bin()) to creat Ghost like VARCHAR(24) hex Id.

Trio - aim is to support provide a "Multi-Volume" theme, though in its current guise this is limited to 5 volumes - main and 4 x sub-volumes. Ghost Trio - a Samuel Becket television play, "Its three 'acts' reflect Beethoven's Fifth Piano Trio (Opus 70, #1), known as The Ghost because of the slightly spooky mood of the second movement, Largo. The passages selected by Beckett are from the "ghostly" second theme." and for the old (as they might remember) and the young who might like it... . Trio also acknowledges the "Ghostly Trio" where from "Casper - The Friendly Ghost". Picture - from "Casper the Friendly Ghost Classics Vol 1" - with the "Ghostly Trio", but wait ... is that the FeeBSD Daemon there?!

"The Ghostly Trio"

Appendix

This code was not used, as it was easier to just do Commento migration via SQL...

---
--- Commento to Ghost comments migration
---
const ObjectId = require('bson-objectid').default;

  let file = ope(csv file);
  let line = null;
  let email = null;
  let post_uid = null
  let sql = null;
  let db = new DB(name, id, password)l
  
  for (line in file) {
    sql = `select * from members where email = '${line["email"]}';`;
    let member = db.query(sql)l
    if (member != null) {
      set slug = strip_volume(line["path"]);
      sql = `select * from posts where slug = '${slug}';`;
      let post = db.query(sql);
      if (post != null) {
        let uid = ObjectId().toHexString();
        
        sql = `insert comments values(${uid},${post.id},${member.id},,'published',${line.html},,${line.createdate},`;
        if (line.deleted) {
          sql = sql + `${line.deletedate});`
        } else {
          sql = sql + `${line.createdate});`
        }
        db.query(sql);
      }
    }
  }