Middleman - Vue Search

Published on May 04, 2018

About 9 minutes read

Introduction

When I started creating this website I experimented with a number of different platforms and technologies until I settled on Middleman. I went throught the all usual suspects like Wordpress, Ghost, a custom Ruby on Rails CMS, etc, but I felt a static website would cater to my needs quite nicely.

I wanted to be able to enjoy coding my website, so I wanted to pick a Ruby based platform. I had two main options: Jekyll and Middleman. I created a couple of test projects to get a feel for both frameworks and in the end I chose Middleman, because it felt more intuitive and had support for multiple templating languages, not just Markdown.

When it comes to the advantages of static websites, you get a number of great benefits like great security and minimal maintenance (something that was very high up my required features list), fast load times (especially if you use a CDN to serve your content) and the ability to create something that is trully tailored to your needs. The functionality that you miss out on is things like search, user form actions (like the ability to subscribe to a newsletter), etc. Most marketing or informational websites have no need for most of these features with one obvious exception: search. Even though it's possible to manually create indexes or tag clouds where your user can try to navigate to reach his/her desired content, it's neither ideal nor very practical. So I knew that I had to find a way to implement search functionality on my website somehow.

I went through a number of considerations when I was trying to pick the way to implement search:

  • Have a customized Google Search page. It was an option that I chose to not follow simply because it felt like a cheat. It also felt that it would take away from the uniform design of the website and the user's experience wouldn't be as "native" as I intended.

  • Create a Hybrid architecture having a backend API serve all the content to Middleman and having Middleman display just the content and rely solely on the API to get its data. Middleman post the contents of the website to a hosted instance of ElasticSearch or Solr and create a form that would post the search query to that server and display the results to the user via an AJAX call. That is how JAMStack websites work in principle. That would increase the maintenance and actual cost of running my website

  • Use a commonly used solution amongst Middleman pages like Lunr. It bears a number of similarities to the Apache Solr engine but it runs on the client instead of a server. I read through the documentation and looked at a number of middleman related gems that integrated the Lunr engine. For some reason I couldn't get them to work the way I wanted to (the most probable reason was that I didn't spend enough time on it)

None of these solutions proved satisfactory, so I decided to create my way of search.

Say hello to Vue Search

Vue is a new(-ish) Javascript framework developed by Evan You, a former Google developer. It is considered to be relatively straightforward to pick up and use and has a very easy learning curve.

I started using Vue for a business management solution I was implementing and found it very intuitive and enjoyable to use. Vue allows you to use Single Page Components (where a file holds the template, functionality and styling for each component) or add all your components in one file. In order to use Single File Components, you need to use Webpack (which I currently am). The search functionality in this project is so small and non-complex, that creating Single File Components seems a bit redundant.

Integration of VueJS is very easy. You just need to add the dependencies for Vue and Vue Resource or Axios (for fetching the entries file), as well as your custom Vue code in the search page and you're set!

The include section of the search page is as follows:

<%= javascript_include_tag "https://cdn.jsdelivr.net/npm/vue@2.5.13/dist/vue.js" %>
<%= javascript_include_tag "https://cdnjs.cloudflare.com/ajax/libs/axios/0.18.0/axios.min.js" %>
<%= javascript_include_tag "vuesearch" %>

I've used Vue and Axios from a CDN to make things simpler. I will not be using Single Page Components as that would be overkill for my needs and should I need to refactor it to SPC in the future it wouldn't be difficult at all. One caveat of using a CDN is that if you're not connected to the internet while you're developing your website, you will not be able to use feature at all.

The basic idea behind it all

Whenever you're accessing a website, whether it is static or dynamic, the interaction follows very similar principles. The client requests data from a server and based on the type of technology of the website, the server determines the appropriate method of transferring the requested data to the client. When you're requesting the frontpage of the NY Times from a browser, the browser creates an HTML file that sends to your browser and it gets displayed on your screen. In the case of searching you need an index of some sort that contains the keywords that you can go through in order to determine the location of the resource you are looking for. For websites with a database powered backend, that means sending a query to the database and letting the database determine the location of the resource you're looking for. What I tried to do was to replicate the same kind of idea, but shift the resource determination process to the client instead of the server.

Backend Configuration

In order for the client side search to work, I needed to create an index of all the entries on my website. This would serve as my database for the search.

My website is broken down into three areas: Articles, Projects and Photo Albums. Every one of those areas is another instance of the middleman-blog extension with it's own set of configuration settings, but there is a large overlap of common data for each of these areas. This is by design, because I wanted to be able to reuse partials and layouts between areas with minimal changes. I needed to produce a JSON file that could then be parsed by a Vue Component that would determine whether the resource that was requested existed or not. So using one of Middlemans' template engines I created an json.erb file that gathered all the necessary data for this task:

<%
entries = []

data.site.site_sections.each do |section|
  blog(section).articles.each do |article|
    entry = {
      :title => article.title,
      :date => formatted_date(article.date), 
      :tags => titleize(tags_as_a_string(article.tags)) || '',
      :category => article.data.category || '',
      :url => article.url,
      :content => strip_tags(article.body.strip),
      :summary => resource_summary(article),
      :cover_image => image_source(article.data.cover),
      :blog => section
    }

    entries << entry
  end
end
%>

<%= entries.to_json %>

