26 |
27 |
28 |
--------------------------------------------------------------------------------
/test/test-site/css/main.scss:
--------------------------------------------------------------------------------
1 | ---
2 | # Only the main Sass file needs front matter (the dashes are enough)
3 | ---
4 | @charset "utf-8";
5 |
6 |
7 |
8 | // Our variables
9 | $base-font-family: Helvetica, Arial, sans-serif;
10 | $base-font-size: 16px;
11 | $small-font-size: $base-font-size * 0.875;
12 | $base-line-height: 1.5;
13 |
14 | $spacing-unit: 30px;
15 |
16 | $text-color: #111;
17 | $background-color: #fdfdfd;
18 | $brand-color: #2a7ae2;
19 |
20 | $grey-color: #828282;
21 | $grey-color-light: lighten($grey-color, 40%);
22 | $grey-color-dark: darken($grey-color, 25%);
23 |
24 | // Width of the content area
25 | $content-width: 800px;
26 |
27 | $on-palm: 600px;
28 | $on-laptop: 800px;
29 |
30 |
31 |
32 | // Using media queries with like this:
33 | // @include media-query($on-palm) {
34 | // .wrapper {
35 | // padding-right: $spacing-unit / 2;
36 | // padding-left: $spacing-unit / 2;
37 | // }
38 | // }
39 | @mixin media-query($device) {
40 | @media screen and (max-width: $device) {
41 | @content;
42 | }
43 | }
44 |
45 |
46 |
47 | // Import partials from `sass_dir` (defaults to `_sass`)
48 | @import
49 | "base",
50 | "layout",
51 | "syntax-highlighting"
52 | ;
53 |
--------------------------------------------------------------------------------
/test/assets_test.rb:
--------------------------------------------------------------------------------
1 | # @author Mike Bland (michael.bland@gsa.gov)
2 |
3 | require_relative 'test_helper'
4 | require_relative '../lib/jekyll_pages_api_search/assets'
5 | require_relative '../lib/jekyll_pages_api_search/sass'
6 | require_relative '../lib/jekyll_pages_api_search/tags'
7 |
8 | require 'fileutils'
9 | require 'minitest/autorun'
10 | require 'tmpdir'
11 |
12 | module JekyllPagesApiSearch
13 | class AssetsTest < ::Minitest::Test
14 | attr_reader :basedir
15 |
16 | def setup
17 | @basedir = Dir.mktmpdir
18 | end
19 |
20 | def teardown
21 | FileUtils.remove_entry self.basedir
22 | end
23 |
24 | def test_write_assets_to_files
25 | baseURL = '/foo'
26 | scss = File.join self.basedir, 'interface.scss'
27 | html = File.join self.basedir, 'interface.html'
28 | js = File.join self.basedir, 'load-search.js'
29 |
30 | Assets::write_to_files baseURL, scss, html, js
31 | assert(File.exist? scss)
32 | assert_equal File.read(Sass::INTERFACE_FILE), File.read(scss)
33 | assert(File.exist? html)
34 | assert_equal SearchInterfaceTag::CODE, File.read(html)
35 | assert(File.exist? js)
36 | assert_equal LoadSearchTag::generate_script(baseURL), File.read(js)
37 | end
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/lib/jekyll_pages_api_search/search.rb:
--------------------------------------------------------------------------------
1 | # @author Mike Bland (michael.bland@gsa.gov)
2 |
3 | require 'jekyll_pages_api'
4 | require 'json'
5 |
6 | module JekyllPagesApiSearch
7 | class SearchIndexBuilder
8 | DIRNAME = File.dirname(__FILE__).freeze
9 | COMPILE_SCRIPT = File.join(DIRNAME, 'search.js').freeze
10 | INDEX_FILE = 'search-index.json'.freeze
11 |
12 | def self.build_index(site)
13 | corpus_page = find_corpus_page(site.pages)
14 | raise 'Pages API corpus not found' if corpus_page == nil
15 |
16 | search_config = site.config['jekyll_pages_api_search']
17 | index_fields = JSON.generate(search_config['index_fields'] || {})
18 | input = "{\"corpus\": #{corpus_page.output}," \
19 | "\"indexFields\": #{index_fields}}"
20 | compile(input,
21 | JekyllPagesApi::PageWithoutAFile.new(site, site.source, '', INDEX_FILE))
22 | end
23 |
24 | def self.find_corpus_page(pages)
25 | pages.each {|page| return page if page.name == 'pages.json'}
26 | end
27 |
28 | def self.compile(input, index_page)
29 | compiler = open("|node #{COMPILE_SCRIPT}", File::RDWR)
30 | compiler.puts(input)
31 | compiler.close_write
32 | index_page.output = compiler.gets
33 | index_page
34 | end
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/test/test-site/_config.yml:
--------------------------------------------------------------------------------
1 | # Site settings
2 | title: Your title
3 | email: your-email@domain.com
4 | description: > # this means to ignore newlines until "baseurl:"
5 | Write a description for your new site here. You can edit this
6 | line in _config.yml. It will appear in your document head meta (for
7 | Google search results) and in your feed.xml site description.
8 | baseurl: /baseurl # the subpath of your site, e.g. /blog/
9 | url: "http://yourdomain.com" # the base hostname & protocol for your site
10 | twitter_username: jekyllrb
11 | github_username: jekyll
12 |
13 | search_endpoint: lunr-search
14 |
15 | # Build settings
16 | markdown: kramdown
17 |
18 | exclude:
19 | - Gemfile
20 | - Gemfile.lock
21 | - bower_components
22 | - bower.json
23 |
24 | # Configuration for jekyll_pages_api_search plugin gem.
25 | jekyll_pages_api_search:
26 | # Uncomment this to speed up site generation while developing.
27 | #skip_index: true
28 |
29 | # Each member of `index_fields` should correspond to a field generated by
30 | # the jekyll_pages_api. It can hold an optional `boost` member as a signal
31 | # to Lunr.js to weight the field more highly (default is 1).
32 | index_fields:
33 | title:
34 | boost: 10
35 | tags:
36 | boost: 10
37 | url:
38 | boost: 5
39 | body:
40 |
--------------------------------------------------------------------------------
/lib/jekyll_pages_api_search/browserify.js:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env node
2 |
3 | 'use strict';
4 |
5 | var browserify = require('browserify');
6 | var fs = require('fs');
7 | var path = require('path');
8 | var zlib = require('zlib');
9 |
10 | var SOURCE = process.argv[2];
11 | var TARGET = process.argv[3];
12 | var errors = [];
13 |
14 | if (!SOURCE) {
15 | errors.push('source file not defined');
16 | } else if (!fs.existsSync(SOURCE)) {
17 | errors.push('source file ' + SOURCE + ' does not exist');
18 | }
19 |
20 | if (!TARGET) {
21 | errors.push('target file not defined');
22 | } else if (!fs.existsSync(path.dirname(TARGET))) {
23 | errors.push('parent directory of target file ' + TARGET + ' does not exist');
24 | }
25 |
26 | if (errors.length !== 0) {
27 | process.stderr.write(errors.join('\n') + '\n');
28 | process.exit(1);
29 | }
30 |
31 | var bundler = browserify({ standalone: 'renderJekyllPagesApiSearchResults' });
32 | var outputStream = fs.createWriteStream(TARGET);
33 |
34 | bundler.add(SOURCE)
35 | .transform({ global: true }, 'uglifyify')
36 | .bundle()
37 | .pipe(outputStream);
38 |
39 | outputStream.on('close', function() {
40 | var gzip = zlib.createGzip({ level: zlib.BEST_COMPRESSION });
41 | fs.createReadStream(TARGET)
42 | .pipe(gzip)
43 | .pipe(fs.createWriteStream(TARGET + '.gz'));
44 | });
45 |
--------------------------------------------------------------------------------
/test/test-site/_posts/2015-05-18-welcome-to-jekyll.markdown:
--------------------------------------------------------------------------------
1 | ---
2 | layout: post
3 | title: "Welcome to Jekyll!"
4 | date: 2015-05-18 16:29:10
5 | categories: jekyll update
6 | tags:
7 | - Jekyll
8 | - posts
9 | ---
10 | You’ll find this post in your `_posts` directory. Go ahead and edit it and re-build the site to see your changes. You can rebuild the site in many different ways, but the most common way is to run `jekyll serve`, which launches a web server and auto-regenerates your site when a file is updated.
11 |
12 | To add new posts, simply add a file in the `_posts` directory that follows the convention `YYYY-MM-DD-name-of-post.ext` and includes the necessary front matter. Take a look at the source for this post to get an idea about how it works.
13 |
14 | Jekyll also offers powerful support for code snippets:
15 |
16 | {% highlight ruby %}
17 | def print_hi(name)
18 | puts "Hi, #{name}"
19 | end
20 | print_hi('Tom')
21 | #=> prints 'Hi, Tom' to STDOUT.
22 | {% endhighlight %}
23 |
24 | Check out the [Jekyll docs][jekyll] for more info on how to get the most out of Jekyll. File all bugs/feature requests at [Jekyll’s GitHub repo][jekyll-gh]. If you have questions, you can ask them on [Jekyll’s dedicated Help repository][jekyll-help].
25 |
26 | [jekyll]: http://jekyllrb.com
27 | [jekyll-gh]: https://github.com/jekyll/jekyll
28 | [jekyll-help]: https://github.com/jekyll/jekyll-help
29 |
--------------------------------------------------------------------------------
/lib/jekyll_pages_api_search/standalone.rb:
--------------------------------------------------------------------------------
1 | # @author Mike Bland (michael.bland@gsa.gov)
2 |
3 | require_relative './compressor'
4 | require_relative './site'
5 | require 'fileutils'
6 | require 'jekyll_pages_api'
7 |
8 | module JekyllPagesApiSearch
9 | class Standalone
10 | def self.generate_index(basedir, config, pages_json, baseURL,
11 | title_prefix, body_element_tag)
12 | site = Site.new basedir, config
13 |
14 | # Generate pages.json if it doesn't already exist.
15 | if baseURL.nil?
16 | site.load_pages_json pages_json
17 | else
18 | site.pages << ::JekyllPagesApi::Generator.new(
19 | ::JekyllPagesApi::GeneratedSite.new(
20 | baseURL, basedir, title_prefix, body_element_tag)).page
21 | end
22 |
23 | # Build the index; output pages_json if necessary; gzip outputs.
24 | index = SearchIndexBuilder.build_index site
25 | index_outfile = File.join site.source, index.name
26 | output = { index_outfile => index.output.to_s }
27 | output[pages_json] = site.pages.first.output unless File.exist? pages_json
28 | output.each do |outfile, content|
29 | FileUtils.mkdir_p File.dirname(outfile)
30 | File.open(outfile, 'w') {|f| f << content}
31 | end
32 | Compressor::gzip_in_memory_content output
33 | Assets::copy_to_basedir site.source
34 | end
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | As a work of the United States Government, this project is in the
2 | public domain within the United States.
3 |
4 | Additionally, we waive copyright and related rights in the work
5 | worldwide through the CC0 1.0 Universal public domain dedication.
6 |
7 | ## CC0 1.0 Universal Summary
8 |
9 | This is a human-readable summary of the [Legal Code (read the full text)](https://creativecommons.org/publicdomain/zero/1.0/legalcode).
10 |
11 | ### No Copyright
12 |
13 | The person who associated a work with this deed has dedicated the work to
14 | the public domain by waiving all of his or her rights to the work worldwide
15 | under copyright law, including all related and neighboring rights, to the
16 | extent allowed by law.
17 |
18 | You can copy, modify, distribute and perform the work, even for commercial
19 | purposes, all without asking permission.
20 |
21 | ### Other Information
22 |
23 | In no way are the patent or trademark rights of any person affected by CC0,
24 | nor are the rights that other persons may have in the work or in how the
25 | work is used, such as publicity or privacy rights.
26 |
27 | Unless expressly stated otherwise, the person who associated a work with
28 | this deed makes no warranties about the work, and disclaims liability for
29 | all uses of the work, to the fullest extent permitted by applicable law.
30 | When using or citing the work, you should not imply endorsement by the
31 | author or the affirmer.
32 |
--------------------------------------------------------------------------------
/lib/jekyll_pages_api_search/search_page_layouts.rb:
--------------------------------------------------------------------------------
1 | require 'jekyll'
2 | require 'safe_yaml'
3 |
4 | module JekyllPagesApiSearch
5 | # We have to essentially recreate the ::Jekyll::Layout constructor to loosen
6 | # the default restriction that layouts be included in the site source.
7 | #
8 | # Copied from guides_style_18f/layouts.rb. Could probably be extracted into
9 | # another gem.
10 | class SearchPageLayouts < ::Jekyll::Layout
11 | DEFAULT_LAYOUT = 'search-results'
12 | LAYOUTS_DIR = File.join(File.dirname(__FILE__), 'layouts')
13 |
14 | private_class_method :new
15 |
16 | def initialize(site, layout_name)
17 | @site = site
18 | @base = LAYOUTS_DIR
19 | @name = "#{layout_name}.html"
20 | @path = File.join(@base, @name)
21 | parse_content_and_data(File.join(@base, name))
22 | process(name)
23 | end
24 |
25 | def parse_content_and_data(file_path)
26 | @data = {}
27 | @content = File.read(file_path)
28 |
29 | front_matter_pattern = /^(---\n.*)---\n/m
30 | front_matter_match = front_matter_pattern.match(content)
31 | return unless front_matter_match
32 |
33 | @content = front_matter_match.post_match
34 | @data = SafeYAML.load(front_matter_match[1], safe: true) || {}
35 | end
36 | private :parse_content_and_data
37 |
38 | def self.register(site)
39 | site.layouts[DEFAULT_LAYOUT] ||= new(site, DEFAULT_LAYOUT)
40 | end
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/assets/js/search.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser, node */
2 |
3 | 'use strict';
4 |
5 | var SearchEngine = require('./search-engine');
6 | var SearchUi = require('./search-ui');
7 |
8 | function writeResultsToList(query, results, doc, resultsList) {
9 | results.forEach(function(result, index) {
10 | var item = doc.createElement('li'),
11 | link = doc.createElement('a'),
12 | text = doc.createTextNode(result.title);
13 |
14 | link.appendChild(text);
15 | link.title = result.title;
16 | link.href = result.url;
17 | item.appendChild(link);
18 | resultsList.appendChild(item);
19 |
20 | link.tabindex = index;
21 | if (index === 0) {
22 | link.focus();
23 | }
24 | });
25 | }
26 |
27 | module.exports = function() {
28 | var searchUi = new SearchUi(window.document,
29 | window.JEKYLL_PAGES_API_SEARCH_UI_OPTIONS),
30 | searchEngine = new SearchEngine(
31 | window.JEKYLL_PAGES_API_SEARCH_ENGINE_OPTIONS);
32 |
33 | searchUi.enableGlobalShortcut();
34 |
35 | if (!searchUi.resultsElement) {
36 | return;
37 | }
38 |
39 |
40 | return searchEngine.executeSearch(window.JEKYLL_PAGES_API_SEARCH_BASEURL, window.location.href)
41 | .then(function(searchResults) {
42 | searchUi.renderResults(searchResults.query,
43 | searchResults.results || [],
44 | window.renderJekyllPagesApiSearchResults || writeResultsToList);
45 | })
46 | .catch(function(error) {
47 | console.error(error);
48 | });
49 | }();
50 |
--------------------------------------------------------------------------------
/lib/jekyll_pages_api_search/search_hook.rb:
--------------------------------------------------------------------------------
1 | # @author Mike Bland (michael.bland@gsa.gov)
2 |
3 | require_relative './compressor'
4 | require_relative './config'
5 | require_relative './search_page'
6 | require_relative './search_page_layouts'
7 |
8 | require 'jekyll/site'
9 | require 'jekyll_pages_api'
10 | require 'zlib'
11 |
12 | # This Jekyl::Site override creates a hook for generating the search index
13 | # after the jekyll_pages_api plugin has produced the api/v1/pages.json corpus.
14 | # In the very near term, we should probably create a proper hook in the
15 | # jekyll_pages_api plugin itself.
16 | module Jekyll
17 | class Site
18 | alias_method :pages_api_after_render, :after_render
19 | alias_method :orig_write, :write
20 |
21 | def skip_index?
22 | @skip_index ||= JekyllPagesApiSearch::Config.skip_index?(self)
23 | end
24 |
25 | def after_render
26 | pages_api_after_render
27 | return if skip_index?
28 | self.pages << JekyllPagesApiSearch::SearchIndexBuilder.build_index(self)
29 | end
30 |
31 | def write
32 | orig_write
33 | pages_api_search_after_write unless skip_index?
34 | end
35 |
36 | def pages_api_search_after_write
37 | index = pages.find {|p| p.name == 'search-index.json'}
38 | raise 'Search index not found' if index.nil?
39 | JekyllPagesApiSearch::Compressor.gzip_in_memory_content(
40 | "#{index.destination self.dest}" => index.output)
41 | JekyllPagesApiSearch::Browserify.create_bundle(self)
42 | end
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/jekyll_pages_api_search.gemspec:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | lib = File.expand_path('../lib', __FILE__)
3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4 | require 'jekyll_pages_api_search/version'
5 |
6 | Gem::Specification.new do |s|
7 | s.name = 'jekyll_pages_api_search'
8 | s.version = JekyllPagesApiSearch::VERSION
9 | s.authors = ['Mike Bland']
10 | s.email = ['michael.bland@gsa.gov']
11 | s.summary = 'Adds lunr.js search based on the jekyll_pages_api gem'
12 | s.description = (
13 | 'Contains a Jekyll plugin and associated files that facilitate adding ' +
14 | 'client-side search features to a site using the jekyll_pages_api gem.'
15 | )
16 | s.homepage = 'https://github.com/18F/jekyll_pages_api_search'
17 | s.license = 'CC0'
18 |
19 | s.files = `git ls-files -z *.md bin lib assets`.split("\x0") + [
20 | 'assets/js/search-bundle.js',
21 | 'assets/js/search-bundle.js.gz',
22 | 'lib/jekyll_pages_api_search/lunr.min.js',
23 | ]
24 | s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) }
25 |
26 | s.add_runtime_dependency 'jekyll_pages_api', '~> 0.1.4'
27 | s.add_runtime_dependency 'sass', '~> 3.4'
28 | s.add_development_dependency 'rake', '~> 10.0'
29 | s.add_development_dependency 'bundler', '~> 1.7'
30 | s.add_development_dependency 'jekyll'
31 | s.add_development_dependency 'minitest'
32 | s.add_development_dependency 'codeclimate-test-reporter'
33 | s.add_development_dependency 'coveralls'
34 | s.add_development_dependency 'test_temp_file_helper'
35 | end
36 |
--------------------------------------------------------------------------------
/lib/jekyll_pages_api_search/assets.rb:
--------------------------------------------------------------------------------
1 | # @author Mike Bland (michael.bland@gsa.gov)
2 |
3 | require_relative './sass'
4 | require_relative './tags'
5 |
6 | require 'fileutils'
7 | require 'jekyll/static_file'
8 |
9 | module JekyllPagesApiSearch
10 | class Assets
11 | SOURCE = File.realpath(File.join(File.dirname(__FILE__), '..', '..'))
12 | JAVASCRIPT_DIR = File.join('assets', 'js')
13 | BEGIN_PATH = SOURCE.size + File::SEPARATOR.size
14 |
15 | def self.copy_to_site(site)
16 | asset_paths.each do |file_path|
17 | site.static_files << ::Jekyll::StaticFile.new(
18 | site, SOURCE, File.dirname(file_path), File.basename(file_path))
19 | end
20 | end
21 |
22 | def self.copy_to_basedir(basedir)
23 | asset_paths.each do |asset|
24 | target_path = File.join(basedir, asset)
25 | target_dir = File.dirname(target_path)
26 | FileUtils.mkdir_p(target_dir) if !Dir.exist?(target_dir)
27 | FileUtils.cp(File.join(SOURCE, asset), target_path)
28 | end
29 | end
30 |
31 | def self.write_to_files(baseurl, scss, html, js)
32 | [scss, html, js].each {|i| FileUtils.mkdir_p File.dirname(i)}
33 | FileUtils.cp Sass::INTERFACE_FILE, scss
34 | File.open(html, 'w') {|f| f << SearchInterfaceTag::CODE}
35 | File.open(js, 'w') {|f| f << LoadSearchTag::generate_script(baseurl)}
36 | end
37 |
38 | private
39 |
40 | def self.asset_paths
41 | js_pattern = File.join(SOURCE, JAVASCRIPT_DIR, 'search-bundle.js*')
42 | img_pattern = File.join(SOURCE, 'assets', '{png,svg}', '*')
43 | all_paths = Dir.glob(js_pattern) + Dir.glob(img_pattern)
44 | all_paths.map { |path| path[BEGIN_PATH..-1] }
45 | end
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/test/assets_copier_test.rb:
--------------------------------------------------------------------------------
1 | # @author Mike Bland (michael.bland@gsa.gov)
2 |
3 | require_relative 'test_helper'
4 | require_relative '../lib/jekyll_pages_api_search/assets'
5 |
6 | require 'fileutils'
7 | require 'minitest/autorun'
8 | require 'tmpdir'
9 |
10 | module JekyllPagesApiSearch
11 | class DummySite
12 | attr_accessor :static_files
13 |
14 | def initialize
15 | @static_files = []
16 | end
17 | end
18 |
19 | class AssetsCopyToSiteTest < ::Minitest::Test
20 | def test_copy_to_site
21 | site = DummySite.new
22 | Assets::copy_to_site(site)
23 | bundle, bundle_gz = site.static_files
24 | refute_nil bundle
25 | refute_nil bundle_gz
26 |
27 | output_dir = File.join(Assets::SOURCE, Assets::JAVASCRIPT_DIR)
28 | assert_equal File.join(output_dir, 'search-bundle.js'), bundle.path
29 | assert_equal Assets::JAVASCRIPT_DIR, bundle.destination_rel_dir
30 | assert_equal File.join(output_dir, 'search-bundle.js.gz'), bundle_gz.path
31 | assert_equal Assets::JAVASCRIPT_DIR, bundle_gz.destination_rel_dir
32 | end
33 | end
34 |
35 | class AssetsCopyToBasedirTest < ::Minitest::Test
36 | attr_reader :basedir
37 |
38 | def setup
39 | @basedir = Dir.mktmpdir
40 | end
41 |
42 | def teardown
43 | FileUtils.remove_entry self.basedir
44 | end
45 |
46 | def test_copy_to_basedir
47 | Assets::copy_to_basedir(self.basedir)
48 | assets_dir = File.join(self.basedir, Assets::JAVASCRIPT_DIR)
49 | assert(Dir.exist?(assets_dir))
50 | expected = ['search-bundle.js', 'search-bundle.js.gz'].map do |f|
51 | File.join(assets_dir, f)
52 | end
53 | assert_equal(expected, Dir[File.join assets_dir, '*'])
54 | end
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/lib/jekyll_pages_api_search/tags.rb:
--------------------------------------------------------------------------------
1 | # @author Mike Bland (michael.bland@gsa.gov)
2 |
3 | require_relative './config'
4 | require 'liquid'
5 |
6 | module JekyllPagesApiSearch
7 | class SearchInterfaceTag < Liquid::Tag
8 | NAME = 'jekyll_pages_api_search_interface'
9 | Liquid::Template.register_tag(NAME, self)
10 | CODE = File.read(File.join(File.dirname(__FILE__), 'search.html'))
11 | TEMPLATE = Liquid::Template.parse(CODE)
12 |
13 | def render(context)
14 | site = context.registers[:site]
15 | placeholder = Config.get(site, 'placeholder') ||
16 | 'Search - click or press \'/\''
17 | baseurl = site.config['baseurl'] || ''
18 | search_endpoint = site.config['search_endpoint'] || 'search/'
19 | search_endpoint = "/#{baseurl}/#{search_endpoint}/".gsub('//', '/')
20 | TEMPLATE.render('search_endpoint' => search_endpoint,
21 | 'placeholder' => placeholder)
22 | end
23 | end
24 |
25 | class LoadSearchTag < Liquid::Tag
26 | NAME = 'jekyll_pages_api_search_load'
27 | Liquid::Template.register_tag(NAME, self)
28 |
29 | def render(context)
30 | return @code if @code
31 | site = context.registers[:site]
32 | baseurl = site.config['baseurl']
33 | @code = LoadSearchTag.generate_script(baseurl, site: site)
34 | end
35 |
36 | def self.generate_script(baseurl, site: nil)
37 | "\n" +
38 | site_bundle_load_tag(site, baseurl) +
39 | ""
41 | end
42 |
43 | def self.site_bundle_load_tag(site, baseurl)
44 | browserify_config = site.nil? ? nil : Config.get(site, 'browserify')
45 | return '' if browserify_config.nil?
46 | "\n"
47 | end
48 | end
49 |
50 | class SearchResultsTag < Liquid::Tag
51 | NAME = 'jekyll_pages_api_search_results'
52 | Liquid::Template.register_tag(NAME, self)
53 |
54 | def render(context)
55 | ''
56 | end
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/assets/js/search-engine.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser, node */
2 |
3 | 'use strict';
4 |
5 | var lunr = require('lunr');
6 | var querystring = require('querystring');
7 | var url = require('url');
8 |
9 | module.exports = SearchEngine;
10 |
11 | function SearchEngine(options) {
12 | var opts = options || {};
13 |
14 | this.indexPath = opts.indexPath || SearchEngine.DEFAULT_SEARCH_INDEX_PATH;
15 | this.queryParam = opts.queryParam || SearchEngine.DEFAULT_QUERY_PARAM;
16 | }
17 |
18 | SearchEngine.DEFAULT_SEARCH_INDEX_PATH = '/search-index.json';
19 | SearchEngine.DEFAULT_QUERY_PARAM = 'q';
20 |
21 | SearchEngine.prototype.fetchIndex = function(baseUrl) {
22 | var engine = this;
23 |
24 | return new Promise(function(resolve, reject) {
25 | var req = new XMLHttpRequest(),
26 | indexUrl = baseUrl + engine.indexPath;
27 |
28 | req.addEventListener('load', function() {
29 | var rawJson;
30 |
31 | try {
32 | rawJson = JSON.parse(this.responseText);
33 | resolve({
34 | urlToDoc: rawJson.urlToDoc,
35 | index: lunr.Index.load(rawJson.index)
36 | });
37 | } catch (err) {
38 | reject(new Error('failed to parse ' + indexUrl));
39 | }
40 | });
41 | req.open('GET', indexUrl);
42 | req.send();
43 | });
44 | };
45 |
46 | SearchEngine.prototype.parseSearchQuery = function(queryUrl) {
47 | return querystring.parse(url.parse(queryUrl).query)[this.queryParam];
48 | };
49 |
50 | SearchEngine.prototype.getResults = function(query, searchIndex) {
51 | var results = searchIndex.index.search(query);
52 |
53 | results.forEach(function(result) {
54 | var urlAndTitle = searchIndex.urlToDoc[result.ref];
55 |
56 | Object.keys(urlAndTitle).forEach(function(key) {
57 | result[key] = urlAndTitle[key];
58 | });
59 | });
60 | return results;
61 | };
62 |
63 | SearchEngine.prototype.executeSearch = function(baseUrl, queryUrl) {
64 | var searchEngine = this;
65 | return searchEngine.fetchIndex(baseUrl)
66 | .then(function(searchIndex) {
67 | var query = searchEngine.parseSearchQuery(queryUrl),
68 | results = searchEngine.getResults(query, searchIndex);
69 | return Promise.resolve({ query: query, results: results });
70 | });
71 | };
72 |
--------------------------------------------------------------------------------
/assets/js/search-ui.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = SearchUi;
4 |
5 | // eslint-disable-next-line
6 | // based on https://github.com/angular/angular.js/blob/54ddca537/docs/app/src/search.js#L198-L206
7 | function SearchUi(doc, options) {
8 | var opts = options || {};
9 |
10 | this.doc = doc;
11 | this.inputElement = doc.getElementById(
12 | opts.inputElementId || SearchUi.DEFAULT_SEARCH_INPUT_ID);
13 | this.resultsElement = doc.getElementById(
14 | opts.searchResultsId || SearchUi.DEFAULT_SEARCH_RESULTS_ID);
15 | this.emptyResultsMessagePrefix = opts.emptyResultsMessagePrefix ||
16 | SearchUi.DEFAULT_EMPTY_RESULTS_MESSAGE_PREFIX;
17 | this.emptyResultsElementType = opts.emptyResultsElementType ||
18 | SearchUi.DEFAULT_EMPTY_RESULTS_ELEMENT_TYPE;
19 | this.emptyResultsElementClass = opts.emptyResultsElementClass ||
20 | SearchUi.DEFAULT_EMPTY_RESULTS_ELEMENT_CLASS;
21 | }
22 |
23 | SearchUi.DEFAULT_SEARCH_INPUT_ID = 'search-input';
24 | SearchUi.DEFAULT_SEARCH_RESULTS_ID = 'search-results';
25 | SearchUi.DEFAULT_EMPTY_RESULTS_MESSAGE_PREFIX = 'No results found for';
26 | SearchUi.DEFAULT_EMPTY_RESULTS_ELEMENT_TYPE = 'p';
27 | SearchUi.DEFAULT_EMPTY_RESULTS_ELEMENT_CLASS = 'search-empty';
28 |
29 | function isForwardSlash(keyCode) {
30 | return keyCode === 191;
31 | }
32 |
33 | function isInput(element) {
34 | return element.tagName.toLowerCase() === 'input';
35 | }
36 |
37 | SearchUi.prototype.enableGlobalShortcut = function() {
38 | var doc = this.doc,
39 | inputElement = this.inputElement;
40 |
41 | doc.body.onkeydown = function(event) {
42 | if (isForwardSlash(event.keyCode) && !isInput(doc.activeElement)) {
43 | event.stopPropagation();
44 | event.preventDefault();
45 | inputElement.focus();
46 | inputElement.select();
47 | }
48 | };
49 | };
50 |
51 | SearchUi.prototype.renderResults = function(query, results, renderResults) {
52 | if (!query) {
53 | return;
54 | }
55 | this.inputElement.value = query;
56 |
57 | if (results.length === 0) {
58 | this.createEmptyResultsMessage(query);
59 | this.inputElement.focus();
60 | }
61 | renderResults(query, results, this.doc, this.resultsElement);
62 | };
63 |
64 | SearchUi.prototype.createEmptyResultsMessage = function(query) {
65 | var item = this.doc.createElement(this.emptyResultsElementType),
66 | message = this.doc.createTextNode(
67 | this.emptyResultsMessagePrefix + ' "' + query + '".'),
68 | parentItem = this.resultsElement.parentElement;
69 |
70 | item.style.className = this.emptyResultsElementClass;
71 | item.appendChild(message);
72 | parentItem.insertBefore(item, this.resultsElement);
73 | };
74 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require 'bundler/gem_tasks'
2 | require 'json'
3 | require 'rake/testtask'
4 | require 'zlib'
5 |
6 | PACKAGE_INFO = JSON.parse(
7 | File.read(File.join(File.dirname(__FILE__), 'package.json')))
8 |
9 | Rake::TestTask.new do |t|
10 | t.libs << 'test'
11 | t.test_files = FileList['test/*test.rb']
12 | end
13 |
14 | desc "Run tests"
15 | task :default => :test
16 |
17 | def program_exists?(program)
18 | `which #{program}`
19 | $?.success?
20 | end
21 |
22 | def required_programs
23 | result = PACKAGE_INFO['dependencies'].keys
24 | dev_dep = PACKAGE_INFO['devDependencies'] || {}
25 | result.concat(dev_dep.keys.each { |k| "#{k} (development)" })
26 | end
27 |
28 | desc "Check for Node.js and NPM packages"
29 | task :check_for_node do
30 | unless program_exists? 'which'
31 | puts [
32 | "Cannot automatically check for Node.js and NPM packages on this system.",
33 | "If Node.js is not installed, visit https://nodejs.org/.",
34 | "If any of the following packages are not yet installed, please install",
35 | "them by executing `npm install -g PACKAGE`, where `PACKAGE` is one of",
36 | "the names below:"].join("\n")
37 | puts " " + required_programs.join("\n ")
38 | return
39 | end
40 |
41 | unless program_exists? 'node'
42 | abort 'Please install Node.js: https://nodejs.org/'
43 | end
44 | end
45 |
46 | desc "Install JavaScript components"
47 | task :install_js_components => :check_for_node do
48 | abort unless system 'npm', 'install'
49 | end
50 |
51 | desc "Update JavaScript components"
52 | task :update_js_components => :check_for_node do
53 | abort unless system 'npm', 'update'
54 | end
55 |
56 | LIB_LUNR_TARGET = File.join %w(lib jekyll_pages_api_search lunr.min.js)
57 | LIB_LUNR_SOURCE = File.join %w(node_modules lunr lunr.min.js)
58 |
59 | file LIB_LUNR_TARGET => LIB_LUNR_SOURCE do
60 | FileUtils.cp LIB_LUNR_SOURCE, LIB_LUNR_TARGET
61 | end
62 |
63 | main_js = PACKAGE_INFO['main']
64 | search_bundle = File.join 'assets', 'js', 'search-bundle.js'
65 | file search_bundle => main_js do
66 | unless system 'npm', 'run', 'make-bundle'
67 | abort "browserify failed"
68 | end
69 | end
70 |
71 | search_bundle_gz = "#{search_bundle}.gz"
72 | file search_bundle_gz => search_bundle do
73 | ::Zlib::GzipWriter.open(search_bundle_gz, ::Zlib::BEST_COMPRESSION) do |gz|
74 | gz.write(File.read(search_bundle))
75 | end
76 | end
77 |
78 | ARTIFACTS = [LIB_LUNR_TARGET, search_bundle, search_bundle_gz]
79 |
80 | task :build_js => [ :check_for_node ].concat(ARTIFACTS) do
81 | ARTIFACTS.each do |artifact|
82 | unless File.exist?(artifact) && File.stat(artifact).size != 0
83 | abort "#{artifact} missing or empty"
84 | end
85 | end
86 | end
87 |
88 | task :clean do
89 | [search_bundle, search_bundle_gz].each { |f| File.unlink(f) }
90 | end
91 |
92 | task :test => [:build_js]
93 | task :build => [:test]
94 | task :ci_build => [:install_js_components, :test]
95 |
--------------------------------------------------------------------------------
/bin/jekyll_pages_api_search:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env ruby
2 | # Author: Mike Bland
3 | # Date: 2015-06-21
4 |
5 | require_relative '../lib/jekyll_pages_api_search'
6 | require 'fileutils'
7 | require 'jekyll_pages_api'
8 | require 'zlib'
9 |
10 | ASSETS_DIR = ::JekyllPagesApiSearch::JavascriptCopier::ASSETS_DIR
11 |
12 | USAGE=< element that loads the search code bundle
36 | basedir
37 | Path to the generated site's root directory
38 | config.yml
39 | Path to the site's config.yml containing a `jekyll_pages_api_search` entry
40 | pages.json
41 | Path to the output file generated by `jekyll_pages_api`
42 | baseURL
43 | URL prefix of every page of the generated site
44 | title_prefix
45 | Prefix to strip from page titles
46 | body_element_tag
47 | Tag (or tag prefix) identifying the main content element within the
48 | element of each document. Can be a complete tag (ending in '>'), or the
49 | prefix of a longer tag. Used to strip boilerplate out of the content
50 | exported via the API.
51 | END_USAGE
52 |
53 | if ARGV.length == 1 && ARGV[0] == '-h'
54 | puts USAGE
55 | exit
56 | end
57 |
58 | if ARGV.length == 5
59 | if ARGV[0] == '--assets'
60 | baseURL, scss, html, js = ARGV[1..ARGV.size]
61 | ::JekyllPagesApiSearch::Assets::write_to_files baseURL, scss, html, js
62 | exit
63 | else
64 | $stderr.puts "Wrong number of arguments: #{ARGV.length}"
65 | $stderr.puts USAGE
66 | exit 1
67 | end
68 | end
69 |
70 | unless [3, 6].include? ARGV.length
71 | $stderr.puts "Wrong number of arguments: #{ARGV.length}"
72 | $stderr.puts USAGE
73 | exit 1
74 | end
75 |
76 | basedir, config, pages_json, baseURL, title_prefix, body_element_tag = ARGV
77 |
78 | unless File.exist? basedir
79 | $stderr.puts "#{basedir} does not exist"
80 | exit 1
81 | end
82 |
83 | if ARGV.length == 3 && !File.exist?(pages_json)
84 | $stderr.puts "#{pages_json} does not exist"
85 | exit 1
86 | end
87 |
88 | ::JekyllPagesApiSearch::Standalone.generate_index(basedir, config, pages_json,
89 | baseURL, title_prefix, body_element_tag)
90 |
--------------------------------------------------------------------------------
/test/test-site/_includes/footer.html:
--------------------------------------------------------------------------------
1 |
56 |
--------------------------------------------------------------------------------
/test/search_test.rb:
--------------------------------------------------------------------------------
1 | # @author Mike Bland (michael.bland@gsa.gov)
2 |
3 | require_relative 'test_helper'
4 | require_relative 'site_builder'
5 | require_relative '../lib/jekyll_pages_api_search/tags'
6 |
7 | require 'json'
8 | require 'liquid'
9 | require 'minitest/autorun'
10 |
11 | module JekyllPagesApiSearch
12 | class DummyJekyllSite
13 | attr_accessor :config
14 | def initialize
15 | @config = {
16 | 'baseurl' => '/baseurl',
17 | 'search_endpoint' => 'lunr-search',
18 | }
19 | end
20 | end
21 |
22 | class DummyLiquidContext
23 | attr_accessor :registers
24 | def initialize
25 | @registers = {:site => DummyJekyllSite.new}
26 | end
27 | end
28 |
29 | class SearchTest < ::Minitest::Test
30 | def setup
31 | @index_page_path = File.join(SiteBuilder::BUILD_DIR, 'index.html')
32 | assert(File.exist?(@index_page_path), "index.html does not exist")
33 | @context = DummyLiquidContext.new
34 | end
35 |
36 | def test_index_built
37 | index_file = File.join(SiteBuilder::BUILD_DIR,
38 | SearchIndexBuilder::INDEX_FILE)
39 | assert(File.exist?(index_file), "Serialized search index doesn't exist")
40 |
41 | File.open(index_file, 'r') do |f|
42 | search_index = JSON.parse f.read, :max_nesting => 200
43 | refute_empty search_index
44 |
45 | index = search_index['index']
46 | refute_empty index
47 | refute_nil index['corpusTokens']
48 | refute_nil index['documentStore']
49 | refute_nil index['fields']
50 | refute_nil index['pipeline']
51 | refute_nil index['ref']
52 | refute_nil index['tokenStore']
53 | refute_nil index['version']
54 |
55 | url_to_doc = search_index['urlToDoc']
56 | refute_empty url_to_doc
57 | url_to_doc.each do |k,v|
58 | refute_nil v['url']
59 | refute_nil v['title']
60 | assert_equal k, v['url']
61 | end
62 | end
63 | end
64 |
65 | def test_skip_index_page_not_included
66 | index_file = File.join(SiteBuilder::BUILD_DIR,
67 | SearchIndexBuilder::INDEX_FILE)
68 |
69 | File.open(index_file, 'r') do |f|
70 | search_index = JSON.parse f.read, :max_nesting => 200
71 | assert_nil search_index['urlToDoc']['/about/']
72 | end
73 | end
74 |
75 | def get_tag(name)
76 | Liquid::Template.tags[name].parse(nil, nil, nil, {})
77 | end
78 |
79 | def test_interface_style_present
80 | css_path = File.join(SiteBuilder::BUILD_DIR, 'css', 'main.css')
81 | assert(File.exist?(css_path), "css/main.css does not exist")
82 | File.open(css_path, 'r') do |f|
83 | assert_includes(f.read, '.search-interface',
84 | 'generated files do not contain interface style code')
85 | end
86 | end
87 |
88 | def test_interface_tag_replaced
89 | tag = get_tag SearchInterfaceTag::NAME
90 | File.open(@index_page_path, 'r') do |f|
91 | assert_includes(f.read, tag.render(@context),
92 | 'generated files do not contain interface code')
93 | end
94 | end
95 |
96 | def test_load_tag_replaced
97 | tag = get_tag LoadSearchTag::NAME
98 | File.open(@index_page_path, 'r') do |f|
99 | assert_includes(f.read, tag.render(@context),
100 | 'generated files do not contain script loading code')
101 | end
102 | end
103 | end
104 | end
105 |
--------------------------------------------------------------------------------
/test/test-site/_sass/_syntax-highlighting.scss:
--------------------------------------------------------------------------------
1 | /**
2 | * Syntax highlighting styles
3 | */
4 | .highlight {
5 | background: #fff;
6 | @extend %vertical-rhythm;
7 |
8 | .c { color: #998; font-style: italic } // Comment
9 | .err { color: #a61717; background-color: #e3d2d2 } // Error
10 | .k { font-weight: bold } // Keyword
11 | .o { font-weight: bold } // Operator
12 | .cm { color: #998; font-style: italic } // Comment.Multiline
13 | .cp { color: #999; font-weight: bold } // Comment.Preproc
14 | .c1 { color: #998; font-style: italic } // Comment.Single
15 | .cs { color: #999; font-weight: bold; font-style: italic } // Comment.Special
16 | .gd { color: #000; background-color: #fdd } // Generic.Deleted
17 | .gd .x { color: #000; background-color: #faa } // Generic.Deleted.Specific
18 | .ge { font-style: italic } // Generic.Emph
19 | .gr { color: #a00 } // Generic.Error
20 | .gh { color: #999 } // Generic.Heading
21 | .gi { color: #000; background-color: #dfd } // Generic.Inserted
22 | .gi .x { color: #000; background-color: #afa } // Generic.Inserted.Specific
23 | .go { color: #888 } // Generic.Output
24 | .gp { color: #555 } // Generic.Prompt
25 | .gs { font-weight: bold } // Generic.Strong
26 | .gu { color: #aaa } // Generic.Subheading
27 | .gt { color: #a00 } // Generic.Traceback
28 | .kc { font-weight: bold } // Keyword.Constant
29 | .kd { font-weight: bold } // Keyword.Declaration
30 | .kp { font-weight: bold } // Keyword.Pseudo
31 | .kr { font-weight: bold } // Keyword.Reserved
32 | .kt { color: #458; font-weight: bold } // Keyword.Type
33 | .m { color: #099 } // Literal.Number
34 | .s { color: #d14 } // Literal.String
35 | .na { color: #008080 } // Name.Attribute
36 | .nb { color: #0086B3 } // Name.Builtin
37 | .nc { color: #458; font-weight: bold } // Name.Class
38 | .no { color: #008080 } // Name.Constant
39 | .ni { color: #800080 } // Name.Entity
40 | .ne { color: #900; font-weight: bold } // Name.Exception
41 | .nf { color: #900; font-weight: bold } // Name.Function
42 | .nn { color: #555 } // Name.Namespace
43 | .nt { color: #000080 } // Name.Tag
44 | .nv { color: #008080 } // Name.Variable
45 | .ow { font-weight: bold } // Operator.Word
46 | .w { color: #bbb } // Text.Whitespace
47 | .mf { color: #099 } // Literal.Number.Float
48 | .mh { color: #099 } // Literal.Number.Hex
49 | .mi { color: #099 } // Literal.Number.Integer
50 | .mo { color: #099 } // Literal.Number.Oct
51 | .sb { color: #d14 } // Literal.String.Backtick
52 | .sc { color: #d14 } // Literal.String.Char
53 | .sd { color: #d14 } // Literal.String.Doc
54 | .s2 { color: #d14 } // Literal.String.Double
55 | .se { color: #d14 } // Literal.String.Escape
56 | .sh { color: #d14 } // Literal.String.Heredoc
57 | .si { color: #d14 } // Literal.String.Interpol
58 | .sx { color: #d14 } // Literal.String.Other
59 | .sr { color: #009926 } // Literal.String.Regex
60 | .s1 { color: #d14 } // Literal.String.Single
61 | .ss { color: #990073 } // Literal.String.Symbol
62 | .bp { color: #999 } // Name.Builtin.Pseudo
63 | .vc { color: #008080 } // Name.Variable.Class
64 | .vg { color: #008080 } // Name.Variable.Global
65 | .vi { color: #008080 } // Name.Variable.Instance
66 | .il { color: #099 } // Literal.Number.Integer.Long
67 | }
68 |
--------------------------------------------------------------------------------
/test/test-site/_sass/_base.scss:
--------------------------------------------------------------------------------
1 | @import "jekyll_pages_api_search";
2 |
3 | /**
4 | * Reset some basic elements
5 | */
6 | body, h1, h2, h3, h4, h5, h6,
7 | p, blockquote, pre, hr,
8 | dl, dd, ol, ul, figure {
9 | margin: 0;
10 | padding: 0;
11 | }
12 |
13 |
14 |
15 | /**
16 | * Basic styling
17 | */
18 | body {
19 | font-family: $base-font-family;
20 | font-size: $base-font-size;
21 | line-height: $base-line-height;
22 | font-weight: 300;
23 | color: $text-color;
24 | background-color: $background-color;
25 | -webkit-text-size-adjust: 100%;
26 | }
27 |
28 |
29 |
30 | /**
31 | * Set `margin-bottom` to maintain vertical rhythm
32 | */
33 | h1, h2, h3, h4, h5, h6,
34 | p, blockquote, pre,
35 | ul, ol, dl, figure,
36 | %vertical-rhythm {
37 | margin-bottom: $spacing-unit / 2;
38 | }
39 |
40 |
41 |
42 | /**
43 | * Images
44 | */
45 | img {
46 | max-width: 100%;
47 | vertical-align: middle;
48 | }
49 |
50 |
51 |
52 | /**
53 | * Figures
54 | */
55 | figure > img {
56 | display: block;
57 | }
58 |
59 | figcaption {
60 | font-size: $small-font-size;
61 | }
62 |
63 |
64 |
65 | /**
66 | * Lists
67 | */
68 | ul, ol {
69 | margin-left: $spacing-unit;
70 | }
71 |
72 | li {
73 | > ul,
74 | > ol {
75 | margin-bottom: 0;
76 | }
77 | }
78 |
79 |
80 |
81 | /**
82 | * Headings
83 | */
84 | h1, h2, h3, h4, h5, h6 {
85 | font-weight: 300;
86 | }
87 |
88 |
89 |
90 | /**
91 | * Links
92 | */
93 | a {
94 | color: $brand-color;
95 | text-decoration: none;
96 |
97 | &:visited {
98 | color: darken($brand-color, 15%);
99 | }
100 |
101 | &:hover {
102 | color: $text-color;
103 | text-decoration: underline;
104 | }
105 | }
106 |
107 |
108 |
109 | /**
110 | * Blockquotes
111 | */
112 | blockquote {
113 | color: $grey-color;
114 | border-left: 4px solid $grey-color-light;
115 | padding-left: $spacing-unit / 2;
116 | font-size: 18px;
117 | letter-spacing: -1px;
118 | font-style: italic;
119 |
120 | > :last-child {
121 | margin-bottom: 0;
122 | }
123 | }
124 |
125 |
126 |
127 | /**
128 | * Code formatting
129 | */
130 | pre,
131 | code {
132 | font-size: 15px;
133 | border: 1px solid $grey-color-light;
134 | border-radius: 3px;
135 | background-color: #eef;
136 | }
137 |
138 | code {
139 | padding: 1px 5px;
140 | }
141 |
142 | pre {
143 | padding: 8px 12px;
144 | overflow-x: scroll;
145 |
146 | > code {
147 | border: 0;
148 | padding-right: 0;
149 | padding-left: 0;
150 | }
151 | }
152 |
153 |
154 |
155 | /**
156 | * Wrapper
157 | */
158 | .wrapper {
159 | max-width: -webkit-calc(#{$content-width} - (#{$spacing-unit} * 2));
160 | max-width: calc(#{$content-width} - (#{$spacing-unit} * 2));
161 | margin-right: auto;
162 | margin-left: auto;
163 | padding-right: $spacing-unit;
164 | padding-left: $spacing-unit;
165 | @extend %clearfix;
166 |
167 | @include media-query($on-laptop) {
168 | max-width: -webkit-calc(#{$content-width} - (#{$spacing-unit}));
169 | max-width: calc(#{$content-width} - (#{$spacing-unit}));
170 | padding-right: $spacing-unit / 2;
171 | padding-left: $spacing-unit / 2;
172 | }
173 | }
174 |
175 |
176 |
177 | /**
178 | * Clearfix
179 | */
180 | %clearfix {
181 |
182 | &:after {
183 | content: "";
184 | display: table;
185 | clear: both;
186 | }
187 | }
188 |
189 |
190 |
191 | /**
192 | * Icons
193 | */
194 | .icon {
195 |
196 | > svg {
197 | display: inline-block;
198 | width: 16px;
199 | height: 16px;
200 | vertical-align: middle;
201 |
202 | path {
203 | fill: $grey-color;
204 | }
205 | }
206 | }
207 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | ## Welcome!
2 |
3 | We're so glad you're thinking about contributing to an 18F open source project!
4 | If you're unsure or afraid of anything, just ask or submit the issue or pull
5 | request anyways. The worst that can happen is that you'll be politely asked to
6 | change something. We appreciate any sort of contribution, and don't want a wall
7 | of rules to get in the way of that.
8 |
9 | Before contributing, we encourage you to read our CONTRIBUTING policy (you are
10 | here), our LICENSE, and our README, all of which should be in this repository.
11 | If you have any questions, or want to read more about our underlying policies,
12 | you can consult the 18F Open Source Policy GitHub repository at
13 | https://github.com/18f/open-source-policy, or just shoot us an email/official
14 | government letterhead note to [18f@gsa.gov](mailto:18f@gsa.gov).
15 |
16 | ## Public domain
17 |
18 | This project is in the public domain within the United States, and
19 | copyright and related rights in the work worldwide are waived through
20 | the [CC0 1.0 Universal public domain dedication](https://creativecommons.org/publicdomain/zero/1.0/).
21 |
22 | All contributions to this project will be released under the CC0
23 | dedication. By submitting a pull request, you are agreeing to comply
24 | with this waiver of copyright interest.
25 |
26 | ## Starting work on an issue
27 |
28 | Issues that are marked with the `ready` label are ripe for the picking! Simply
29 | assign yourself to the issue to and change its label to `in progress` to
30 | indicate that you are working on it.
31 |
32 | If the issue involves writing code or producing some other change that will
33 | result in a pull request, begin by creating yourself a branch with a short
34 | descriptive name of the work that includes the issue number at the end, e.g.,
35 | `document-pr-process-#36`.
36 |
37 | **Note:** If you are not a part of the 18F Team, please fork the repository
38 | first and then create a branch for yourself with the same convention.
39 |
40 | Once your local branch is created, simply push it remotely and this will
41 | assign the issue to you and move it to be `in progress` automatically.
42 |
43 | ## Submitting a pull request and completing work
44 |
45 | When you are satisfied with your work and ready to submit it to be completed,
46 | please submit a pull request for review. If you haven't already, please
47 | follow the instructions above and create a branch for yourself first. Prior
48 | to submitting the pull request, please make note of the following:
49 |
50 | 1. Code changes should be accompanied by tests.
51 | 2. Please run the tests (`$ npm test`) and the linter
52 | (`$ npm run-script lint`) to make sure there are no regressions.
53 |
54 | Once everything is ready to go, [submit your pull request](https://help.github.com/articles/using-pull-requests/)!
55 | When creating a pull request please be sure to reference the issue number it
56 | is associated with, preferably in the title.
57 |
58 | If you are working in a branch off of the `18F/jekyll_pages_api_search` repo
59 | directly, you can reference the issue like this:
60 | `Closes #45: Short sentence describing the pull request`
61 |
62 | If you are working in a forked copy of the repo, please reference the issue
63 | like this:
64 | `Closes 18F/jekyll_pages_api_search#45: Short sentence describing the pull
65 | request`
66 |
67 | In both cases, please include a descriptive summary of the change in the body
68 | of the pull request as that will help greatly in reviewing the change and
69 | understanding what should be taking place inside of it.
70 |
71 | By referencing the issue in the pull request as noted above, this will
72 | automatically update the issue with a `needs review` label and notify the
73 | collaborators on the project that something is ready for a review. One of us
74 | will take a look as soon as we can and initiate the review process, provide
75 | feedback as necessary, and ultimately merge the change.
76 |
77 | Once the code is merged, the branch will be deleted and the `in review`
78 | label will be removed. The issue will be automatically updated again to be
79 | marked as Done and Closed.
80 |
81 | ## Performing a review of a pull request
82 |
83 | If you are performing a review of a pull request please add the `in review`
84 | label to the pull request and be sure keep the `needs review` label
85 | associated with it. This will help keep our Waffle board up-to-date and
86 | reflect that the pull request is being actively reviewed. Also, please
87 | assign yourself so others know who the primary reviewer is.
88 |
89 |
--------------------------------------------------------------------------------
/test/standalone_test.rb:
--------------------------------------------------------------------------------
1 | # @author Mike Bland (michael.bland@gsa.gov)
2 |
3 | require_relative 'test_helper'
4 | require_relative 'site_builder'
5 | require_relative '../lib/jekyll_pages_api_search'
6 |
7 | require 'digest'
8 | require 'fileutils'
9 | require 'jekyll_pages_api/generator'
10 | require 'minitest/autorun'
11 | require 'tmpdir'
12 |
13 | module JekyllPagesApiSearch
14 | class DummySite
15 | def each_site_file
16 | end
17 | end
18 |
19 | class StandaloneTest < ::Minitest::Test
20 | attr_reader :basedir, :config, :pages_json_rel_path
21 | attr_reader :generated_pages_json, :search_bundle_path, :search_index_path
22 | attr_reader :orig_pages_json, :orig_search_bundle, :orig_search_index
23 |
24 | def setup
25 | @basedir = File.join Dir.mktmpdir
26 | FileUtils.cp_r SiteBuilder::BUILD_DIR, @basedir
27 | @config = File.join SiteBuilder::SOURCE_DIR, '_config.yml'
28 |
29 | # Just need this to grab the canonical JekyllPagesApi output path.
30 | generator = ::JekyllPagesApi::Generator.new DummySite.new
31 | page = generator.page
32 | @pages_json_rel_path = page.path
33 | @generated_pages_json = File.join @basedir, @pages_json_rel_path
34 | @search_bundle_path = File.join(@basedir, Assets::JAVASCRIPT_DIR,
35 | 'search-bundle.js')
36 | @search_index_path = File.join(@basedir, 'search-index.json')
37 |
38 | StandaloneTest.remove_files @search_bundle_path, @search_index_path
39 |
40 | @orig_pages_json = File.join SiteBuilder::BUILD_DIR, page.path
41 | @orig_search_bundle = File.join(SiteBuilder::BUILD_DIR,
42 | 'search-bundle.js')
43 | @orig_search_index= File.join SiteBuilder::BUILD_DIR, 'search-index.json'
44 | end
45 |
46 | def self.remove_files(*files_to_remove)
47 | files_to_remove.each do |f|
48 | FileUtils.remove_file File.join(@basedir, f) if File.exist? f
49 | gz = "#{f}.gz"
50 | FileUtils.remove_file File.join(@basedir, gz) if File.exist? gz
51 | end
52 | end
53 |
54 | def teardown
55 | FileUtils.remove_entry self.basedir
56 | end
57 |
58 | def assert_file_exists_and_matches_original(generated_file, orig_file)
59 | assert File.exist?(generated_file), "#{generated_file} not generated"
60 | assert_equal(::Digest::SHA256.file(generated_file),
61 | ::Digest::SHA256.file(generated_file),
62 | "content of generated file #{generated_file}\n" +
63 | "differs from original file #{orig_file}")
64 | end
65 |
66 | def test_create_index_and_pages_json
67 | StandaloneTest.remove_files self.pages_json_rel_path
68 | baseURL = ""
69 | title_prefix = ""
70 | body_element_tag = '
'
71 | Standalone::generate_index(self.basedir, self.config,
72 | self.generated_pages_json, baseURL, title_prefix, body_element_tag)
73 |
74 | assert_file_exists_and_matches_original(self.search_bundle_path,
75 | self.orig_search_bundle)
76 | assert_file_exists_and_matches_original("#{self.search_bundle_path}.gz",
77 | "#{self.orig_search_bundle}.gz")
78 | assert_file_exists_and_matches_original(self.search_index_path,
79 | self.orig_search_index)
80 | assert_file_exists_and_matches_original("#{self.search_index_path}.gz",
81 | "#{self.orig_search_index}.gz")
82 | assert_file_exists_and_matches_original(self.generated_pages_json,
83 | self.orig_pages_json)
84 | assert_file_exists_and_matches_original("#{self.generated_pages_json}.gz",
85 | "#{self.orig_pages_json}")
86 | end
87 |
88 | def test_create_index_using_existing_pages_json
89 | baseURL = nil
90 | title_prefix = nil
91 | body_element_tag = nil
92 | Standalone::generate_index(self.basedir, self.config,
93 | self.orig_pages_json, baseURL, title_prefix, body_element_tag)
94 |
95 | assert_file_exists_and_matches_original(self.search_bundle_path,
96 | self.orig_search_bundle)
97 | assert_file_exists_and_matches_original("#{self.search_bundle_path}.gz",
98 | "#{self.orig_search_bundle}.gz")
99 | assert_file_exists_and_matches_original(self.search_index_path,
100 | self.orig_search_index)
101 | assert_file_exists_and_matches_original("#{self.search_index_path}.gz",
102 | "#{self.orig_search_index}.gz")
103 | refute(File.exist?(self.generated_pages_json),
104 | "#{self.pages_json_rel_path} generated when it shouldn't've been")
105 | refute(File.exist?("#{self.generated_pages_json}.gz"),
106 | "#{self.pages_json_rel_path}.gz generated when it shouldn't've been")
107 | end
108 | end
109 | end
110 |
--------------------------------------------------------------------------------
/test/test-site/_sass/_layout.scss:
--------------------------------------------------------------------------------
1 | /**
2 | * Site header
3 | */
4 | .site-header {
5 | border-top: 5px solid $grey-color-dark;
6 | border-bottom: 1px solid $grey-color-light;
7 | min-height: 56px;
8 |
9 | // Positioning context for the mobile navigation icon
10 | position: relative;
11 | }
12 |
13 | .site-title {
14 | font-size: 26px;
15 | line-height: 56px;
16 | letter-spacing: -1px;
17 | margin-bottom: 0;
18 | float: left;
19 |
20 | &,
21 | &:visited {
22 | color: $grey-color-dark;
23 | }
24 | }
25 |
26 | .site-nav {
27 | float: right;
28 | line-height: 56px;
29 |
30 | .menu-icon {
31 | display: none;
32 | }
33 |
34 | .page-link {
35 | color: $text-color;
36 | line-height: $base-line-height;
37 |
38 | // Gaps between nav items, but not on the first one
39 | &:not(:first-child) {
40 | margin-left: 20px;
41 | }
42 | }
43 |
44 | @include media-query($on-palm) {
45 | position: absolute;
46 | top: 9px;
47 | right: 30px;
48 | background-color: $background-color;
49 | border: 1px solid $grey-color-light;
50 | border-radius: 5px;
51 | text-align: right;
52 |
53 | .menu-icon {
54 | display: block;
55 | float: right;
56 | width: 36px;
57 | height: 26px;
58 | line-height: 0;
59 | padding-top: 10px;
60 | text-align: center;
61 |
62 | > svg {
63 | width: 18px;
64 | height: 15px;
65 |
66 | path {
67 | fill: $grey-color-dark;
68 | }
69 | }
70 | }
71 |
72 | .trigger {
73 | clear: both;
74 | display: none;
75 | }
76 |
77 | &:hover .trigger {
78 | display: block;
79 | padding-bottom: 5px;
80 | }
81 |
82 | .page-link {
83 | display: block;
84 | padding: 5px 10px;
85 | }
86 | }
87 | }
88 |
89 |
90 |
91 | /**
92 | * Site footer
93 | */
94 | .site-footer {
95 | border-top: 1px solid $grey-color-light;
96 | padding: $spacing-unit 0;
97 | }
98 |
99 | .footer-heading {
100 | font-size: 18px;
101 | margin-bottom: $spacing-unit / 2;
102 | }
103 |
104 | .contact-list,
105 | .social-media-list {
106 | list-style: none;
107 | margin-left: 0;
108 | }
109 |
110 | .footer-col-wrapper {
111 | font-size: 15px;
112 | color: $grey-color;
113 | margin-left: -$spacing-unit / 2;
114 | @extend %clearfix;
115 | }
116 |
117 | .footer-col {
118 | float: left;
119 | margin-bottom: $spacing-unit / 2;
120 | padding-left: $spacing-unit / 2;
121 | }
122 |
123 | .footer-col-1 {
124 | width: -webkit-calc(35% - (#{$spacing-unit} / 2));
125 | width: calc(35% - (#{$spacing-unit} / 2));
126 | }
127 |
128 | .footer-col-2 {
129 | width: -webkit-calc(20% - (#{$spacing-unit} / 2));
130 | width: calc(20% - (#{$spacing-unit} / 2));
131 | }
132 |
133 | .footer-col-3 {
134 | width: -webkit-calc(45% - (#{$spacing-unit} / 2));
135 | width: calc(45% - (#{$spacing-unit} / 2));
136 | }
137 |
138 | @include media-query($on-laptop) {
139 | .footer-col-1,
140 | .footer-col-2 {
141 | width: -webkit-calc(50% - (#{$spacing-unit} / 2));
142 | width: calc(50% - (#{$spacing-unit} / 2));
143 | }
144 |
145 | .footer-col-3 {
146 | width: -webkit-calc(100% - (#{$spacing-unit} / 2));
147 | width: calc(100% - (#{$spacing-unit} / 2));
148 | }
149 | }
150 |
151 | @include media-query($on-palm) {
152 | .footer-col {
153 | float: none;
154 | width: -webkit-calc(100% - (#{$spacing-unit} / 2));
155 | width: calc(100% - (#{$spacing-unit} / 2));
156 | }
157 | }
158 |
159 |
160 |
161 | /**
162 | * Page content
163 | */
164 | .page-content {
165 | padding: $spacing-unit 0;
166 | }
167 |
168 | .page-heading {
169 | font-size: 20px;
170 | }
171 |
172 | .post-list {
173 | margin-left: 0;
174 | list-style: none;
175 |
176 | > li {
177 | margin-bottom: $spacing-unit;
178 | }
179 | }
180 |
181 | .post-meta {
182 | font-size: $small-font-size;
183 | color: $grey-color;
184 | }
185 |
186 | .post-link {
187 | display: block;
188 | font-size: 24px;
189 | }
190 |
191 |
192 |
193 | /**
194 | * Posts
195 | */
196 | .post-header {
197 | margin-bottom: $spacing-unit;
198 | }
199 |
200 | .post-title {
201 | font-size: 42px;
202 | letter-spacing: -1px;
203 | line-height: 1;
204 |
205 | @include media-query($on-laptop) {
206 | font-size: 36px;
207 | }
208 | }
209 |
210 | .post-content {
211 | margin-bottom: $spacing-unit;
212 |
213 | h2 {
214 | font-size: 32px;
215 |
216 | @include media-query($on-laptop) {
217 | font-size: 28px;
218 | }
219 | }
220 |
221 | h3 {
222 | font-size: 26px;
223 |
224 | @include media-query($on-laptop) {
225 | font-size: 22px;
226 | }
227 | }
228 |
229 | h4 {
230 | font-size: 20px;
231 |
232 | @include media-query($on-laptop) {
233 | font-size: 18px;
234 | }
235 | }
236 | }
237 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # DEPRECATED
2 |
3 | This project is no longer maintained. For Federal sites, we recommend [search.gov](https://search.gov/).
4 |
5 | ---
6 |
7 | ## jekyll_pages_api_search Plugin
8 |
9 | [](https://travis-ci.org/18F/jekyll_pages_api_search)
10 |
11 | The [`jekyll_pages_api_search` Ruby
12 | gem](https://rubygems.org/gems/jekyll_pages_api_search) adds a
13 | [lunr.js](http://lunrjs.com) search index to a
14 | [Jekyll](http://jekyllrb.com/)-based web site.
15 |
16 | The search index is generated and compressed automatically via `jekyll build`
17 | or `jekyll serve`. The supporting JavaScript code is optimized, compressed,
18 | and loads asynchronously. These features ensure that the preparation of the
19 | search index does not introduce rendering latency in the browser.
20 |
21 |
22 |
23 | Example of querying the search index and selecting from the search
24 | results page. The `/` key moves focus to the search query box, and the first
25 | result receives the tab index, making mouse-based navigation unnecessary.
26 |
27 |
28 |
29 | ## How it works
30 |
31 | On the server building the site, the plugin takes the corpus produced by the
32 | [`jekyll_pages_api` gem](https://github.com/18F/jekyll_pages_api/) and feeds
33 | it to a [Node.js](https://nodejs.org/) script that compiles it into a lunr.js
34 | index, serialized as JSON. The plugin adds this output file to the Jekyll site
35 | output. It generates a compressed copy as well, enabling web servers to take
36 | advantage of sending the compressed output directly, e.g. using the [Nginx
37 | `gzip_static on` directive](http://nginx.org/en/docs/http/ngx_http_gzip_static_module.html).
38 |
39 | The gem also generates a (configurable, customizable) search results page that
40 | will serve the search results.
41 |
42 | On the client, the search interface box submits a query form that fetches the
43 | search results page. The code on the search results page then fetches the
44 | search index from the server (which will be cached by the browser until the
45 | next site build). After loading the index, the page issues the query and
46 | inserts the results into its search results interface element.
47 |
48 | All of the client-side components are bundled together with
49 | `assets/js/search.js` into `assets/js/search-bundle.js` using
50 | [Browserify](http://browserify.org/).
51 |
52 | ## Installation
53 |
54 | 1. Install [Node.js](https://nodejs.org/) on your system. This plugin requires
55 | version 4.2 or greater or version 5 or greater. You may wish to first install a
56 | version manager such as [nvm](https://github.com/creationix/nvm) to manage and install different Node.js versions.
57 |
58 |
59 | 1. Add this line to your Jekyll project's `Gemfile`:
60 | ```ruby
61 | group :jekyll_plugins do
62 | gem 'jekyll_pages_api_search'
63 | end
64 | ```
65 |
66 | 1. Add the following to the project's `_config.yml` file:
67 |
68 | ```yaml
69 | # Configuration for jekyll_pages_api_search plugin gem.
70 | jekyll_pages_api_search:
71 | # Uncomment this to speed up site generation while developing.
72 | #skip_index: true
73 |
74 | # Each member of `index_fields` should correspond to a field generated by
75 | # the jekyll_pages_api. It can hold an optional `boost` member as a signal
76 | # to Lunr.js to weight the field more highly (default is 1).
77 | index_fields:
78 | title:
79 | boost: 10
80 | tags:
81 | boost: 10
82 | url:
83 | boost: 5
84 | body:
85 |
86 | # If defined and browserify and uglifyify are installed, the plugin will
87 | # generate a bundle to define the renderJekyllPagesApiSearchResults
88 | # function.
89 | browserify:
90 | source: js/my-search.js
91 | target: js/my-search-bundle.js
92 | ```
93 |
94 | 1. Run `jekyll build` or `jekyll serve` to produce `search-index.json` and
95 | `search-index.json.gz` files in the `_site` directory (or other output
96 | directory, as configured).
97 |
98 | If `browserify:` is defined, it will also produce the `target:` bundle file
99 | and its gzipped version. See the [browserify section](#using-browserify)
100 | for more details.
101 |
102 | 1. If you're running [Nginx](http://nginx.org), you may want to use the
103 | [`gzip_static on`
104 | directive](http://nginx.org/en/docs/http/ngx_http_gzip_static_module.html)
105 | to take advantage of the gzipped versions of the search index and supporting
106 | JavaScript.
107 |
108 | ## Usage
109 |
110 | To add the index to your pages, insert the following tags in your `_layouts`
111 | and `_includes` files as you see fit:
112 |
113 | - `{% jekyll_pages_api_search_interface %}`: inserts the HTML for the search
114 | box and search results
115 | - `{% jekyll_pages_api_search_load %}`: inserts the `
179 | ```
180 |
181 | To override the default search results rendering function, define a function
182 | called `renderJekyllPagesApiSearchResults` that conforms to the following
183 | interface. This is the default implementation, which creates new `
`
184 | elements containing a link for each search result.
185 |
186 | ```html
187 |
215 | ```
216 |
217 | ### Using browserify
218 |
219 | The most modular means of defining `renderJekyllPagesApiSearchResults` may be
220 | to create a Node.js implementation file and generate a browser-compatible
221 | version using [browserify](http://browserify.org/). First create the
222 | implementation as described above. Then perform the following steps:
223 |
224 | ```shell
225 | # Create a package.json file
226 | $ npm init
227 |
228 | # Install browserify and uglifyify, a JavaScript minimizer
229 | $ npm install browserify uglifyify --save-dev
230 | ```
231 |
232 | Add the `browserify:` configuration as defined in the [installation
233 | instructions](#installation) above, replacing `js/my-search.js` with the path
234 | to your `renderJekyllPagesApiSearchResults` implementation script and
235 | `js/my-search-bundle.js` with the path to your generated bundle.
236 |
237 | ### Examples from apps.gov
238 |
239 | [apps.gov's default
240 | layout](https://github.com/presidential-innovation-fellows/apps-gov/blob/master/_layouts/default.html)
241 | contains an example of setting the user interface options in concert with a [custom
242 | search results rendering
243 | script](https://github.com/presidential-innovation-fellows/apps-gov/blob/master/assets/js/products.js):
244 |
245 | ```html
246 |
247 |
254 | {% jekyll_pages_api_search_load %}
255 |