├── Gemfile ├── test ├── test-site │ ├── _plugins │ │ └── search.rb │ ├── Gemfile │ ├── _layouts │ │ ├── page.html │ │ ├── default.html │ │ └── post.html │ ├── bower.json │ ├── about.md │ ├── index.html │ ├── _includes │ │ ├── head.html │ │ ├── header.html │ │ └── footer.html │ ├── css │ │ └── main.scss │ ├── _config.yml │ ├── _posts │ │ └── 2015-05-18-welcome-to-jekyll.markdown │ └── _sass │ │ ├── _syntax-highlighting.scss │ │ ├── _base.scss │ │ └── _layout.scss ├── test_helper.rb ├── index.html ├── site_builder.rb ├── assets_test.rb ├── assets_copier_test.rb ├── search_test.rb └── standalone_test.rb ├── assets ├── png │ └── search.png ├── svg │ └── search.svg └── js │ ├── search.js │ ├── search-engine.js │ └── search-ui.js ├── .travis.yml ├── lib ├── jekyll_pages_api_search │ ├── version.rb │ ├── layouts │ │ └── search-results.html │ ├── sass.rb │ ├── search.html │ ├── compressor.rb │ ├── generator.rb │ ├── search_page.rb │ ├── site.rb │ ├── config.rb │ ├── browserify.rb │ ├── search.js │ ├── sass │ │ └── jekyll_pages_api_search.scss │ ├── search.rb │ ├── browserify.js │ ├── standalone.rb │ ├── search_page_layouts.rb │ ├── search_hook.rb │ ├── assets.rb │ └── tags.rb └── jekyll_pages_api_search.rb ├── .eslintrc ├── .gitignore ├── package.json ├── LICENSE.md ├── jekyll_pages_api_search.gemspec ├── Rakefile ├── bin └── jekyll_pages_api_search ├── CONTRIBUTING.md └── README.md /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /test/test-site/_plugins/search.rb: -------------------------------------------------------------------------------- 1 | require 'jekyll_pages_api_search' 2 | -------------------------------------------------------------------------------- /assets/png/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/18F/jekyll_pages_api_search/HEAD/assets/png/search.png -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | script: bundle exec rake ci_build 3 | rvm: 4 | - 2.2.0 5 | env: 6 | global: 7 | secure: 8 | -------------------------------------------------------------------------------- /test/test-site/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'jekyll' 4 | gem 'jekyll_pages_api_search', :path => '../..' 5 | -------------------------------------------------------------------------------- /lib/jekyll_pages_api_search/version.rb: -------------------------------------------------------------------------------- 1 | # @author Mike Bland (michael.bland@gsa.gov) 2 | 3 | module JekyllPagesApiSearch 4 | VERSION = '0.5.0' 5 | end 6 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # @author Mike Bland (michael.bland@gsa.gov) 2 | 3 | require "codeclimate-test-reporter" 4 | CodeClimate::TestReporter.start 5 | 6 | require "coveralls" 7 | Coveralls.wear! 8 | -------------------------------------------------------------------------------- /test/test-site/_layouts/page.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | --- 4 |
5 | 6 |
7 |

{{ page.title }}

