This is the documentation for an older version of Scroll Viewport. Here you can view the most up-to-date version of the Scroll Viewport documentation.

How we integrated Google Search in our Help Center

Since we noticed a high demand on instructions on how to implement Google search in your own Viewport, we decided to write down the way we implemented it.

What you will need:

  • A Google account
  • jQuery

Setting up a Google Custom Search Engine

For K15t, we use a Google CSE paid plan – this means that only our entries are included in the search results, and not extra advertisements. You can see the main advantages in their pricing docs.

This recipe documents how to seamlessly integrate the Google CSE, avoiding any unwanted advertising. If you are okay with using the default search bar and results that is delivered with CSE, you can go for the default implementation that you can find with 'Get Code' in the control panel.

Adding your pages

By default, Google will search through all pages. You can also add extra entries that do not belong to the same page, like we did to connect with JIRA, Zendesk and our blog.

If you want to also search through JIRA projects, it is recommended to add an entry for each project key.

For example: k15t.jira.com/browse/PDF-*

Testing the Search

After your pages have been set up, you can test them on the right side of the CSE control panel:

If everything works out correctly, it is now time to build the Viewport-powered search results page.

Picking the right results for bigger projects – cse-filter

If you use JIRA alongside your Confluence installation, there is a good chance that looking through existing issues can help the user to find what they are looking for.

Along with the search query of your users, you can send a statement with them to further control the output. This way you can decide which domains are most likely to contain the desired information. In our help center, we also allowed users to search for JIRA issues related to the selected product and K15t pages.

This can be done with the google search syntax that can be passed before any other user query:

( site:help.k15t.com/{link} OR site:k15t.jira.com/browse/{key} OR site:www.k15t.com )

Where we replaced {link} with the sub-page link (e.g. scroll-versions) and {key} with the project key as you can see in the JavaScript in the next section.

Filtering results 

When you let a user search for something, you get a lot of information. Some of these are interesting for us.

fieldmeaningexample
kindtype of resultcustomsearch#result
titletitle capped at 55 charactersWhy Confluence's Page History Feature Doesn't Help When ...
htmlTitletitle formatted as HTMLWhy Confluence's Page History Feature Doesn't \u003cb\u003eHelp\u003c/b\u003e When ...
linkabsolute link to result pagehttps://www.k15t.com/blog/2014/10/why-confluence-s-page-history-feature-doesn-t-help-when-managing-versioned-documentation
displayLinkshort domain linkwww.k15t.com
snippeta description of the pageOct 2, 2014 ... Confluence's built-in versioning functionality is a great way to track changes – but \ndoes it really help you manage multiple versions effectively?
htmlSnippetdescription HTML encodedOct 2, 2014 \u003cb\u003e...\u003c/b\u003e Confluence's built-in versioning functionality is a great way to track changes – but \u003cbr\u003e\ndoes it really \u003cb\u003ehelp\u003c/b\u003e you manage multiple versions effectively?
cacheIdto be used with cachemapsexEeVQiC65cJ
formattedUrlshorter version of the absolute linkhttps://www.k15t.com/.../why-confluence-s-page-history-feature-doesn-t-help-when-managing-versioned-documentation
htmlFormattedUrlencoded formattedUrlhttps://www.k15t.com/.../why-confluence-s-page-history-feature-doesn-t-\u003cb\u003ehelp\u003c/b\u003e-when-managing-versioned-documentation
pagemapObject of Metadata associated with that page e.g. the project key for JIRA results 

Adding a search bar

  1. Firstly, create a page called Search in Confluence, in order to have a page to use for the results.
  2. Add a page property table for using a contentTemplate:

Add the HTML form to the template. In this example, we used fontawesome for the search icon. 

Make sure to replace the data-cse-filter with your custom additional google search params. In our case, we wanted to filter for spaces in JIRA projects and other websites. You can also leave it empty.

