TucsonJS

August 2016

Keystone.js

The Node.js Answer to Wordpress?

Charlie King

Who are we?

Richard Key

Software Engineer @ Weedmaps

Creative Director @ Loot Storm

Agenda

  • What is KeystoneJS? 
  • How it works
  • Advantages / Disadvantages
  • Questions / Comments

What is KeystoneJS?

The open source framework for developing database-driven websites, applications and APIs in Node.js. Built on Express and MongoDB.

CMS options

Gatsby

Content as a Service (CaaS)

Static site generators

Self hosted Content Management

Keystone Features

MVC - Customizable Express Routes, Mongoose Models and Views ( Jade, Swig, etc )

Out of the box - Authentication, Auto-generated Admin, Forms, email sending

Plugins - Google Maps, Google analytics, Mandrill, Azure, S3, etc. 

How it works

Configuration

Views (Controllers)

Models + DB

Middleware (optional)

Routes

Templates

Configuration

require('dotenv').load();

// Require keystone
const keystone = require('keystone');

keystone.init({
    'name': 'Your App',
    'brand': 'Your App',
    'less': 'public',
    'static': 'public',
    'favicon': 'public/favicon.ico',
    'compress': true,
    'views': 'templates/views',
    'view engine': 'jade',
    'emails': 'templates/emails',
    'auto update': true,
    'session': true,
    'auth': true,
    'user model': 'User',
    'logger': "dev",
    'cookie secret': process.env.COOKIE_SECRET || "sdfkjkjkjdf",
    'mongo': process.env.MONGO_URI || "mongodb://localhost/your_app"
});

// Load your project's Models
keystone.import('models');

// Setup common locals for your templates. The following are required for the
// bundled templates and layouts. Any runtime locals (that should be set uniquely
// for each request) should be added to ./routes/middleware.js
keystone.set('locals', {
    _: require('lodash'),
    env: keystone.get('env'),
    utils: keystone.utils,
    editable: keystone.content.editable,
    clientGaId: process.env.GA_TRACKING_ID
});

// Load your project's Routes
keystone.set('routes', require('./routes'));

// Configure the navigation bar in Keystone's Admin UI
keystone.set('nav', {
    'posts': ['posts'],
    'pages': ['pages'],
    'enquiries': 'enquiries',
    'users': 'users',
    'metadata': ['tags', 'categories'],
    'site configuration': ['configurations']
});
keystone.set('wysiwyg additional buttons', 'styleselect, blockquote, Figure');
keystone.set('wysiwyg additional plugins', 'visualblocks, image');

// Start Keystone to connect to your database and initialise the web server
keystone.start();

Models

const keystone = require('keystone');
const Types = keystone.Field.Types;

/**
 * Post Model
 * ==========
 */

const Post = new keystone.List('Post', {
    map: { name: 'title' },
    autokey: { path: 'slug', from: 'title', unique: true },
    defaultSort: '-publishedDate'
});

Post.add({
    title: { type: String, required: true },
    slug: {
	type: String,
	required: true,
	initial: false,
	default: 'slug-goes-here',
	note: "The slug is the unique URL for the item, e.g. videos/slug"
    },
    subtitle: { type: String, required: false },
    state: { 
        type: Types.Select, 
        options: 'draft, published, archived', 
        default: 'draft', 
        index: true 
    },
    author: { type: Types.Relationship, ref: 'User', index: true },
    publishedDate: { type: Types.Datetime, index: true, default: Date.now },
    image: { type: Types.CloudinaryImage, select: true},
    content: {
	extended: { 
            type: Types.Html, 
            wysiwyg: true, 
            height: 400, 
            note: "The content of the post" 
        }
    },
    tags: { type: Types.Relationship, ref: 'Tag', many: true },
    category: { type: Types.Relationship, ref: 'Category', many: false }
});

Post.schema.virtual('content.full').get(function () {
    return this.content.extended || this.content.brief;
});

// index for search
Post.schema.index({
    tags: 'text',
    title: "text",
    subtitle: "text",
    author: "text",
    "content.extended": "text"
});

Post.defaultColumns = 'title, state|20%, author|20%, publishedDate|20%';
Post.register();

Middleware

/**
 * This file contains the common middleware used by your routes.
 *
 * Extend or replace these functions as your application requires.
 *
 * This structure is not enforced, and just a starting point. If
 * you have more middleware you may want to group it as separate
 * modules in your project's /lib directory.
 */

const _ = require('lodash');
const keystone = require('keystone');

/**
 Forces a redirect to SSL (HTTPS)
 */
exports.forceHTTPS = function requireHTTPS(req, res, next) {

    if (process.env.NODE_ENV !== 'development' &&
        req.headers['x-forwarded-proto'] !== 'https') {
	return res.redirect(301,'https://' + req.get('host') + req.url);
    }

    next();
}

Routes

const keystone = require('keystone');
const middleware = require('./middleware');
const importRoutes = keystone.importer(__dirname);

// Common Middleware
keystone.pre('routes', middleware.forceHTTPS);


// Import Route Controllers
const routes = {
    views: importRoutes('./views')
};


// Setup Route Bindings
exports = module.exports = function ( app ) {
    // Views
    app.get('/', routes.views.index);
    app.get('/search', routes.views.search);
    app.get('/articles/:category?', routes.views.blog);
    app.get('/articles/post/:post', routes.views.post);
    app.all('/contact', routes.views.contact);
    app.get('*', routes.views.page);

    // NOTE: To protect a route so that only admins can see it, use the requireUser middleware:
    // app.get('/protected', middleware.requireUser, routes.views.protected);
};

