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 @@ {% block pageTitle %}{% endblock %} – Remote In Tech {%- for stylesheet in stylesheets %} - + + {%- endfor %} + {%- for src in inlineScripts %} + {%- endfor %} {%- for script in scripts %} - + {%- endfor %} diff --git a/test/fixtures/duplicate-company/README.md b/test/fixtures/duplicate-company/README.md new file mode 100644 index 00000000..11b25f3b --- /dev/null +++ b/test/fixtures/duplicate-company/README.md @@ -0,0 +1,13 @@ +# Test data + +This company table and its linked company profiles contain fully valid data. + +## Companies + +Name | Website | Region +------------ | ------- | ------- +[&yet](/company-profiles/and-yet.md) | https://andyet.com | Worldwide +[&Yet](/company-profiles/and.md) | https://andyet.com | Worldwide +[10up](/company-profiles/10up.md) | https://10up.com/ | Worldwide +[17hats](/company-profiles/17hats.md) | https://www.17hats.com/ | Worldwide +[18F](/company-profiles/18f.md) | https://18f.gsa.gov/ | USA diff --git a/test/fixtures/duplicate-company/company-profiles/10up.md b/test/fixtures/duplicate-company/company-profiles/10up.md new file mode 100644 index 00000000..e8087298 --- /dev/null +++ b/test/fixtures/duplicate-company/company-profiles/10up.md @@ -0,0 +1,39 @@ +# 10up, A WordPress Development Agency + +## Company blurb + +We make websites and content management simple and fun with premiere web design & development consulting services, by contributing to open platforms like WordPress, and by providing tools and products (like [PushUp](https://pushupnotifications.com/)) that make web publishing a cinch. + +At 10up, we don’t just “make” things – we engineer them. We’re a group of people built to solve problems; made to create; wired to delight. From beautiful pixels to beautiful code, we constantly improve the things around us, applying our passions to our clients’ projects and goals. + +We’ve had the privilege of working on big web projects for clients as diverse as TechCrunch, ESPN’s FiveThirtyEight and Grantland, SurveyMonkey, Junior Diabetes Research Foundation (JDRF), and Google. + +## Company size + +125 and growing, spread across web engineering, systems, design, project management, strategy/accounts, and operations. + +## Remote status + +10up didn’t integrate remote work – we intended to be remote from the start! Being remote allows us to find talent no matter where they're located, scale up to meet needs with relative fluidity, and have been bootstrapped from the start. We also recognize the challenges of working remotely, and put a lot of effort into in-person meetups, communication tools, and ensuring that employees have the benefits and support they need no matter where they are. + +## Region + +We have employees all around the world, from across the US to the UK to South Africa to the Philippines. Most are currently located in North America, a number travel frequently, and some even work nomadically. + +## Company technologies + +* WordPress +* PHP +* Sass +* Git +* Vagrant +* Nginx +* Memcache + +## Office locations + +None; or everywhere! + +## How to apply + +Check out our [careers page](https://10up.com/careers/) and send an email to jobs@10up.com. Our amazing Recruitment Manager Christine Garrison will be on the other end. diff --git a/test/fixtures/duplicate-company/company-profiles/17hats.md b/test/fixtures/duplicate-company/company-profiles/17hats.md new file mode 100644 index 00000000..d1c9ae31 --- /dev/null +++ b/test/fixtures/duplicate-company/company-profiles/17hats.md @@ -0,0 +1,25 @@ +# 17hats + +## Company blurb + +Are you ready to stop juggling multiple apps to manage your business? From booking clients, to managing projects, to controlling your finances, with 17hats you have everything you need to manage your business and clients anytime, anywhere. Pretty neat if we say so ourselves! We created 17hats specifically for “businesses of one” with a focus on simplicity and ease of use. + +## Company size + +11-50 + +## Remote status + +Every engineer on our team works remotely. That being said, we do need someone who can work California business hours (9am-6pm PST) and who is available for emergencies at night. If you live in the LA area, you are welcome to be in our cool office in Pasadena. Bonus points if you can curse in Dutch. + +## Region + +Worldwide + +## Company technologies + +iOS, React, Knockout, Rails, Perl, HTML, Sql, Ruby, JQuery + +## How to apply + +Email buford@17hats.com with github and/or CV diff --git a/test/fixtures/duplicate-company/company-profiles/18f.md b/test/fixtures/duplicate-company/company-profiles/18f.md new file mode 100644 index 00000000..785ad16f --- /dev/null +++ b/test/fixtures/duplicate-company/company-profiles/18f.md @@ -0,0 +1,55 @@ +# 18F + +## Company blurb + +18F is a civic consultancy for the government, inside the government, working with agencies to rapidly deploy tools and services that are easy to use, cost efficient, and reusable. Our goal is to change how the government buys and develops digital services by helping agencies adopt modern techniques that deliver superior products. + +We are transforming government from the inside out, creating cultural change by working with teams inside agencies who want to create great services for the public. + +We are a trusted partner for agencies working to transform how they build and buy tools and services in a user-centered way. + +We will accomplish our mission by: + +putting the needs of the public first +being design-centric, agile, open, and data-driven +deploying tools and services early and often + +## Company size + +100+ + +## Remote status + +18F employees live all over the country. We work out of homes in Dayton and Tucson, St. Louis and Chapel Hill — and in federal buildings in San Francisco, Chicago, New York City, and Washington D.C. + +That means many of our project teams are also made up of distributed employees working all over the country. For example, you may have a developer in Austin, a designer in Washington D.C., and a content strategist in Portland — but they’re all working on the same team and with the same partners. + +Because we work on distributed teams so frequently, we've developed certain strategies for working well as a collaborative operation. + +[We have a “remote first” mindset.](https://18f.gsa.gov/2015/10/15/best-practices-for-distributed-teams/) + +## Region + +U.S. citizens, non-citizens who are nationals of the U.S., or people who have been admitted to the U.S. for permanent residence and hold a valid green card. + +## Company technologies + +Ruby, Python, HTML, CSS, JavaScript + +## Office locations + +Federal buildings in San Francisco, Chicago, New York City, and Washington D.C. + +## How to apply + +[Open positions](https://pages.18f.gov/joining-18f/open-positions/) + +If you want to apply directly to 18F please email join18f@gsa.gov. We don’t require a formal cover letter, but let us know more about you: + +Send your current resume, preferably as a PDF. +Link to your GitHub profile, design portfolio, or attach a writing sample. +Specify what role you’d like to be considered for. Check out our openings here. +If you're a Veteran of the U.S. Armed Forces or if you are eligible for "derived" preference, please mention that in your email so we can give you priority consideration. +Don't see an opening that suits you? Tell us what you want to do! + +[How to apply](https://pages.18f.gov/joining-18f/how-to-apply/) diff --git a/test/fixtures/duplicate-company/company-profiles/and-yet.md b/test/fixtures/duplicate-company/company-profiles/and-yet.md new file mode 100644 index 00000000..265f24ea --- /dev/null +++ b/test/fixtures/duplicate-company/company-profiles/and-yet.md @@ -0,0 +1,44 @@ +# &yet (And Yet) + +## Company blurb +[&yet](https://andyet.com) is about people. We’re known as a design and development consultancy (specializing in Node, React, and realtime), but we don’t fit neatly in a box. + +We design and [develop custom software](https://andyet.com/software) for web, mobile, desktop, chat, and voice. + +We enable millions of people to make super simple video calls with [Talky](https://talky.io). + +We pioneer software and standards for [realtime communications](https://andyet.com/realtime). + +We [wrote the book](https://gatherthepeople.com) on taking a human approach to marketing for people who would rather make what they love than persuade people to buy it. + +We create high-impact conference experiences such as [RealtimeConf](http://experience.realtimeconf.com) and more recently–[&yetConf](http://andyetconf.com). + +[Learn more about our team](https://andyet.com/about). + +## Company size +20+ + +## Remote status +We employ several strategies to ensure an inclusive and collaborative environment for all our employees. + +To communicate we use [Slack](https://slack.com) (text-chat), our own product [Talky](https://talky.io) (video chat and meetings), [Twist](https://twistapp.com) (daily check-ins) and [GitHub](https://github.com) (organization wide discussions). + +One-on-ones and bi-weekly company-wide updates are a crucial part of staying connected and understanding our team as things change. We encourage employees to use these meetings to bring up frustrations, ideas, or whatever they need in order to be their best selves and to do their best work. + +At least once a year we organize an in-person all-hands team week. It’s the best. + +## Region +&yet has one office located in Richland, WA. Currently ten people are working remotely out of Seattle, Portland, Folsom, Phoenix, Denver, Kansas City, Frankfurt, Oslo, and Melbourne. The most significant timezone difference is 17 hours. + +## Company technologies +* Node.js +* React +* WebRTC +* Pug +* Stylus + +## Office locations +[Fuse Coworking in Richland, WA](https://goo.gl/maps/oJaAQFf12tv) + +## How to apply +No current openings. diff --git a/test/fixtures/duplicate-company/company-profiles/and.md b/test/fixtures/duplicate-company/company-profiles/and.md new file mode 100644 index 00000000..265f24ea --- /dev/null +++ b/test/fixtures/duplicate-company/company-profiles/and.md @@ -0,0 +1,44 @@ +# &yet (And Yet) + +## Company blurb +[&yet](https://andyet.com) is about people. We’re known as a design and development consultancy (specializing in Node, React, and realtime), but we don’t fit neatly in a box. + +We design and [develop custom software](https://andyet.com/software) for web, mobile, desktop, chat, and voice. + +We enable millions of people to make super simple video calls with [Talky](https://talky.io). + +We pioneer software and standards for [realtime communications](https://andyet.com/realtime). + +We [wrote the book](https://gatherthepeople.com) on taking a human approach to marketing for people who would rather make what they love than persuade people to buy it. + +We create high-impact conference experiences such as [RealtimeConf](http://experience.realtimeconf.com) and more recently–[&yetConf](http://andyetconf.com). + +[Learn more about our team](https://andyet.com/about). + +## Company size +20+ + +## Remote status +We employ several strategies to ensure an inclusive and collaborative environment for all our employees. + +To communicate we use [Slack](https://slack.com) (text-chat), our own product [Talky](https://talky.io) (video chat and meetings), [Twist](https://twistapp.com) (daily check-ins) and [GitHub](https://github.com) (organization wide discussions). + +One-on-ones and bi-weekly company-wide updates are a crucial part of staying connected and understanding our team as things change. We encourage employees to use these meetings to bring up frustrations, ideas, or whatever they need in order to be their best selves and to do their best work. + +At least once a year we organize an in-person all-hands team week. It’s the best. + +## Region +&yet has one office located in Richland, WA. Currently ten people are working remotely out of Seattle, Portland, Folsom, Phoenix, Denver, Kansas City, Frankfurt, Oslo, and Melbourne. The most significant timezone difference is 17 hours. + +## Company technologies +* Node.js +* React +* WebRTC +* Pug +* Stylus + +## Office locations +[Fuse Coworking in Richland, WA](https://goo.gl/maps/oJaAQFf12tv) + +## How to apply +No current openings. diff --git a/test/fixtures/valid-incomplete/parsed-content/readme.html b/test/fixtures/valid-incomplete/parsed-content/readme.html index 2294b23e..e387b72d 100644 --- a/test/fixtures/valid-incomplete/parsed-content/readme.html +++ b/test/fixtures/valid-incomplete/parsed-content/readme.html @@ -12,37 +12,37 @@ website links.