8 |
9 | 10 |
11 | {{ content }} 12 |
13 | 14 |
15 | -------------------------------------------------------------------------------- /lib/jekyll_pages_api_search/layouts/search-results.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ page.title }} 5 | 6 | 7 | {% jekyll_pages_api_search_interface %} 8 | {% jekyll_pages_api_search_results %} 9 | 10 | 11 | {% jekyll_pages_api_search_load %} 12 | -------------------------------------------------------------------------------- /lib/jekyll_pages_api_search/sass.rb: -------------------------------------------------------------------------------- 1 | # @author Mike Bland (michael.bland@gsa.gov) 2 | 3 | require 'sass' 4 | 5 | module JekyllPagesApiSearch 6 | class Sass 7 | DIR = File.join File.dirname(__FILE__), 'sass' 8 | INTERFACE_FILE = File.join DIR, 'jekyll_pages_api_search.scss' 9 | end 10 | end 11 | 12 | Sass.load_paths << ::JekyllPagesApiSearch::Sass::DIR 13 | -------------------------------------------------------------------------------- /lib/jekyll_pages_api_search/search.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 8 |
9 | 10 |
11 | -------------------------------------------------------------------------------- /test/test-site/bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jekyll_pages_api_search_test_site", 3 | "dependencies": { 4 | "lunr.js": "~0.5.7" 5 | }, 6 | "install": { 7 | "path": "assets/js/vendor" 8 | }, 9 | "version": "0.0.0", 10 | "license": "CC0", 11 | "ignore": [ 12 | "**/.*", 13 | "node_modules", 14 | "bower_components", 15 | "test", 16 | "tests" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /test/test-site/_layouts/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% include head.html %} 5 | 6 | 7 | {% include header.html %} 8 | {% jekyll_pages_api_search_interface %} 9 | 10 |
11 |
12 | {{ content }} 13 |
14 |
15 | 16 | {% include footer.html %} 17 | 18 | 19 | 20 | {% jekyll_pages_api_search_load %} 21 | 22 | -------------------------------------------------------------------------------- /test/test-site/_layouts/post.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | --- 4 |
5 | 6 |
7 |

{{ page.title }}