includes/search-bar.vm
#set($search = $include.page('search'))
<form data-cse-filter="( site:help.k15t.com/{link} OR site:k15t.jira.com/browse/{key} OR site:www.k15t.com )" id="search" action="$search" method="GET">
    <div class="actions" layout="row">
        <i tabindex="1" class="fa fa-search"></i>
    </div>
    <input
        type="text" name="query"
        value="$!stringEscapeUtils.escapeHtml($!request.getParameter("query"))"
        placeholder="Search documentation" autocomplete="off">
    <input id="product-selector" type="hidden" name="s" value="$space.key">
</form>
<div>
    <ul tabindex="-1" id="search-dropdown-results">
    </ul>
</div>

You can use the same code to also have a dropdown, like on help.k15t.com. For now, we will just focus on the results page.

Creating a search results page

All results have to be dynamically loaded – this means that we had to build our own solution to use a HTML template to display the results.
You can use both templates together to have a search bar plus results on one page. Since we can use the search bar independently, we recommend creating an extra template for the results.

  1. Create a template for results

    templates/search-results.vm
    <section class="content search">
        <div id="search-results-container">
            <div>
                About <span class="search-results-no"></span> results found
                <span class="search-results-product"></span>
                (<span class="search-results-time"></span> seconds)
            </div>
            <div id="search-results">
                <div id="sr-template">
                    <a class="search-result" href="{{link}}">
                        <h4>{{htmlTitle}}</h4>
                        <p>
                            <span class="sr-source">{{source}}</span>
                            <span class="sr-description">{{htmlSnippet}}</span><br>
                            <span class="sr-url">{{htmlFormattedUrl}}</span>
                        </p>
                    </a>
                </div>
            </div>
            <div id="search-pager">
                <a id="search-prev" class="disabled">&laquo; Previous</a>
                <a id="search-next" class="disabled">Next &raquo;</a>
            </div>
        </div>
    </section>
    
  2. To have the users correctly switch to the results, once they entered a search term, we created a switch in our page.vm

    page.vm
    // Always include the search bar
    #parse("includes/search-bar.vm")
    // Switch depending on context
    #if ($isError)
        #parse("templates/content-error.vm")
    #else
        #if($request.getParameter("query") && $page.title == 'Search')
            #parse("templates/search-results.vm")
        #elseif($page.properties.contentTemplate)
            #parse($page.properties.contentTemplate)
        #else
            #if ($page.ancestors.size() == 0)
                #parse("templates/content-home.vm")
            #elseif ($page.ancestors.size() == 1)
                #parse("templates/content-section.vm")
            #else
                #parse("templates/content-article.vm")
            #end
        #end
    #end

    As you can see on line 8, we ask for GET request parameter query, which you can also find on line 7 in the includes/search-bar.vm. The names have to match.

  3. Finally, we need to have some JavaScript to take care of the input and output. Make sure to have your google CSE API and CX key ready to have a working search. You can find them in the Control Panel in Business > XML & JSON menu.
    In the example below, we also include a list of spaces to search with the current location context. This way you can offer more relevant information.

    You have to adapt everything that may not fit your needs. As it is with custom search engines, we had to create a custom solution too. So this is more of an inspiration than a ready-to-use snippet. In many cases you have to remove or change code in order to get the functionality you need.

    It is best to have this in an extra js file, since velocity templates are going to have problems with $ and {}. Since they are reserved for evaluating variables.

    search.js
    'use strict';
    // If you want to search through spaces, please change or extend this.
    var SPACES = {
        VSN: {
            name: 'Scroll Versions',
            link: 'scroll-versions'
        },
        BAC: {
            name: 'Backbone Issue Sync',
            link: 'backbone-issue-sync'
        }
    };
    var GOOGLEAPI = {
        url: 'https://www.googleapis.com/customsearch/v1',
        key: 'enter your api key here',
        cx: 'enter your cx key here'
    };
    var Search = function Search() {
        var _this = this;
        this.initSearch = function () {
            _this.start = ~~_this.getQueryVariable('start', 1);
            $('#product-selector').val(_this.key);
            _this.searchParams = '';
            if (_this.key != 'HELP') {
                _this.searchParams = $('#search').data().cseFilter;
                _this.searchParams = _this.searchParams.replace(/{key}/, _this.key);
                _this.searchParams = _this.searchParams.replace(/{link}/, _this.space.link);
                //searchParams = `( site:help.k15t.com/${space.link} OR site:k15t.jira.com/browse/${key} OR site:www.k15t.com ) `
            }
            _this.query = _this.getQueryVariable('query', -1);
            _this.url = GOOGLEAPI.url + '?q=' + encodeURIComponent(_this.searchParams + _this.query) + '&start=' + _this.start + '&cx=' + _this.cx + '&key=' + _this.apiKey + '&fields=searchInformation,queries,items';
            $.ajax({
                type: 'GET',
                dataType: 'json',
                cache: false,
                url: url,
                success: function success(data) {
                    $('.search-results-time').html(data.searchInformation.formattedSearchTime);
                    $('.search-results-no').html(data.searchInformation.formattedTotalResults);
                    if (_this.product) {
                        $('.search-results-product').html('for ' + _this.product);
                    }
                    $('#search-results-container').addClass('loading-complete');
                    _this.renderResultItems(data.items);
                    _this.renderPager(data.queries);
                },
                error: function error(data) {
                    try {
                        var result = JSON.parse(data.responseText);
                        _this.renderError('Error: ' + result.error.message);
                    } catch (e) {
                        _this.renderError('Unknown Error');
                    }
                    $('#search-results-container').addClass('loading-complete error');
                }
            });
        };
        this.renderPager = function (queries) {
            if (queries.nextPage) {
                $('#search-next').attr('href', _this.replaceOrAddStartParam(queries.request[0].startIndex, queries.nextPage[0].startIndex)).removeClass('disabled');
            }
            if (queries.previousPage) {
                $('#search-prev').attr('href', _this.replaceOrAddStartParam(queries.request[0].startIndex, queries.previousPage[0].startIndex)).removeClass('disabled');
            }
        };
        this.replaceOrAddStartParam = function (oldStart, newStart) {
            if (location.href.indexOf('start=' + oldStart) > 0) {
                return location.href.replace('start=' + oldStart, 'start=' + newStart);
            } else {
                return location.href + "&start=" + newStart;
            }
        };
        // Get GET query param
        this.getQueryVariable = function (variable, defaultValue) {
            var query = window.location.search.substring(1);
            var vars = query.split('&');
            for (var i = 0; i < vars.length; i++) {
                var pair = vars[i].split('=');
                if (decodeURIComponent(pair[0]) == variable) {
                    return decodeURIComponent(pair[1]);
                }
            }
            return defaultValue;
        };
        this.renderResultItems = function (items) {
            var html = '';
            $(items).each(function (index, item) {
                html += this.renderResultItem(item);
            });
            $('#search-results').html(html);
        };
        this.renderResultItem = function (item) {
            // Depending on result origin, we display the source as label - make sure to adapt this!
            var source = '';
            if (item.link.startsWith('https://k15t.jira.com')) {
                source = 'JIRA';
            } else if (item.link.startsWith('https://www.k15t.com/blog')) {
                source = 'Blog';
            } else if (item.link.startsWith('https://www.k15t.com')) {
                source = 'Website';
            } else if (item.link.startsWith('https://groups.google.com')) {
                source = 'Forums';
            } else if (item.link.startsWith('https://k15t.zendesk.com/forums')) {
                source = 'Forums';
            } else if (item.link.startsWith('https://help.k15t.com')) {
                source = 'Documentation';
            }
            return $('#sr-template').html().replace(/\{\{link}}/, item.link).replace(/\{\{htmlTitle}}/, item.htmlTitle).replace(/\{\{source}}/, source).replace(/\{\{htmlSnippet}}/, item.htmlSnippet.replace(/<br>/g, '')).replace(/\{\{htmlFormattedUrl}}/, item.htmlFormattedUrl).replace(/\{\{link}}/, item.link).replace(/\{\{link}}/, item.link);
        };
        this.renderError = function (message) {
            var html = '<p>' + message + '</p>';
            $('#search-results').html(html);
        };
        this.product = false;
        this.key = this.getQueryVariable('s');
        this.space = '';
        if (this.key) {
            if (SPACES[this.key]) {
                this.product = SPACES[this.key].name;
                this.space = SPACES[this.key];
                $('#search input[type=text]').attr('placeholder', 'Search contents for ' + product);
            }
            this.initSearch();
        }
        this.apiKey = GOOGLEAPI.key;
        this.cx = GOOGLEAPI.cx;
    };
    $(document).ready( function() {
        new Search();
    })