Views

const keystone = require('keystone');
const async = require('async');

exports = module.exports = function( req, res ) {

    const view = new keystone.View(req, res);
    const locals = res.locals;

    // Init locals
    locals.section = 'blog';
    locals.filters = {
	category: req.params.category,
	tag: req.query.tag
    };
    locals.data = {
        posts: [],
        categories: []
    };

    // Load the current category filter
    view.on('init', function( next ) {

	if ( req.params.category ) {
	    keystone.list('Category')
		    .model.findOne({key: locals.filters.category})
		    .exec(function( err, result ) {
		       	locals.data.category = result;
			next(err);
		    });
	} else {
	    next();
	}

    });

    // Load the current tags filter
    view.on('init', function( next ) {

    	if ( locals.filters.tag ) {
	    keystone.list('Tag')
            .model
            .findOne({tag: locals.filters.tag})
            .exec(function( err, result ) {
	        locals.data.tag = result;
		next(err);
	    });
	} else {
	    next();
	}

    });

	// Load the posts
    view.on('init', function( next ) {

        const q = keystone.list('Post').paginate({
	    page: req.query.page || 1,
	    perPage: 12
	})
	.where('state', 'published')
	.sort('-publishedDate')
	.populate('author category');

	if ( locals.data.category ) {
	    q.where('category').in([locals.data.category]);
	}

	if ( locals.filters.tag ) {
	    q.where('tags').in([locals.data.tag]);
	}

	q.exec(function( err, results ) {
	    locals.data.posts = results;
	    next(err);
	});

    });

	// Load the featured posts
    view.on('init', function( next ) {

        const q = keystone.list('Post')
        .model.find()
    	.where('state', 'published')
    	.where('featured', true)
    	.sort('-publishedDate')
    	.populate('author category');
    
        if ( locals.data.category ) {
            q.where('category').in([locals.data.category]);
        }
    
        q.exec(function( err, results ) {
            if ( results.length ) {
    	        locals.data.featuredPosts = results;
            }
            next(err);
        });

    });

    // Render the view
    view.render('blog');

};

Templates

extends ../layouts/default

mixin post(post)
    .post-container
	a(href='/articles/post/' + post.slug).post(data-ks-editable=editable(user, {list: 'Post', id: post.id}))
		.post-heading
	            h2= post.title
		        if post.subtitle
			    h3=post.subtitle
			if post.category
			    .post-block-category(title="#{post.category.name}",style="background-image:url(#{post.category._.image.src()")
    if post.image.exists
        .img-wrapper
	    img(src=post._.image.src())

mixin paginationBlock
    .pagination-container
	span.pagination-prev
	    if data.posts.previous
		a(href="/articles#{filters.category?'/'+filters.category:''}?page=#{data.posts.previous}#{filters.tag?'&tag='+filters.tag:''}") Previous Page
		    span.text-weight-normal.paination-pages
			strong #{data.posts.first}
			|  to 
			strong #{data.posts.last}
			|  of 
			strong #{data.posts.total}
			|  posts
		    span.pagination-next
			if data.posts.next
			    a(href="/articles#{filters.category?'/'+filters.category:''}?page=#{data.posts.next}#{filters.tag?'&tag='+filters.tag:''}") Next Page
block content
    .body-main.dirty-bg.pad-top
        .container-fluid: .row
		.col-lg-12
		    if filters.category && !data.category
			    h3.text-muted Invalid Category.
		    else
			    if data.posts.results.length
				    if !filters.tag && data.featuredPosts && data.featuredPosts.length
					    .mobile-featured.hide-on-desktop
						    h1.tag-header Featured Articles
						    .blog
						        each post in data.featuredPosts
							    +post(post)
				    if filters.tag && !filters.category
					    h1.tag-header
						    | Tagged:
						    em  #{filters.tag}
				    if filters.category && !filters.tag
					    h1.tag-header
						    | Category:
						    em  #{filters.category}
				    if filters.category && filters.tag
					    h1.tag-header
						    | Category:
						    em  #{filters.category}  
						    | and Tagged: 
						    em  #{filters.tag}
				    h1.tag-header.hide-on-desktop Articles
				    if data.posts.totalPages > 1
					    +paginationBlock()
				    .blog
					    each post in data.posts.results
						    +post(post)
				    if data.posts.totalPages > 1
					    +paginationBlock()
			    else
				    if data.category
					    h3.text-muted There are no posts in the category #{data.category.name}.
				    else
					    h3.text-muted There are no posts yet.
block js
	script(src='/js/blog.min.js',defer=true)

Advantages (3.x)

ExpressJS

Familiar JS tools

Modern CSS for styling with integrated tooling

Performance

Go

Node.js

PHP

HHVM

Wordpress (PHP)

Wordpress (HHVM)

4542

3614

1773

3090

854

1259

Middleware

Middleware is standard ExpressJS middleware, so you can leverage the full Express ecosystem

Community

  • Great community support, no corporate overlords
  • Many new features in the 4.x roadmap (Themes, Plugins, Admin UI extensibility)
  • Built with React and leverages that community, with solid core principles

Disadvantages (3.x)

Plugin system

  • Very few compared to other (WP, Drupal)
  • No way to create custom plugins!

Version

  • Currently undergoing rewrite, so current branch feels stale.. No new features

Admin UI

  • No way to manage the site using a UI. All configuration is done via the source code

Questions/Comments?

TucsonJS August 2016

By Charles King

TucsonJS August 2016

  • 1,385