I have been interested in creating a minimalistic content management system for some time now. Content Management Systems (CMS) is what I do in my day to day job, and I felt like there are many opportunities in this area to use the modern web platform. I have seen and used some newer products in this area, but for a host of reasons I wanted to develop my own solution. In light of this I began doing a proof of concepts with Neo4j, and I developed out a host of CMS features built on this graph database.
The most interesting thing that Neo4j enabled was multiple interpretations of the data hierarchy, instead of having a single canonical hierarchy of data. This was made possible by two nodes in Neo4j being able to be connected by multiple edges. However I found that while there were many interesting opportunities in that area, I wanted to scale back and attempt a CMS in a single day of effort, and I was seeing that my plans with Neo4j, while powerful, would take months to get right.
This is where I switched to using Firebase, specifically the Firestore database and the static hosting. My goals were the following:
- The ability for localization and translation based upon country and language.
- A hierarchy of pages stored as data in the database, where breadcrumbs, the home page, child pages, navigation links, and so on could be derived from the hierarchy.
- The content of the pages would exist as data in the database as well. To some degree even the format and layout could be determined by data.
- No server side logic or rendering whatsoever. This means that the page rendering all must happen on the client side.
- Very limited amounts of vendor JS sent to the browser.
How did I go about solving for these goals? Let's start by looking at the data.
Defining a Data Hierarchy
The Chosen Technology
Firestore is a document database. The most common example of this would be MongoDB, but there are many others. In a document database there exists two top level entities: documents and collections. The rules are simple, documents contain data, usually in a form that is easily converted to or presented as JSON, and collections contain a number of documents, where each document has a unique ID. Additionally, a document can itself contain collections.
Defining the Hierarchy
For our case, our top level collections will be named "pages". This is where the content for all of the pages in our multiple country, multilingual website will be. You may want other forms of data in your database, and so you want something of a top level namespace to separate out pages from other forms of data.
In the pages collection there will be a number of documents, each representing a country. The ID of each document here should be the two letter country code for the country, such as "us" or "ca".
Each of the countries will contain a collection called children, which contains the child pages of the country page. These children collections will each contain a number of documents that represent the languages available in that country. The ID's of these documents will be the two letter language code of the language, such as "en", or "fr".
These language pages will in turn have a children collection, representing the child pages of the language. From here you can structure your pages to your liking and needs, however every document needs a collection called "children" that represents the child pages of that page (if it has child pages). The way I did this was to have a "home" document in the children collection of the language, and then have a series of child pages in the home pages children collection. Remember that each of these documents represents a page of the website, and will eventually be accessible through a url unique to that page.
The Content of the Pages
Each of these documents, which I will now refer to as pages, will contain data that represents the metadata for the page, such as the page title, the page type, redirect information, and so forth. In addition these pages will contain the content data of the page. This content data will be used to render the HTML of the page. In addition the pages location in the hierarchy of the database will be used to determine links to the home page, links to child pages, and to generate navigation elements and so forth.
Some pages such as the country and language pages shouldn't be shown to users directly but should redirect users to the home page. For this reason these pages need a "type" property of "redirect" and a "redirect" property of the path to the home page that the user should be redirected to. These redirects will also need built into the firebase hosting schema, and is an unfortunate but necessary duplication. I am still looking into a way of preventing the need for this duplication.
Rendering the Page
If we aren't going to have any server side rendering, and yet we need to render this hierarchy of data as HTML pages, then we will have to do client side rendering. The solution is to simply have the client look at the url, and then determine the specified page based upon some rendering logic. The first step in this rendering logic is to have a client side router that determines what data and what logic to use to render the page based upon the url.
Initialize the Firestore Database and Import lit-html
<div class="page-container"></div>
<script type="module">
import {html, render} from '/vendor/lit-html.js';
let app = firebase.app();
var db = firebase.firestore();
// The firebase website told me to do this.
db.settings({
timestampsInSnapshots: true
});
</script>
This assumes the default Firestore hosting setup. All of the JavaScript that we add later in this blog post will be added to this script tag.
Defining the Router
There is a lot to this, so I have boiled it down to a single method. Given a path (such as location.pathname), we can make some assumptions about the structure of our database in order to form a query for the data that we need.
function getPath(path) {
const truePath = ['', '/'].includes(path) ? '/us/en/home' : path;
const parts = truePath.substring(1).split('/')
let query = db.collection('pages');
parts.forEach((part, index) => {
query = query.doc(part);
if (index !== parts.length -1) query = query.collection('children');
});
return query;
}
The assumptions we have made here is that if no useable path is provided then we will redirect to the United States English home page. We then request the pages collection. Finally, we iterate over each part of the path, and assume that each part is the name of a document in the current collection, and then we retrieve the "children" collection within this document.
This forms a query that takes a url path such as "/us/en/home/example". It will return a Firestore query for the document with the id "example" located at the following location:
- pages -> us -> children -> en -> children -> home -> children -> example
Where the bolded words are documents and the italicized words are collections. Now we can use this query to retrieve the data for page within are database, excluding the collection names from the path.
getPath('/us/en/home/example').get().then(page => page.data())
Now we can use this data to generate the HTML for the page.
Rendering the Data as HTML
For this I used lit-html. To do this we use our getPath method to form a query for our page, and then render a template to a specific place on the page using lit-html. When the request finishes our promise will resolve, and the DOM will be updated with our title and description. This assumes that these properties exist on the document in the database representing this page.
let pageQuery = getPath(window.location.pathname);
render(html`
${pageQuery.get().then(page => html`
<h1>${page.data().title}</h1>
<p>${page.data().description}</p>
`)}
`, document.querySelector('.page-container'));
Contextual Rendering
What if we want a link to the 'home page', or a list of links to the top level pages below the home page? This is called contextual rendering.
let pageQuery = getPath('/us/en/home');
let homePageQuery = getHomePageQuery(pageQuery)
.then(homePageQuery => homePageQuery.get());
let navPagesQuery = getNavPagesQuery(pageQuery);
homePageQuery.then(homePage => console.log(homePage.data()));
render(html`
${pageQuery.get().then(page => html`
<h1>${page.data().title}</h1>
<p>${page.data().description}</p>
`)}
<div class="navigation">
${homePageQuery.then(homePage => html`
<a href="${homePage.path}"
class="${homePage.path === window.location.pathname ? 'active' : ''}">
${homePage.title}
</a>
`)}
${navPagesQuery.then(navPages => navPages.map(navPage => html`
<a href="${navPage.type === 'redirect' ? navPage.redirect : navPage.path}"
target="${navPage.type === 'redirect' ? '_blank' : ''}"
class="${navPage.path === window.location.pathname ? 'active' : ''}">
${navPage.title}
</a>
`))}
</div>
`, document.querySelector('.page-container'));
You will notice that we use the "path" property of the Firestore document that represents the page in order to set the href of the links. This means that we need to provide a canonical path in the "path" property of each page. We could also generate this value for each document by looking up the hierarchy and removing the collection names, or by having a database hook to generate these values when the documents representing the pages are created.
Here we define a set of links using two new functions, getHomePagesQuery and getNavPagesQuery. These functions will take the current page query, and crawl up the hierarchy of pages and collections to find a document with the property "type"="home". Once it finds this home page, the getHomePagesQuery function will return a query for this page. The getNavPagesQuery function will then formulate a query for all of the child pages of the home page and return this new query.
In this way you can do contextual rendering. That is, the navigation can be generated based upon the current pages location in the page hierarchy. A similar approach can be followed to create bread crumbs or find a list of child pages. Example implementations of the two methods used above are shown below:
function getHomePageQuery(pageQuery) {
return (async () => {
let type = (await pageQuery.get()).data().type;
while (type != 'home') {
pageQuery = pageQuery.parent.parent;
type = (await pageQuery.get()).data().type;
}
return pageQuery;
})();
}
function getNavPagesQuery(pageQuery) {
let homePageQuery = getHomePageQuery(pageQuery);
return homePageQuery
.then(homePageQuery => homePageQuery.collection('children').get())
.then(children => {
let firstLevelPages = [];
children.forEach(child => {
firstLevelPages.push(child.data());
});
return firstLevelPages
});
}
For this "getNavPagesQuery" method you will want to create more page below your "home" page, so that you can see a list of links to these "first level" pages - which could be the start to site navigation.
Extending the Paradigm
This example is clearly only a start to using the Firebase Firestore as a hierarchical content management system. The examples provided here would need developed and need further organization, but the basic concept of contextual data and rendering with a hierarchy of pages has been demonstrated. I am using this concept as a starting point for my personal about me website: about.alexlockhart.me.
What I have described here is not beholden to the chosen technologies. In fact, the only technologies that were required was a document database with a promise based JavaScript API, and a client side rendering library. I chose the Firebase Firestore and lit-html for this, but many other options are available. The point is that it is not about the specific technology used, its about the paradigm employed.
Page Content
We could extend this by creating more complex data within each page document, and then rendering this complex data as a fully fledge web page that could have text, images, and other features. In a future blog post I will demonstrate how to do this.
SPA + PWA
Beyond this, we are half a step away from a Single Page Application with deep linking that comes for free based upon the design decisions we have made. We are also very close to a Progressive Web App which will allow our CMS to be installable as an app on modern platforms and available offline. I will have a blog post coming soon on extending this paradigm in these directions.
Translation Integration
Lastly, we could also implement database hooks and a translation API for generating other country and language page hierarchies when updates are made to the United States English hierarchy. I plan on also making a blog post about this, so stay tuned!
For more of my articles checkout my blog at www.alexlockhart.me
Top comments (0)