Region - + &yet andyet.com Worldwide - + 10up 10up.com Worldwide - + 17hats 17hats.com Worldwide - + 18F 18f.gsa.gov USA - + 45royale45royale.com - + Aerolabaerolab.co - + AngularClasswikihow.com/wikiHow:About-wikiHow PST Timezone diff --git a/test/lib/index.js b/test/lib/index.js index 0f88ef99..1d66c3d0 100644 --- a/test/lib/index.js +++ b/test/lib/index.js @@ -54,9 +54,7 @@ exports.runValidationScriptWithFixtures = ( dirName, env = {} ) => { const output = result.stdout.toString().trim().split( '\n' ); const exitCode = result.status; - expect( output[ output.length - 1 ] ).to.equal( - exitCode + ' problem' + ( exitCode === 1 ? '' : 's' ) + ' detected' - ); + let errorSummary = output[ output.length - 1 ]; if ( output.length >= 2 ) { expect( output[ output.length - 2 ] ).to.equal( '' ); output.splice( -2 ); @@ -70,5 +68,5 @@ exports.runValidationScriptWithFixtures = ( dirName, env = {} ) => { } ); } - return { output, exitCode }; + return { output, errorSummary, exitCode }; }; diff --git a/test/validation-errors.js b/test/validation-errors.js index 843a9b60..9a589557 100644 --- a/test/validation-errors.js +++ b/test/validation-errors.js @@ -39,6 +39,15 @@ describe( 'validation errors', () => { } ); } ); + it( 'should catch duplicate company names', () => { + expectValidateFixturesResult( 'duplicate-company', { + errorCount: 1, + output: [ + 'README.md: Duplicate company: &Yet', + ] + } ); + } ); + it( 'should catch unsorted company names', () => { expectValidateFixturesResult( 'unsorted', { errorCount: 2, diff --git a/test/validation-script.js b/test/validation-script.js index 875eb900..62fc0714 100644 --- a/test/validation-script.js +++ b/test/validation-script.js @@ -6,6 +6,7 @@ describe( 'validation script (integration tests)', () => { it( 'should pass with valid data', () => { expect( runValidationScriptWithFixtures( 'valid' ) ).to.eql( { exitCode: 0, + errorSummary: '0 problems detected', output: [], } ); } ); @@ -14,6 +15,7 @@ describe( 'validation script (integration tests)', () => { const env = { REPORT_PROFILE_HEADINGS: 'y' }; expect( runValidationScriptWithFixtures( 'valid-incomplete', env ) ).to.eql( { exitCode: 0, + errorSummary: '0 problems detected', output: [ 'Profile headings by count (7 total profiles):', 'Company blurb: 7', @@ -30,7 +32,8 @@ describe( 'validation script (integration tests)', () => { it( 'should catch unsorted company names, and count headings', () => { const env = { REPORT_PROFILE_HEADINGS: 'y' }; expect( runValidationScriptWithFixtures( 'unsorted', env ) ).to.eql( { - exitCode: 2, + exitCode: 3, + errorSummary: '2 problems detected', output: [ 'README.md: Company is listed out of order: "17hats" (should be before "18F")', 'README.md: Company is listed out of order: "&yet" (should be before "17hats")',