8 | 9 |
10 | 11 |
12 | {{ content }} 13 |
14 | 15 |
16 | -------------------------------------------------------------------------------- /lib/jekyll_pages_api_search/compressor.rb: -------------------------------------------------------------------------------- 1 | require 'fileutils' 2 | require 'zlib' 3 | 4 | module JekyllPagesApiSearch 5 | class Compressor 6 | def self.gzip_in_memory_content(file_to_content_hash) 7 | file_to_content_hash.each do |file, content| 8 | ::Zlib::GzipWriter.open("#{file}.gz", Zlib::BEST_COMPRESSION) do |gz| 9 | gz.write content 10 | end 11 | FileUtils.touch([file, "#{file}.gz"]) 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | jekyll_pages_api_search browser tests 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /test/test-site/about.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page 3 | title: About 4 | permalink: /about/ 5 | skip_index: true 6 | --- 7 | 8 | This is the base Jekyll theme. You can find out more info about customizing your Jekyll theme, as well as basic Jekyll usage documentation at [jekyllrb.com](http://jekyllrb.com/) 9 | 10 | You can find the source code for the Jekyll new theme at: [github.com/jglovier/jekyll-new](https://github.com/jglovier/jekyll-new) 11 | 12 | You can find the source code for Jekyll at [github.com/jekyll/jekyll](https://github.com/jekyll/jekyll) 13 | -------------------------------------------------------------------------------- /lib/jekyll_pages_api_search.rb: -------------------------------------------------------------------------------- 1 | # @author Mike Bland (michael.bland@gsa.gov) 2 | 3 | require_relative './jekyll_pages_api_search/assets' 4 | require_relative './jekyll_pages_api_search/generator' 5 | require_relative './jekyll_pages_api_search/sass' 6 | require_relative './jekyll_pages_api_search/search' 7 | require_relative './jekyll_pages_api_search/search_hook' 8 | require_relative './jekyll_pages_api_search/site' 9 | require_relative './jekyll_pages_api_search/standalone' 10 | require_relative './jekyll_pages_api_search/tags' 11 | require_relative './jekyll_pages_api_search/version' 12 | -------------------------------------------------------------------------------- /lib/jekyll_pages_api_search/generator.rb: -------------------------------------------------------------------------------- 1 | require_relative './assets' 2 | require_relative './browserify' 3 | require_relative './config' 4 | require_relative './search_page' 5 | require_relative './search_page_layouts' 6 | 7 | require 'jekyll' 8 | 9 | module JekyllPagesApiSearch 10 | class Generator < ::Jekyll::Generator 11 | def generate(site) 12 | return if Config.skip_index?(site) 13 | JekyllPagesApiSearch::SearchPageLayouts.register(site) 14 | site.pages << JekyllPagesApiSearch::SearchPage.new(site) 15 | JekyllPagesApiSearch::Assets.copy_to_site(site) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/test-site/index.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | --- 4 | 5 |
6 | 7 |

Posts

8 | 9 | 20 | 21 |

subscribe via RSS

22 | 23 |
24 | -------------------------------------------------------------------------------- /test/site_builder.rb: -------------------------------------------------------------------------------- 1 | # @author Mike Bland (michael.bland@gsa.gov) 2 | 3 | require 'test_temp_file_helper' 4 | 5 | module JekyllPagesApiSearch 6 | class SiteBuilder 7 | __TEMP_FILE_HELPER = TestTempFileHelper::TempFileHelper.new 8 | SOURCE_DIR = File.join(File.dirname(__FILE__), 'test-site') 9 | BUILD_DIR = __TEMP_FILE_HELPER.mkdir('test-site') 10 | puts "Building site in #{BUILD_DIR}" 11 | unless system( 12 | "cd #{SOURCE_DIR} && bundle exec jekyll build " + 13 | "--destination #{BUILD_DIR} --trace", 14 | {:out => '/dev/null', :err =>STDERR}) 15 | STDERR.puts "\n***\nSiteBuilder failed to build site for tests\n***\n" 16 | exit $?.exitstatus 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { "extends": "eslint:recommended", 2 | "ecmaFeatures" : { 3 | "modules" : true 4 | }, 5 | "env" : { 6 | "node" : true, 7 | "mocha" : true, 8 | "es6" : true /** all es6 features except modules */ 9 | }, 10 | "rules" : { 11 | "indent": [ 12 | 2, 13 | 2, 14 | { "VariableDeclarator": 2 } 15 | ], 16 | "quotes": [ 17 | 2, 18 | "single" 19 | ], 20 | "semi": [ 21 | 2, 22 | "always" 23 | ], 24 | "comma-dangle": [ 25 | 1, 26 | "never" 27 | ], 28 | "no-console": [ 0 ], 29 | "max-len": [ 30 | 2, 31 | 80, 32 | 2 33 | ], 34 | "camelcase": [ 35 | 2, 36 | { "properties": "always" } 37 | ] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /assets/svg/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /test/test-site/_includes/head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% if page.title %}{{ page.title }}{% else %}{{ site.title }}{% endif %} 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /lib/jekyll_pages_api_search/search_page.rb: -------------------------------------------------------------------------------- 1 | require 'jekyll' 2 | 3 | module JekyllPagesApiSearch 4 | class SearchPage < ::Jekyll::Page 5 | DEFAULT_TITLE = 'Search results' 6 | DEFAULT_ENDPOINT = 'search' 7 | 8 | def initialize(site) 9 | @site = site 10 | @name = 'index.html' 11 | 12 | process(@name) 13 | @data = {} 14 | search_config = site.config['jekyll_pages_api_search'] 15 | data['title'] = search_config['results_page_title'] || DEFAULT_TITLE 16 | data['permalink'] = endpoint(site.config, search_config) 17 | data['layout'] = ( 18 | search_config['layout'] || SearchPageLayouts::DEFAULT_LAYOUT) 19 | data['skip_index'] = true 20 | end 21 | 22 | private 23 | 24 | def endpoint(site_config, search_config) 25 | "/#{search_config['endpoint'] || DEFAULT_ENDPOINT}/".gsub(/\/+/, '/') 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .*.swp 2 | *.gem 3 | *.rbc 4 | /.config 5 | /coverage/ 6 | /InstalledFiles 7 | /pkg/ 8 | /spec/reports/ 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | 13 | ## Specific to RubyMotion: 14 | .dat* 15 | .repl_history 16 | build/ 17 | 18 | ## Documentation cache and generated files: 19 | /.yardoc/ 20 | /_yardoc/ 21 | /doc/ 22 | /rdoc/ 23 | 24 | ## Environment normalisation: 25 | /.bundle/ 26 | /lib/bundler/man/ 27 | 28 | # for a library or gem, you might want to ignore these files since the code is 29 | # intended to run in multiple environments; otherwise, check them in: 30 | Gemfile.lock 31 | .ruby-version 32 | .ruby-gemset 33 | 34 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 35 | .rvmrc 36 | 37 | # Development artifacts 38 | bower_components 39 | .sass-cache 40 | node_modules 41 | 42 | # Build artifacts 43 | lib/jekyll_pages_api_search/lunr.min.js 44 | assets/js/search-bundle.js* 45 | -------------------------------------------------------------------------------- /lib/jekyll_pages_api_search/site.rb: -------------------------------------------------------------------------------- 1 | # @author Mike Bland (michael.bland@gsa.gov) 2 | 3 | require 'jekyll_pages_api' 4 | require 'safe_yaml' 5 | 6 | module JekyllPagesApiSearch 7 | class Site 8 | attr_reader :source, :config 9 | attr_accessor :pages 10 | 11 | def initialize(basedir, config) 12 | @source = basedir 13 | @config = SafeYAML.load_file(config, :safe => true) 14 | @pages = [] 15 | end 16 | 17 | # TODO(mbland): comment on how a pages.json file can be transfered from 18 | # JekyllPagesApi::GeneratedSite 19 | def load_pages_json(pages_json_path) 20 | basename = File.basename pages_json_path 21 | rel_dir = File.dirname pages_json_path 22 | rel_dir = rel_dir[self.source.size..rel_dir.size] 23 | page = ::JekyllPagesApi::PageWithoutAFile.new( 24 | self, self.source, rel_dir, basename) 25 | page.output = File.read(pages_json_path) 26 | self.pages << page 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/jekyll_pages_api_search/config.rb: -------------------------------------------------------------------------------- 1 | module JekyllPagesApiSearch 2 | class Config 3 | def self.get(site, value) 4 | search_config = site.config['jekyll_pages_api_search'] 5 | search_config[value] unless search_config.nil? 6 | end 7 | 8 | def self.skip_index?(site) 9 | search_config = site.config['jekyll_pages_api_search'] 10 | return true if search_config.nil? 11 | skip_index_value = search_config['skip_index'] 12 | return skip_index_value unless skip_index_value.nil? 13 | search_config['skip_index'] = !node_installed? 14 | end 15 | 16 | def self.node_installed? 17 | $stdout.write('jekyll_pages_api_search: checking for Node.js: ') 18 | return true if system('node', '-v') 19 | puts('not generating search index because Node.js not found; check ' \ 20 | 'your PATH environment variable or visit https://nodejs.org/ ' \ 21 | 'to download Node.js for your system') 22 | false 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/jekyll_pages_api_search/browserify.rb: -------------------------------------------------------------------------------- 1 | require_relative './config' 2 | 3 | require 'English' 4 | require 'jekyll/static_file' 5 | require 'jekyll_pages_api' 6 | 7 | module JekyllPagesApiSearch 8 | class Browserify 9 | DIRNAME = File.dirname(__FILE__).freeze 10 | BROWSERIFY_SCRIPT = File.join(DIRNAME, 'browserify.js').freeze 11 | 12 | def self.create_bundle(site) 13 | browserify_config = Config.get(site, 'browserify') 14 | return if browserify_config.nil? 15 | source = File.join(site.source, browserify_config['source']) 16 | target = File.join(site.dest, browserify_config['target']) 17 | execute_browserify(source, target) 18 | end 19 | 20 | def self.execute_browserify(source, target) 21 | status = system("node #{BROWSERIFY_SCRIPT} #{source} #{target}") 22 | if $CHILD_STATUS.exitstatus.nil? 23 | $stderr.puts('Could not execute browserify script') 24 | exit 1 25 | end 26 | exit $CHILD_STATUS.exitstatus if !status 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/jekyll_pages_api_search/search.js: -------------------------------------------------------------------------------- 1 | var lunr = require('./lunr.min'); 2 | var input = ''; 3 | 4 | function buildIndex(corpus, indexFields) { 5 | var index, 6 | urlToDoc = {}; 7 | 8 | index = lunr(function() { 9 | var boost; 10 | 11 | this.ref('url'); 12 | 13 | for (var fieldName in indexFields) { 14 | boost = indexFields[fieldName]; 15 | this.field(fieldName, boost); 16 | } 17 | }); 18 | 19 | corpus.entries.forEach(function(page) { 20 | if (page.skip_index !== true) { 21 | index.add(page); 22 | urlToDoc[page.url] = {url: page.url, title: page.title}; 23 | } 24 | }); 25 | 26 | return JSON.stringify({ 27 | index: index.toJSON(), 28 | urlToDoc: urlToDoc 29 | }); 30 | } 31 | 32 | process.stdin.setEncoding('utf8'); 33 | 34 | process.stdin.on('data', function(data) { 35 | input = input + data; 36 | }); 37 | 38 | process.stdin.on('end', function() { 39 | input = JSON.parse(input); 40 | process.stdout.write(buildIndex(input.corpus, input.indexFields)); 41 | }); 42 | -------------------------------------------------------------------------------- /lib/jekyll_pages_api_search/sass/jekyll_pages_api_search.scss: -------------------------------------------------------------------------------- 1 | @media screen and (min-width: 45em) { 2 | .search-interface{ 3 | width: 30%; 4 | float: right; 5 | } 6 | } 7 | 8 | .search-interface input { 9 | display: block; 10 | -webkit-box-sizing: border-box; 11 | width: 100%; 12 | padding: 6px 4px; 13 | padding-left: 2em; 14 | font: inherit; 15 | border-radius: 3px; 16 | border: 1px solid #ccc; 17 | background: url(../svg/search.svg) 6px 45% no-repeat; 18 | background-size: 16px; 19 | } 20 | 21 | .search-interface button { 22 | position: absolute; 23 | left: -9999em; 24 | bottom: 3px; 25 | border: 1px solid #ddd; 26 | border-radius: 2px; 27 | background: #f8f8f8; 28 | font: inherit; 29 | font-weight: 600; 30 | font-size: 16px; 31 | color: #555; 32 | padding: 4px 12px; 33 | } 34 | 35 | .search-interface button:hover { 36 | background-color: #eee; 37 | color: #111; 38 | border-color: #bbb; 39 | } 40 | 41 | .sr-only, 42 | .search-interface span.label-text { 43 | position: absolute; 44 | overflow: hidden; 45 | width:1px; 46 | height:1px; 47 | margin: -1px; 48 | padding:0; 49 | } 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jekyll_pages_api_search", 3 | "version": "0.1.1", 4 | "description": "Adds lunr.js search to Jekyll sites using jekyll_pages_api", 5 | "homepage": "https://github.com/18F/jekyll_pages_api_search", 6 | "bugs": { 7 | "url": "https://github.com/18F/jekyll_pages_api_search/issues" 8 | }, 9 | "main": "assets/js/search.js", 10 | "devDependencies": { 11 | "browserify": "10.x.x", 12 | "eslint": "^6.8.0", 13 | "lunr": "^0.6.0", 14 | "uglifyify": "3.x.x" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git@github.com:18F/jekyll_pages_api_search.git" 19 | }, 20 | "keywords": [ 21 | "jekyll", 22 | "lunr.js", 23 | "search", 24 | "18F" 25 | ], 26 | "author": "Mike Bland (https://18f.gsa.gov/)", 27 | "license": "CC0-1.0", 28 | "directories": { 29 | "test": "test" 30 | }, 31 | "dependencies": {}, 32 | "scripts": { 33 | "make-bundle": "browserify -g uglifyify assets/js/search.js -o assets/js/search-bundle.js", 34 | "lint": "eslint assets/js/search.js assets/js/search-engine.js assets/js/search-ui.js lib/jekyll_pages_api_search/search.js lib/jekyll_pages_api_search/browserify.js" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /test/test-site/_includes/header.html: -------------------------------------------------------------------------------- 1 | 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 | [![Build Status](https://travis-ci.org/18F/jekyll_pages_api_search.svg?branch=master)](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 | Search demo
    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 `
  1. ` 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 | 256 | ``` 257 | 258 | Note that if you have a _different_ input element on different pages, you can 259 | add something similar to the following to each corresponding layout (taken 260 | from [apps.gov's homepage 261 | layout](https://github.com/presidential-innovation-fellows/apps-gov/blob/master/_layouts/home.html)): 262 | 263 | ```html 264 | 265 | 270 | {% jekyll_pages_api_search_load %} 271 | 272 | ``` 273 | 274 | ## Customizing tags and script loading 275 | 276 | If you prefer to craft your own versions of these tags and styles, you can 277 | capture the output of these tags and the Sass `@import` statement, then create 278 | new tags or included files based on this output, careful not to change 279 | anything that causes the interaction between these components to fail. 280 | 281 | Alternately, you can inspect the code of this gem (all paths relative to 282 | `lib/jekyll_pages_api_search/`): 283 | 284 | - `{% jekyll_pages_api_search_interface %}`: includes `search.html` 285 | - `{% jekyll_pages_api_search_load %}`: generated by the `LoadSearchTag` class 286 | from `tags.rb` 287 | - `{% jekyll_pages_api_search_results %}`: generated by the `SearchResultsTag` 288 | class from `tags.rb` 289 | - `@import "jekyll_pages_api_search";`: includes 290 | `sass/jekyll_pages_api_search.scss` 291 | 292 | ## Running standalone 293 | 294 | If you wish to generate a `search-index.json` file (and optionaly a 295 | `pages.json` file) when using a site generation tool other than Jekyll, you 296 | can run the `jekyll_pages_api_search` executable as a post-generation step. 297 | Run `jekyll_pages_api -h` for instructions. 298 | 299 | ## Developing 300 | 301 | Install Node.js per the [installation instructions (step #1)](#installation). 302 | The `Rakefile` will prompt you to install Node.js and any packages 303 | that are missing from your system when running `bundle exec rake build`. 304 | 305 | After cloning this repository, do the following to ensure your installation is 306 | in a good state: 307 | 308 | ```sh 309 | $ cd jekyll_pages_api_search 310 | $ npm install 311 | $ bundle install 312 | $ bundle exec rake test 313 | ``` 314 | 315 | Run `bundle exec rake -T` to get a list of build commands and descriptions. 316 | 317 | Commit an update to bump the version number of 318 | `lib/jekyll_pages_api_search/version.rb` before running `bundle exec rake 319 | release`. 320 | 321 | While developing this gem, add this to the Gemfile of any project using the 322 | gem to try out your changes (presuming the project's working directory is a 323 | sibling of the gem's working directory): 324 | 325 | ```ruby 326 | group :jekyll_plugins do 327 | gem 'jekyll_pages_api_search', :path => '../jekyll_pages_api_search' 328 | end 329 | ``` 330 | 331 | ## Releasing 332 | 333 | After following the steps from the [Developing section](#developing) to build 334 | and test the gem: 335 | 336 | 1. Ensure all changes for the release have already been merged all into the 337 | `master` branch. 338 | 339 | 1. Bump the version number by editing 340 | [`lib/jekyll_pages_api_search/version.rb`](lib/jekyll_pages_api_search/version.rb). 341 | 342 | 1. Commit the version number update directly to the `master` branch, replacing 343 | `X.X.X` with the new version number: 344 | ```sh 345 | $ git commit -m 'Bump to vX.X.X' lib/jekyll_pages_api_search/version.rb 346 | ``` 347 | 348 | 1. Finally, run the following command. It will build the gem, tag the head 349 | commit in `git`, push the branch and tag to GitHub, and ultimately push the 350 | release to [`jekyll_pages_api_search` on RubyGems.org](https://rubygems.org/gems/jekyll_pages_api_search). 351 | ```sh 352 | $ bundle exec rake release 353 | ``` 354 | 355 | ## Contributing 356 | 357 | If you'd like to contribute to this repository, please follow our 358 | [CONTRIBUTING guidelines](./CONTRIBUTING.md). 359 | 360 | ## Public domain 361 | 362 | This project is in the worldwide [public domain](LICENSE.md). As stated in 363 | [CONTRIBUTING](CONTRIBUTING.md): 364 | 365 | > This project is in the public domain within the United States, and copyright 366 | > and related rights in the work worldwide are waived through the 367 | > [CC0 1.0 Universal public domain dedication](https://creativecommons.org/publicdomain/zero/1.0/). 368 | > 369 | > All contributions to this project will be released under the CC0 dedication. 370 | > By submitting a pull request, you are agreeing to comply with this waiver of 371 | > copyright interest. 372 | --------------------------------------------------------------------------------