diff --git a/bin/build-site.js b/bin/build-site.js index 7e6b6c29..98813eab 100755 --- a/bin/build-site.js +++ b/bin/build-site.js @@ -9,7 +9,11 @@ const phin = require( 'phin' ); const rimraf = require( 'rimraf' ); const swig = require( 'swig-templates' ); -const { parseFromDirectory, headingPropertyNames } = require( '../lib' ); +const { + parseFromDirectory, + headingPropertyNames, + buildSearchData, +} = require( '../lib' ); const contentPath = path.join( __dirname, '..' ); const sitePath = path.join( __dirname, '..', 'site' ); const siteBuildPath = path.join( sitePath, 'build' ); @@ -211,7 +215,7 @@ async function buildSite() { // Set up styles/scripts for specific pages const indexScripts = [ { - url: '//cdnjs.cloudflare.com/ajax/libs/list.js/1.5.0/list.min.js', + url: '//cdnjs.cloudflare.com/ajax/libs/lunr.js/2.3.7/lunr.min.js', }, { url: copyAssetToBuild( 'companies-table.js' ), } ]; @@ -223,6 +227,14 @@ async function buildSite() { fs.copyFileSync( path.join( faviconPath, f ), path.join( siteBuildPath, f ) ); } ); + // Generate search index + console.log( 'Generating search index' ); + const searchIndexData = JSON.stringify( buildSearchData( data ) ); + const searchIndexFilename = copyAssetToBuild( + 'search.js', + searchIndexData + ); + // Generate the index.html file from the main README // TODO: Build this page and its table dynamically; more filters const readmeTemplate = swig.compileFile( @@ -232,6 +244,11 @@ async function buildSite() { writePage( 'index.html', readmeTemplate( { stylesheets, scripts: scripts.concat( indexScripts ), + inlineScripts: [ + '\n\t\tvar searchIndexFilename = ' + JSON.stringify( searchIndexFilename ) + ';' + + '\n\t\tvar searchIndexSize = ' + JSON.stringify( searchIndexData.length ) + ';' + + '\n\t\t', + ], pageContent: data.readmeContent, editUrl: githubEditUrl( 'README.md' ), } ) ); @@ -249,6 +266,7 @@ async function buildSite() { writePage( path.join( dirname, 'index.html' ), companyTemplate( { stylesheets, scripts, + inlineScripts: [], company, headingPropertyNames, missingHeadings, diff --git a/bin/validate.js b/bin/validate.js index d338d330..75d290e6 100755 --- a/bin/validate.js +++ b/bin/validate.js @@ -47,4 +47,4 @@ console.log( ( errorCount === 1 ? '' : 's' ) ); -process.exitCode = Math.min( errorCount, 99 ); +process.exitCode = ( errorCount > 0 ? 3 : 0 ); diff --git a/lib/index.js b/lib/index.js index 91c3280d..2443db19 100755 --- a/lib/index.js +++ b/lib/index.js @@ -5,6 +5,7 @@ const path = require( 'path' ); const util = require( 'util' ); const cheerio = require( 'cheerio' ); +const lunr = require( 'lunr' ); const marked = require( 'marked' ); @@ -75,10 +76,13 @@ exports.stripExtraChars = stripExtraChars; /** * Other exports */ -exports.headingPropertyNames = headingsAll.reduce( ( acc, val ) => { - acc[ toIdentifierCase( val ) ] = val; - return acc; -}, {} ); +function getHeadingPropertyNames() { + return headingsAll.reduce( ( acc, val ) => { + acc[ toIdentifierCase( val ) ] = val; + return acc; + }, {} ); +} +exports.headingPropertyNames = getHeadingPropertyNames(); /** @@ -88,6 +92,7 @@ exports.headingPropertyNames = headingsAll.reduce( ( acc, val ) => { * files, and validate and parse the content of the Markdown files. */ exports.parseFromDirectory = contentPath => { + const companyNamesSeen = {}; let errors = []; function error( filename, msg, ...params ) { @@ -137,13 +142,14 @@ exports.parseFromDirectory = contentPath => { let lastCompanyName = null; $( 'tr' ).each( ( i, tr ) => { + const $tr = $( tr ); if ( i === 0 ) { // Assign an ID to the table. - $( tr ).closest( 'table' ).attr( 'id', 'companies-table' ); + $tr.closest( 'table' ).attr( 'id', 'companies-table' ); // Skip the table header row. return; } - const $td = $( tr ).children( 'td' ); + const $td = $tr.children( 'td' ); const websiteUrl = $td.eq( 1 ).text(); const websiteText = websiteUrl @@ -161,10 +167,25 @@ exports.parseFromDirectory = contentPath => { shortRegion: $td.eq( 2 ).text().trim(), }; - if ( ! readmeEntry.name ) { + if ( ! websiteText ) { + readmeError( + 'Missing website for company: %s', + readmeEntry.name + ); + } + + if ( readmeEntry.name ) { + if ( companyNamesSeen[ readmeEntry.name.toLowerCase() ] ) { + readmeError( + 'Duplicate company: %s', + readmeEntry.name + ); + } + companyNamesSeen[ readmeEntry.name.toLowerCase() ] = true; + } else { readmeError( 'Missing company name: %s', - $( tr ).html().replace( /\n/g, '' ) + $tr.html().replace( /\n/g, '' ) ); } @@ -174,14 +195,14 @@ exports.parseFromDirectory = contentPath => { ) { readmeError( 'Invalid content in Website column: %s', - $( tr ).html().replace( /\n/g, '' ) + $tr.html().replace( /\n/g, '' ) ); } if ( $td.eq( 2 ).children().length > 0 ) { readmeError( 'Extra content in Region column: %s', - $( tr ).html().replace( /\n/g, '' ) + $tr.html().replace( /\n/g, '' ) ); } @@ -235,7 +256,10 @@ exports.parseFromDirectory = contentPath => { ); } - // Set classes on table cells + // Set identifying attributes on table elements + $tr + .attr( 'class', 'company-row' ) + .attr( 'id', 'company-row-' + ( i - 1 ) ); $td.eq( 0 ).attr( 'class', 'company-name' ); $td.eq( 1 ).attr( 'class', 'company-website' ); $td.eq( 2 ).attr( 'class', 'company-region' ); @@ -500,3 +524,65 @@ exports.parseFromDirectory = contentPath => { readmeContent, }; }; + +/** + * Build search index data from the result of parseFromDirectory(). + */ +exports.buildSearchData = data => { + const textData = []; + + data.companies.forEach( ( company, i ) => { + const thisTextData = { + id: String( i ), + nameText: company.name, + websiteText: company.websiteText, + }; + + if ( company.shortRegion ) { + thisTextData.shortRegion = company.shortRegion; + } + + Object.keys( exports.headingPropertyNames ).forEach( h => { + if ( company.profileContent[ h ] ) { + const text = cheerio.load( company.profileContent[ h ] ).text() + // Replace warning emoji with a searchable token + .replace( /\u26a0/, '(_incomplete)' ); + thisTextData[ h ] = text; + } + } ); + + textData.push( thisTextData ); + } ); + + const index = lunr( function() { + this.field( 'nameText' ); + this.field( 'websiteText' ); + this.field( 'shortRegion' ); + + Object.keys( exports.headingPropertyNames ).forEach( h => { + this.field( h ); + } ); + + // https://github.com/olivernn/lunr.js/issues/25#issuecomment-623267494 + this.metadataWhitelist = ['position']; + + // https://github.com/olivernn/lunr.js/issues/192#issuecomment-172915226 + // https://gist.github.com/olivernn/7cd496f8654a0246c53c + function contractionTrimmer( token ) { + return token.update( str => { + return str.replace( /('m|'ve|n't|'d|'ll|'ve|'s|'re)$/, '' ); + } ); + } + lunr.Pipeline.registerFunction( contractionTrimmer, 'contractionTrimmer' ); + this.pipeline.after( lunr.trimmer, contractionTrimmer ); + + Object.keys( textData ).forEach( c => this.add( textData[ c ] ) ); + } ); + + const headings = getHeadingPropertyNames(); + headings.nameText = 'Company name'; + headings.websiteText = 'Website'; + headings.shortRegion = 'Region'; + + return { index, textData, headings }; +}; diff --git a/package-lock.json b/package-lock.json index fe63a517..968338ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -627,6 +627,11 @@ "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=" }, + "lunr": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.7.tgz", + "integrity": "sha512-HjFSiy0Y0qZoW5OA1I6qBi7OnsDdqQnaUr03jhorh30maQoaP+4lQCKklYE3Nq3WJMSUfuBl6N+bKY5wxCb9hw==" + }, "marked": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/marked/-/marked-0.7.0.tgz", diff --git a/package.json b/package.json index 02ab8244..e0dfacfa 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "cheerio": "^1.0.0-rc.3", + "lunr": "2.3.7", "marked": "^0.7.0", "phin": "^3.4.0", "rimraf": "^3.0.0", diff --git a/site/assets/companies-table.js b/site/assets/companies-table.js index 21b44cdf..5345318f 100644 --- a/site/assets/companies-table.js +++ b/site/assets/companies-table.js @@ -1,52 +1,222 @@ -function setupFilters() { +function setupSearch() { var table = document.querySelector( 'table#companies-table' ); - var headerCells = table.querySelectorAll( 'thead tr th' ); - headerCells[ 0 ].innerHTML = - ''; - headerCells[ 1 ].innerHTML = - ''; - headerCells[ 2 ].innerHTML = - ''; + var searchInput = document.createElement( 'input' ); + searchInput.type = 'text'; + searchInput.placeholder = 'Search'; + searchInput.id = 'search-input'; - var tbody = table.querySelector( 'tbody' ); - tbody.setAttribute( 'class', 'list' ); - - var filterInput = document.createElement( 'input' ); - filterInput.type = 'text'; - filterInput.placeholder = 'Filter Companies'; - filterInput.id = 'company-filter'; - filterInput.setAttribute( 'class', 'company-filter' ); + var searchStatus = document.createElement( 'span' ); + searchStatus.id = 'search-status'; var companiesHeading = document.querySelector( 'h2#companies' ); - companiesHeading.appendChild( filterInput ); + companiesHeading.appendChild( searchInput ); + companiesHeading.appendChild( searchStatus ); - var filtersExplanation = document.createElement( 'p' ); - filtersExplanation.id = 'filters-explanation'; - filtersExplanation.innerHTML = ( - 'Use the text box above to filter the list of companies, ' - + 'or click a column heading to sort by that column.' + var searchExplanation = document.createElement( 'p' ); + searchExplanation.id = 'search-explanation'; + searchExplanation.innerHTML = ( + 'Use the text box above to search all of our company data. ' + + ' More info' ); - table.parentNode.insertBefore( filtersExplanation, table ); + table.parentNode.insertBefore( searchExplanation, table ); - window.tableFilter = new List( - 'main', // element ID that contains everything - { - valueNames: [ - 'company-name', - 'company-website', - 'company-region' - ], - searchClass: 'company-filter', + var searchLoading = false; + var searchData = null; + var searchIndex = null; + var updateTimeout = null; + + function updateSearch() { + if ( ! searchData || searchLoading ) { + return; } - ); + + var searchValue = searchInput.value + .replace( /[^a-z0-9_']+/gi, ' ' ) + .trim() + .split( ' ' ) + .map( function( term ) { + term = term + .replace( /('m|'ve|n't|'d|'ll|'ve|'s|'re)$/, '' ) + .replace( /'/g, '' ); + if ( ! lunr.stopWordFilter( term.toLowerCase() ) ) { + return null; + } else if ( term ) { + return '+' + term; + } else { + return term; + } + } ) + .filter( Boolean ) + .join( ' ' ); + var allMatch = ! searchValue; + var searchResults = searchValue ? searchIndex.search( searchValue ) : []; + var searchDisplayValue = ( + searchValue === '+_incomplete' + ? 'Incomplete profile' + : searchInput.value.trim() + ); + if ( allMatch ) { + searchStatus.innerHTML = ( + 'Empty search; showing all ' + + searchData.textData.length + + ' companies' + ); + } else if ( searchResults.length === 1 ) { + searchStatus.innerText = searchDisplayValue + ': 1 result'; + } else { + searchStatus.innerText = ( + searchDisplayValue + ': ' + + searchResults.length + ' results' + ); + } + var searchMatches = {}; + searchResults.forEach( function( r ) { + searchMatches[ +r.ref ] = r; + } ); + if ( window.console && console.log ) { + console.log( 'search', { value: searchValue, results: searchResults } ); + } + searchData.textData.forEach( function( company, index ) { + var match = searchMatches[ index ]; + var row = document.getElementById( 'company-row-' + index ); + var rowMatch = row.nextElementSibling; + if ( rowMatch && rowMatch.classList.contains( 'company-match' ) ) { + rowMatch.parentNode.removeChild( rowMatch ); + } + row.style.display = ( match || allMatch ? '' : 'none' ); + row.classList.remove( 'has-match' ); + if ( match ) { + row.classList.add( 'has-match' ); + var metadata = match.matchData.metadata; + var contextWords = ( window.innerWidth <= 600 ? 4 : 6 ); + var k1, k2, pos; + loop1: for ( k1 in metadata ) { + for ( k2 in metadata[ k1 ] ) { + pos = metadata[ k1 ][ k2 ].position[ 0 ]; + if ( k2 !== 'nameText' ) { + // Accept company name for matches, but prefer + // other fields if there are any + break loop1; + } + } + } + rowMatch = document.createElement( 'tr' ); + rowMatch.setAttribute( 'class', 'company-match' ); + var rowMatchCell = document.createElement( 'td' ); + rowMatchCell.setAttribute( 'colspan', 3 ); + var spanBefore = document.createElement( 'span' ); + var spanMatch = document.createElement( 'strong' ); + var spanAfter = document.createElement( 'span' ); + var text = company[ k2 ]; + var words = []; + var currentWord = ''; + var i, inWord, c; + for ( i = pos[ 0 ] - 1; i >= 0; i-- ) { + c = text.substring( i, i + 1 ); + inWord = /\S/.test( c ); + if ( inWord ) { + currentWord = c + currentWord; + } + if ( ( ! inWord || i === 0 ) && currentWord ) { + words.unshift( currentWord ); + currentWord = ''; + if ( words.length === contextWords + 1 ) { + words[ 0 ] = '\u2026'; + break; + } + } + } + spanBefore.innerText = ( + ( window.innerWidth > 600 ? searchData.headings[ k2 ] + ': ' : '' ) + + words.join( ' ' ) + + ' ' + ).replace( /\(_incomplete\)/, '(Incomplete)' ); + spanMatch.innerText = text + .substring( pos[ 0 ], pos[ 0 ] + pos[ 1 ] ) + .replace( /\(_incomplete\)/, '(Incomplete)' ); + words = []; + currentWord = ''; + for ( i = pos[ 0 ] + pos[ 1 ] + 1; i < text.length; i++ ) { + c = text.substring( i, i + 1 ); + inWord = /\S/.test( c ); + if ( inWord ) { + currentWord += c; + } + if ( ( ! inWord || i === text.length - 1 ) && currentWord ) { + words.push( currentWord ); + currentWord = ''; + if ( words.length === contextWords + 1 ) { + words[ contextWords ] = '\u2026'; + break; + } + } + } + spanAfter.innerText = ( + ' ' + words.join( ' ' ) + ).replace( /\(_incomplete\)/, '(Incomplete)' ); + rowMatchCell.appendChild( spanBefore ); + rowMatchCell.appendChild( spanMatch ); + rowMatchCell.appendChild( spanAfter ); + rowMatch.appendChild( rowMatchCell ); + row.parentNode.insertBefore( rowMatch, row.nextSibling ); + } + } ); + } + + searchInput.addEventListener( 'focus', function() { + if ( searchData || searchLoading ) { + return; + } + + searchLoading = true; + var searchLoadingText = 'Loading search data...'; + + searchStatus.innerHTML = searchLoadingText; + + var xhr = new XMLHttpRequest(); + xhr.open( 'GET', searchIndexFilename ); + + xhr.onprogress = function( e ) { + var percentLoaded; + if ( e.lengthComputable ) { + percentLoaded = Math.round( 100 * e.loaded / e.total ); + } else { + percentLoaded = Math.min( + 100, + Math.round( 100 * e.loaded / searchIndexSize ) + ); + } + searchStatus.innerHTML = searchLoadingText + ' ' + percentLoaded + '%'; + }; + + xhr.onload = function() { + searchLoading = false; + if ( xhr.status === 200 ) { + searchData = JSON.parse( xhr.response ); + searchIndex = lunr.Index.load( searchData.index ); + updateSearch(); + } else { + searchStatus.innerHTML = 'Error!'; + } + }; + + xhr.send(); + } ); + + searchInput.addEventListener( 'keyup', function() { + if ( updateTimeout ) { + clearTimeout( updateTimeout ); + } + updateTimeout = setTimeout( updateSearch, 450 ); + } ); document.body.setAttribute( 'class', - document.body.getAttribute( 'class' ) + ' filters-enabled' + document.body.getAttribute( 'class' ) + ' search-enabled' ); } document.addEventListener( 'DOMContentLoaded', function( event ) { - setupFilters(); + setupSearch(); } ); diff --git a/site/assets/site.css b/site/assets/site.css index c9f2f336..25c8ea6a 100644 --- a/site/assets/site.css +++ b/site/assets/site.css @@ -46,10 +46,10 @@ h1.company-name { } /** - * Styles for the companies table and filters on the main page + * Styles for the companies table and search on the main page */ -#company-filter { +#search-input { margin: 0 0 0 16px; padding: 8px; font-family: "Source Sans Pro", sans-serif; @@ -58,60 +58,47 @@ h1.company-name { vertical-align: middle; } -#filters-explanation { +#search-status { + margin-left: 18px; + font-size: 15px; + font-weight: normal; +} + +#search-explanation { font-style: italic; font-size: 15px; + line-height: 18px; } @media screen and (min-width: 50em) { - #filters-explanation { + #search-explanation { font-size: 16px; } } -body.filters-enabled table#companies-table th { - padding: 0; +table#companies-table th { + border-bottom: 2px solid #eee; + line-height: 1; + padding: 9px 6px; } -table#companies-table button.sort { - width: 100%; +table#companies-table td { border-width: 0; - border-radius: 0; - padding: 7px 3px 4px; - text-align: left; - font-weight: 700; - color: #666; - outline: none; -} -table#companies-table button.sort:hover, -table#companies-table button.sort:focus { - color: #c61610; -} -table#companies-table button.sort:hover { - background: #f4f4f4; + line-height: 1; } -/* Sort indicators adapted from http://listjs.com/examples/table/ */ - -table#companies-table .sort.asc:after, -table#companies-table .sort.desc:after { - width: 0; - height: 0; - border-left: 6px solid transparent; - border-right: 6px solid transparent; - content: ''; - display: inline-block; - position: relative; - left: 3px; - top: -2px; +table#companies-table tr.company-row td { + border-top: 1px solid #eee; + padding: 9px 6px; +} +table#companies-table tr.company-row.has-match td { + padding-bottom: 3px; } -table#companies-table .sort.asc:after { - border-bottom: 6px solid; -} - -table#companies-table .sort.desc:after { - border-top: 6px solid; +table#companies-table tr.company-match td { + padding: 0 0 9px 18px; + font-size: 81%; + font-style: italic; } /* Column-specific styles */ @@ -132,16 +119,21 @@ table#companies-table td.company-region { /* Mobile-friendly display */ @media screen and (max-width: 600px) { - body.filters-enabled h2#companies { - margin-bottom: 18px; + body.search-enabled h2#companies { + margin-bottom: 12px; } - #company-filter { + #search-input { display: block; width: 100%; margin: 27px 0 0 0; } + #search-status { + font-size: 13.5px; + margin-left: 0; + } + table#companies-table, table#companies-table thead, table#companies-table tbody, @@ -149,33 +141,31 @@ table#companies-table td.company-region { display: block; } - table#companies-table tr { - border-bottom: 1px solid #eee; - padding: 0 0 8px 12px; + table#companies-table tr.company-row { + border-top: 1px solid #eee; + padding: 7.5px 0 6px 12px; + line-height: 1.2; + } + table#companies-table tr.company-row.has-match { + padding-bottom: 3px; } - table#companies-table button.sort { - width: auto; - padding-left: 6px; - padding-right: 6px; - } - - table#companies-table .sort.asc:after, - table#companies-table .sort.desc:after { - margin-right: 2px; + table#companies-table tr.company-match td { + padding-left: 12px; + font-size: 90%; } table#companies-table thead tr { - border-bottom-width: 3px; + border-bottom: 2px solid #eee; padding: 0; } table#companies-table th, - table#companies-table td { + table#companies-table tr.company-row td, + table#companies-table tr.company-row.has-match td { width: auto !important; - padding-left: 0; - padding-right: 0; - border-bottom-width: 0; + padding: 0; + border-width: 0; } table#companies-table th { @@ -191,11 +181,12 @@ table#companies-table td.company-region { display: none; } - table#companies-table td.company-name { + table#companies-table tr.company-row td.company-name, + table#companies-table tr.company-row.has-match td.company-name { display: flex; font-size: 16px; font-weight: 700; - padding-bottom: 0; + padding-bottom: 3px; margin-left: -12px; } diff --git a/site/templates/base.html b/site/templates/base.html index 5ccba3d6..80ce33ec 100644 --- a/site/templates/base.html +++ b/site/templates/base.html @@ -6,10 +6,13 @@