Integrate dynamic quick results

If you use the same search bar as provided in Adding a search bar you can use this to dynamically load results as the user types. Again, please place this in an extra file.

dropdown.js
'use strict';
// If you want to search through spaces, please change or extend this.
var SPACES = {
    VSN: {
        name: 'Scroll Versions',
        link: 'scroll-versions'
    },
    BAC: {
        name: 'Backbone Issue Sync',
        link: 'backbone-issue-sync'
    }
};
var RESULTTYPES = {
    'k15t.jira.com': 'JIRA',
    'www.k15t.com': 'BLOG',
    'help.k15t.com': 'DOCS',
    'groups.google.com': 'FORUM',
    'k15t.zendesk.com': 'FORUM'
};
var GOOGLEAPI = {
    url: 'https://www.googleapis.com/customsearch/v1',
    key: 'enter your api key here',
    cx: 'enter your cx key here'
};
var Dropdown = function Dropdown() {
    var _this = this;
    this.setupInputs = function () {
        $('#search .fa-search').keypress(function (event) {
            if (event.keyCode == 13) {
                $('#search .fa-search').click();
            }
        });
        // Search when clicking the search icon
        $('#search .fa-search').on('click', function (e) {
            var searchForm = $('#search');
            if (searchForm.children('input[type=text]')[0].value.length) {
                searchForm.get()[0].submit();
            }
        });
        var input = $('#search input[type=text]');
        input.on('input', function (e) {
            var str = input.val();
            if (str.length >= 3) {
                clearTimeout(_this.timeout);
                _this.timeout = setTimeout(function () {
                    _this.timeout = undefined;
                    _this.doSearch(str);
                }, 500);
                $('#search-dropdown-results').show();
            }
            if (str.length == 0) {
                $('#search-dropdown-results').hide();
            }
        });
        input.on('blur', function (e) {
            setTimeout(function () {
                $('#search-dropdown-results').hide();
            }, 200);
        });
        input.on('focus', function (e) {
            $('#search-dropdown-results').show();
            _this.activateDropdown($('#search-dropdown-results'), _this.selectResult);
        });
        $('form#search').on('submit', function () {
            return false;
        });
    };
    this.selectResult = function (dropdown) {
        var selection = dropdown.find('li.hover a').get()[0];
        if (selection) {
            window.location = selection.href;
        } else {
            $('#search .fa-search').click();
        }
    };
    this.dropdownKeydown = function (direction, dropdown) {
        var items = dropdown.find('li');
        var current = dropdown.find('li.hover');
        if (!current.length) {
            dropdown.children('li').first().addClass('hover');
        } else {
            current.removeClass('hover');
            var next = current.next();
            if (direction === -1) {
                next = current.prev();
            }
            next.addClass('hover');
        }
    };
    this.activateDropdown = function (dropdown, enter) {
        enter = typeof enter == 'function' ? enter : function () {};
        $(document).unbind('keydown');
        $(document).bind('keydown', function (e) {
            switch (e.which) {
                case 13:
                    //Enter
                    enter(dropdown);
                    break;
                case 38:
                    // Up
                    _this.dropdownKeydown(-1, dropdown);
                    break;
                case 40:
                    // Down
                    _this.dropdownKeydown(1, dropdown);
                    break;
                case 27:
                    // Escape
                    dropdown.hide();
                    break;
                default:
                    return;
            }
            e.preventDefault();
        });
    };
    this.getResults = function (query, onResultsAvailableCallback) {
        $('#search-dropdown-results').addClass('active');
        var form = $('#search');
        var action = form.attr('action');
        var key = $('#product-selector').val().replace(/DOC$/, '');
        var searchParams = '';
        if (key != 'HELP') {
            searchParams = $('.search').data().cseFilter;
            searchParams = searchParams.replace(/{key}/, key);
            searchParams = searchParams.replace(/{link}/, SPACES[key].link);
            //searchParams = `( site:help.k15t.com/${spaces[key].link} OR site:k15t.jira.com/browse/${key} OR site:www.k15t.com ) `
        }
        var url = GOOGLEAPI.url;
        $.get(url, {
            key: GOOGLEAPI.key,
            cx: GOOGLEAPI.cx,
            q: '' + searchParams + query
        }, function (result) {
            var types = RESULTTYPES;
            if (!result.hasOwnProperty('items')) {
                return;
            }
            var results = result.items.map(function (item) {
                var type = types.hasOwnProperty(item.displayLink) ? types[item.displayLink] : '';
                var resultItem = {
                    title: item.title,
                    link: item.link,
                    type: type
                };
                return resultItem;
            });
            onResultsAvailableCallback(results, query);
        });
    };
    this.doSearch = function (query) {
        var handleSearchResults = function handleSearchResults(results, query) {
            var resultNode = $('#search-dropdown-results');
            resultNode.empty();
            for (var result in results) {
                if (result <= 7) {
                    result = results[result];
                    var searchQueries = query.toLowerCase().split(' ');
                    // highlight all parts of strings that were found
                    for (var searchQuery in searchQueries) {
                        searchQuery = searchQueries[searchQuery];
                        if (!!~result.title.toLowerCase().indexOf(searchQuery)) {
                            result.title = result.title.replace(
                            // replace occurences only once
                            new RegExp('(' + searchQuery + ')(?!</b>)', 'i'), '<b>$1</b>');
                        }
                    }
                    var key = new URL(result.link, window.location).pathname.split('/');
                    // This depends if it is either on local setup.
                    // key gives you an array of the url pathname:
                    // ["", "display", "VSNDOC", "Intro+to+Context-Sensitive+Help"]
                    key = key[2];
                    var space = SPACES.hasOwnProperty(key) ? SPACES[key] : { name: '' };
                    resultNode.append('<li><a href="' + result.link + '"><span>' + result.title + '</span> <span>' + result.type + '</span></a></li>');
                }
            }
            _this.activateDropdown(resultNode, _this.selectResult);
        };
        _this.getResults(query, handleSearchResults);
    };
    this.setSearchContextOnLoad = function () {
        var page = location.pathname.split('/')[1];
        for (var space in SPACES) {
            if (SPACES[space].link == page) {
                var input = document.querySelector('#search input[type=text]');
                input.placeholder = 'Search contents for ' + SPACES[space].name;
                //setSearchContext(space)
                // TODO:
                //var selector = $('#filter-product-selection').get()[0]
                //selector.innerHTML = $('.filter-product').find(`li[name=${space}] span`)[0].innerHTML
            }
        }
    };
    this.timeout = 0;
    this.setupInputs();
    this.setSearchContextOnLoad();
};
 
$(document).ready( function() {
    new Dropdown();

})