These entry objects are then added to an array that holds all results and finally gets converted to JSON. At this point I have an entries.json file in my project root that acts as an index for all entries on my website and can be accessed by my front-end. As you can see I am including the full content of each post in the file. This is because I want to able to have a pseudo-full text search functionality. If I wanted the index file to be smaller or if I had a more elaborate tagging and cataloging process for my posts, I could have ommited that field and relied on the tags to search for posts.

The next step is to create a Vue instance and two Vue components that will take care of the search and display logic.

Enter Vue

Vue component Anatomy

Every Vue component is broken down into three main sections: - the Template section, where the Vue enhanced HTML that gets sent to the client - the Script section, where the main code is listed and - the Style section, where the styling for the individual component is implemented.

Display component

This is the component (or partial in Middleman terms) that gets populated every time we display a found blog resource. The props array is what is allowed to be shown for each found post.

Vue.component('post', {
  template: `
    <article class="search-result-item">
      <a class="search-result-thumbnail" v-bind:style="{ backgroundImage: 'url(' + coverImage + ')' }" :href= url ></a>
      <div class="search-result-content">
        <h2 class="search-result-content-title"><a :href= url>{{title}} ({{ blog }})</a></h2>
        <div class="search-result-meta">
          <span class="search-result-meta-date">{{ date }}</span>
          <p class="search-result-summary">{{ summary }}</p>
        </div>
      </div>
    </article>
  `,
  props: ['title', 'blog', 'date', 'url', 'coverImage', 'summary']  
})

Main search component

This is where the magic happens. The search component is responsible for downloading the entries.json file, getting the user's input, parsing the file and displaying any posts that match the user's query.

When the component is mounted (i.e. instanciated) it downloads the entries.json from the server. Once it is downloaded, it assigns the result of the file to an variable called allBlogs. This variable serves as the internal index file for the lifecycle of the Vue component. When the user submits a search query, the Vue instance goes through all the keys and data in the index variable to determine whether a match is found or not.

If one or more matches are found it assigns them to the blogs array that is then used to display the found results through the use of the post component we created earlier.

Vue.component('searchComponent', {
  template: `<div class="search-container">

    <div id="search">
      <div class="search-box column is-8">
        <div class="error-messages" v-show="error">
          <p class="message is-danger">
            {{ this.error }}
          </p>
        </div>
        <div class="field has-addons">
          <p class="control" style="width: 100%;">

            <input 
              type="text" 
              v-model="searchTerm"
              v-bind:style="{ minHeight: '2rem' }" 
              placeholder="Search..." 
              @keyup.enter="searchBlogs" 
              class="input search-field" 
              required="true"
            >
          </p>

          <p class="control" @click="searchBlogs">
            <a class="button search-field">
              <i class="fa fa-search" aria-hidden="true"></i>
            </a>
          </p>
        </div>
      </div>

  <div class="search-results" v-show="showFound">
    <div class="has-text-centered has-text-grey">
      <div class="strike" style="max-width: 90%;">
        <span class="is-size-6">Search Results ({{ blogsFound }} items found)</span>
      </div>
    </div>
    <div v-for="blog in blogs" class="search-result posts">
      <post 
        :title="blog.title" 
        :blog="blog.blog" 
        :date="blog.date" 
        :url="blog.url" 
        :coverImage="blog.cover_image" 
        :summary="getSummary(blog.summary)"
      />
    </div>
    <br>
    </div>
  </div>
  </div>
  `,
  data: function () {
    return {
      allBlogs: [],
      searchTerm: '',
      blogs: [],
      error: '',
      showFound: false,
      blogsFound: 0
    }
  },
  methods: {
    searchBlogs: function() {
      this.blogs = this.getBlogs();

      if (this.blogs !== undefined && this.blogs !== []) {
        this.blogsFound = this.blogs.length;  
        this.showFound = true;
      }    
    },
    getBlogs: function(){

      this.blogsFound = 0;
      this.error = ''
      this.showFound = false

      if (this.searchTerm.trim().length > 2) {

        return this.allBlogs.filter((blog) => {
          let blogKeys = Object.keys(blog);
          let found = {};

          for(let i = 0; i <= blogKeys.length; i++){
            if(blog[blogKeys[i]]) {
              found = blog[blogKeys[i]].toLowerCase().match(this.searchTerm.toLowerCase().trim());
              if(found !== null) break;
            }
          }
          return found;
        });
      } else {
        this.blogs = [];
        this.error = 'Your search term is too short. Please enter more than 2 characters';
      }
    },
    getSummary(content) {
      var wordCount = 0;
      var summary = [];

      if (content.length > 0) {

        content.split(" ").forEach(function(word) { 
          if (wordCount < 75) { 
            summary.push(word); wordCount++; 
          } 
        });

        return summary.join(' ').trim() + "..."; 
      } else {
        return "No summary found. ";
      }
    }
  },
  mounted: function() {
    axios
    .get('/entries.json')
    .then(console.log("Index downloaded!"))
    .then(
      (res) => {
      this.allBlogs = res.data
    })
  },
});

new Vue({
  el: '#app'
});

One of the main concerns I had when I created the index file on the Middleman side was to make it as small as possible. In order to reduce dusplicate entries, I excluded a summary entry, but because I needed to display the summary when a match is found, I re-created the same functionality with a function inside the Vue Search component.