2018-06-25 05:00:12 +00:00
#!/usr/bin/env node
const fs = require ( 'fs' ) ;
const path = require ( 'path' ) ;
const util = require ( 'util' ) ;
const cheerio = require ( 'cheerio' ) ;
2020-05-06 06:42:21 +00:00
const lunr = require ( 'lunr' ) ;
2018-06-25 05:00:12 +00:00
const marked = require ( 'marked' ) ;
/ * *
* Constants
* /
const headingsRequired = [
'Company blurb' ,
] ;
const headingsOptional = [
'Company size' ,
'Remote status' ,
'Region' ,
'Company technologies' ,
'Office locations' ,
'How to apply' ,
] ;
2018-06-25 07:16:13 +00:00
const headingsAll = headingsRequired . concat ( headingsOptional ) ;
2018-06-25 05:00:12 +00:00
/ * *
* Utility functions
* /
function companyNameToProfileFilename ( companyName ) {
return companyName . toLowerCase ( )
. replace ( /&/g , ' and ' )
. replace ( /'/g , '' )
. replace ( /[^a-z0-9]+/gi , '-' )
. replace ( /^-|-$/g , '' ) ;
}
exports . companyNameToProfileFilename = companyNameToProfileFilename ;
// adapted from https://gist.github.com/RandomEtc/2657669
function jsonStringifyUnicodeEscaped ( obj ) {
return JSON . stringify ( obj ) . replace ( /[\u007f-\uffff]/g , c => {
return '\\u' + ( '0000' + c . charCodeAt ( 0 ) . toString ( 16 ) ) . slice ( - 4 ) ;
} ) ;
}
exports . jsonStringifyUnicodeEscaped = jsonStringifyUnicodeEscaped ;
2018-06-25 07:16:13 +00:00
function toIdentifierCase ( text ) {
return text
. replace ( /'/g , '' )
. replace ( /[^a-z0-9]+/gi , ' ' )
. trim ( )
. split ( /\s+/ )
. map ( ( word , i ) => {
if ( i === 0 ) {
return word . toLowerCase ( ) ;
}
return (
word . substr ( 0 , 1 ) . toUpperCase ( )
+ word . substr ( 1 ) . toLowerCase ( )
) ;
} )
. join ( '' ) ;
}
exports . toIdentifierCase = toIdentifierCase ;
function stripExtraChars ( text ) {
return text . replace ( /\ufe0f/g , '' ) ;
}
exports . stripExtraChars = stripExtraChars ;
2018-06-25 05:00:12 +00:00
2018-08-12 01:41:11 +00:00
/ * *
* Other exports
* /
2020-05-06 06:42:21 +00:00
function getHeadingPropertyNames ( ) {
return headingsAll . reduce ( ( acc , val ) => {
acc [ toIdentifierCase ( val ) ] = val ;
return acc ;
} , { } ) ;
}
exports . headingPropertyNames = getHeadingPropertyNames ( ) ;
2018-08-12 01:41:11 +00:00
2018-06-25 05:00:12 +00:00
/ * *
* The main exported function
*
* Start with a directory including a README . md and company - profiles / * . md
* files , and validate and parse the content of the Markdown files .
* /
exports . parseFromDirectory = contentPath => {
2020-05-06 06:42:21 +00:00
const companyNamesSeen = { } ;
2018-06-25 05:00:12 +00:00
let errors = [ ] ;
function error ( filename , msg , ... params ) {
errors . push ( {
filename ,
message : util . format ( msg , ... params ) ,
} ) ;
}
// Build list of Markdown files containing company profiles.
const profilesPath = path . join ( contentPath , 'company-profiles' ) ;
const profileFilenames = fs . readdirSync ( profilesPath ) ;
// Scan the company table in the readme.
const readmeCompanies = [ ] ;
2018-06-25 07:16:13 +00:00
const readmeMarkdown = stripExtraChars ( fs . readFileSync (
2018-06-25 05:00:12 +00:00
path . join ( contentPath , 'README.md' ) ,
'utf8'
2018-06-25 07:16:13 +00:00
) ) ;
2018-06-25 05:00:12 +00:00
2019-11-01 03:37:35 +00:00
let inTable = false ;
readmeMarkdown . split ( '\n' ) . forEach ( line => {
if ( /^\s*-+\s*\|\s*-+\s*\|\s*-+\s*$/ . test ( line ) ) {
inTable = true ;
} else if ( /^\s*$/ . test ( line ) ) {
inTable = false ;
} else if ( inTable ) {
const fields = line . split ( '|' ) ;
if ( fields . length !== 3 ) {
readmeError (
'Expected 3 table cells but found %d: %s' ,
fields . length ,
line
) ;
}
}
} ) ;
2018-06-25 05:00:12 +00:00
const $ = cheerio . load ( marked ( readmeMarkdown ) ) ;
function readmeError ( msg , ... params ) {
error ( 'README.md' , msg , ... params ) ;
}
2020-10-29 22:45:48 +00:00
const mainUrl = 'remoteintech.company'
function addTargetBlankAndExternalLinkIcons ( el ) {
if ( el . type === 'tag' ) {
const anchorTagElements = el . children . filter ( element => element . name === 'a' )
if ( anchorTagElements . length > 0 ) {
anchorTagElements . forEach ( element => {
const url = element . attribs . href
const urlInfo = getUrlInfo ( url )
if ( urlInfo . is _email || urlInfo . is _internal ) {
return
}
element . attribs . target = '_blank'
$element = $ ( element )
$element . append ( '<span style="vertical-align: text-top;"> <img src="/assets/external-link.svg" /> </span>' )
} )
}
if ( el . children && el . children . length ) {
el . children . forEach ( element => {
addTargetBlankAndExternalLinkIcons ( element )
} )
}
}
}
/ * *
* Getting info about the url . It includes checking isEmail of isInternal
* @ param { * } url
* /
function getUrlInfo ( url ) {
const data = { }
if ( url . match ( /^mailto:/ ) ) { // checking url email or not
data . is _email = true
return data
}
const mainDomainFromGivenUrl = extractMainDomainFromUrl ( url )
// checking url is email or not
if ( mainDomainFromGivenUrl !== mainUrl ) {
data . is _internal = false
return data
} else {
data . is _internal = true
}
return data
}
/ * *
* Extracting main domain from the url
* @ param { * } url
* /
function extractMainDomainFromUrl ( url ) {
const domainRe = /(https?:\/\/){0,1}((?:[\w\d-]+\.)+[\w\d]{2,})/i ; // taken example from https://stackoverflow.com/questions/6238351/fastest-way-to-detect-external-urls
const data = domainRe . exec ( url )
const splittedDomain = data [ 2 ] . split ( '.' )
if ( splittedDomain . length === 2 ) { // check extra subdomain is present or not
return data [ 2 ]
}
return splittedDomain [ splittedDomain . length - 2 ] + '.' + splittedDomain [ splittedDomain . length - 1 ] // return only main domain address
}
2018-06-25 05:00:12 +00:00
let lastCompanyName = null ;
$ ( 'tr' ) . each ( ( i , tr ) => {
2020-05-06 06:42:21 +00:00
const $tr = $ ( tr ) ;
2018-06-25 05:00:12 +00:00
if ( i === 0 ) {
2018-08-12 01:41:11 +00:00
// Assign an ID to the table.
2020-05-06 06:42:21 +00:00
$tr . closest ( 'table' ) . attr ( 'id' , 'companies-table' ) ;
2018-06-25 05:00:12 +00:00
// Skip the table header row.
return ;
}
2020-05-06 06:42:21 +00:00
const $td = $tr . children ( 'td' ) ;
2018-06-25 05:00:12 +00:00
2018-08-12 01:41:11 +00:00
const websiteUrl = $td . eq ( 1 ) . text ( ) ;
const websiteText = websiteUrl
. replace ( /^https?:\/\// , '' )
. replace ( /^www\./ , '' )
. replace ( /\/$/ , '' ) ;
2018-06-25 05:00:12 +00:00
const readmeEntry = {
2018-06-25 07:16:13 +00:00
// Strip out warning emoji indicating that this profile is incomplete
name : $td . eq ( 0 ) . text ( ) . replace ( /\u26a0/ , '' ) . trim ( ) ,
2018-06-25 05:00:12 +00:00
// Detect warning emoji next to company name
isIncomplete : /\u26a0/ . test ( $td . eq ( 0 ) . text ( ) ) ,
2018-08-12 01:41:11 +00:00
websiteUrl ,
websiteText ,
shortRegion : $td . eq ( 2 ) . text ( ) . trim ( ) ,
2018-06-25 05:00:12 +00:00
} ;
2020-05-06 06:42:21 +00:00
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 {
2018-06-25 05:00:12 +00:00
readmeError (
'Missing company name: %s' ,
2020-05-06 06:42:21 +00:00
$tr . html ( ) . replace ( /\n/g , '' )
2018-06-25 05:00:12 +00:00
) ;
}
2018-07-20 18:30:21 +00:00
if (
$td . eq ( 1 ) . children ( ) . length !== 1 ||
! $td . eq ( 1 ) . children ( ) . eq ( 0 ) . is ( 'a' )
) {
readmeError (
'Invalid content in Website column: %s' ,
2020-05-06 06:42:21 +00:00
$tr . html ( ) . replace ( /\n/g , '' )
2018-07-20 18:30:21 +00:00
) ;
}
if ( $td . eq ( 2 ) . children ( ) . length > 0 ) {
readmeError (
'Extra content in Region column: %s' ,
2020-05-06 06:42:21 +00:00
$tr . html ( ) . replace ( /\n/g , '' )
2018-07-20 18:30:21 +00:00
) ;
}
2018-06-25 05:00:12 +00:00
if (
lastCompanyName &&
readmeEntry . name . toLowerCase ( ) < lastCompanyName . toLowerCase ( )
) {
readmeError (
'Company is listed out of order: "%s" (should be before "%s")' ,
readmeEntry . name ,
lastCompanyName
) ;
}
lastCompanyName = readmeEntry . name ;
const $profileLink = $td . eq ( 0 ) . find ( 'a' ) ;
if ( $profileLink . length === 1 ) {
const match = $profileLink . attr ( 'href' ) . match ( /^\/company-profiles\/(.*\.md)$/ ) ;
if ( match ) {
readmeEntry . linkedFilename = match [ 1 ] ;
if ( profileFilenames . indexOf ( readmeEntry . linkedFilename ) === - 1 ) {
readmeError (
2019-10-14 18:14:56 +00:00
'Missing company profile for "%s", or broken link: "%s"' ,
2018-06-25 05:00:12 +00:00
readmeEntry . name ,
$profileLink . attr ( 'href' )
) ;
}
const nameCheck = $profileLink . text ( ) . trim ( ) ;
if ( nameCheck !== readmeEntry . name ) {
readmeError (
'Extra text in company name: %s, %s' ,
jsonStringifyUnicodeEscaped ( nameCheck ) ,
jsonStringifyUnicodeEscaped ( readmeEntry . name )
) ;
}
} else {
readmeError (
2019-10-14 18:14:56 +00:00
'Invalid link to company profile for "%s": "%s"' ,
2018-06-25 05:00:12 +00:00
readmeEntry . name ,
$profileLink . attr ( 'href' )
) ;
}
} else {
readmeError (
2019-10-14 18:14:56 +00:00
'Company "%s" has no linked Markdown profile ("/company-profiles/%s.md")' ,
2018-06-25 05:00:12 +00:00
readmeEntry . name ,
companyNameToProfileFilename ( readmeEntry . name )
) ;
}
2020-05-06 06:42:21 +00:00
// Set identifying attributes on table elements
$tr
. attr ( 'class' , 'company-row' )
. attr ( 'id' , 'company-row-' + ( i - 1 ) ) ;
2018-08-13 04:12:18 +00:00
$td . eq ( 0 ) . attr ( 'class' , 'company-name' ) ;
$td . eq ( 1 ) . attr ( 'class' , 'company-website' ) ;
$td . eq ( 2 ) . attr ( 'class' , 'company-region' ) ;
2018-08-12 01:41:11 +00:00
// Rewrite company profile link to the correct URL for the static site
if ( $profileLink . length ) {
$profileLink . attr (
'href' ,
$profileLink . attr ( 'href' )
. replace ( /^\/company-profiles\// , '/' )
. replace ( /\.md$/ , '/' )
) ;
}
// Rewrite external website link (target="_blank" etc, shorter text)
const $websiteLink = $td . eq ( 1 ) . children ( ) . eq ( 0 ) ;
$websiteLink
. attr ( 'target' , '_blank' )
. attr ( 'rel' , 'noopener noreferrer' )
. text ( websiteText ) ;
2018-06-25 05:00:12 +00:00
readmeCompanies . push ( readmeEntry ) ;
} ) ;
2018-08-12 01:41:11 +00:00
const readmeContent = $ ( 'body' ) . html ( ) ;
2018-06-25 05:00:12 +00:00
// Scan the individual Markdown files containing the company profiles.
const allProfileHeadings = { } ;
profileFilenames . forEach ( filename => {
function profileError ( msg , ... params ) {
error ( filename , msg , ... params ) ;
}
2018-06-25 07:16:13 +00:00
const profileMarkdown = stripExtraChars ( fs . readFileSync (
2018-06-25 05:00:12 +00:00
path . join ( profilesPath , filename ) ,
'utf8'
2018-06-25 07:16:13 +00:00
) ) ;
2018-06-25 05:00:12 +00:00
const $ = cheerio . load ( marked ( profileMarkdown ) ) ;
let hasTitleError = false ;
if ( $ ( 'h1' ) . length !== 1 ) {
profileError (
'Expected 1 first-level heading but found %d' ,
$ ( 'h1' ) . length
) ;
hasTitleError = true ;
}
if ( ! $ ( 'h1' ) . parent ( ) . is ( 'body' ) ) {
profileError (
'The main title is wrapped inside of another element.'
) ;
}
const companyName = $ ( 'h1' ) . text ( ) ;
if ( ! /[a-z]/i . test ( companyName ) ) {
profileError (
'Company name looks wrong: "%s"' ,
companyName
) ;
hasTitleError = true ;
}
const filenameBase = filename . replace ( /\.md$/ , '' ) ;
const filenameExpected = companyNameToProfileFilename ( companyName ) ;
if (
! hasTitleError &&
filenameBase !== filenameExpected &&
// Some profile files just have shorter names than the company name,
// which is fine.
filenameExpected . substring ( 0 , filenameBase . length + 1 ) !== filenameBase + '-'
) {
profileError (
2019-01-08 01:40:24 +00:00
'Company title "%s" doesn\'t match filename (expected ~ "%s.md")' ,
companyName ,
filenameExpected
2018-06-25 05:00:12 +00:00
) ;
}
const readmeEntry = readmeCompanies . find (
readmeEntry => readmeEntry . linkedFilename === filename
) ;
if ( filename !== 'example.md' && ! readmeEntry ) {
profileError ( 'No link to company profile from readme' ) ;
}
// Build and validate list of headings contained in this Markdown profile.
const profileHeadings = [ ] ;
$ ( 'h2' ) . each ( ( i , el ) => {
const headingName = $ ( el ) . html ( ) ;
if ( ! $ ( el ) . parent ( ) . is ( 'body' ) ) {
profileError (
'The section heading for "%s" is wrapped inside of another element.' ,
headingName
) ;
}
if ( profileHeadings . indexOf ( headingName ) >= 0 ) {
profileError (
'Duplicate section: "%s".' ,
headingName
) ;
} else {
// Track headings for this profile
profileHeadings . push ( headingName ) ;
// Track heading counts across all profiles
if ( ! allProfileHeadings [ headingName ] ) {
allProfileHeadings [ headingName ] = [ ] ;
}
allProfileHeadings [ headingName ] . push ( filename ) ;
}
2018-06-25 07:16:13 +00:00
if ( headingsAll . indexOf ( headingName ) === - 1 ) {
2018-06-25 05:00:12 +00:00
profileError (
'Invalid section: "%s". Expected one of: %s' ,
headingName ,
2018-06-25 07:16:13 +00:00
JSON . stringify ( headingsAll )
2018-06-25 05:00:12 +00:00
) ;
}
} ) ;
headingsRequired . forEach ( headingName => {
if ( profileHeadings . indexOf ( headingName ) === - 1 ) {
profileError (
'Required section "%s" not found.' ,
headingName
) ;
}
} ) ;
// Build and validate the content of each section in this profile.
const profileContent = { } ;
2018-06-25 07:16:13 +00:00
if ( readmeEntry ) {
readmeEntry . profileContent = profileContent ;
}
2018-06-25 05:00:12 +00:00
let currentHeading = null ;
$ ( 'body' ) . children ( ) . each ( ( i , el ) => {
const $el = $ ( el ) ;
if ( $el . is ( 'h1' ) ) {
return ;
}
if ( $el . is ( 'h2' ) ) {
currentHeading = $el . html ( ) ;
profileContent [ currentHeading ] = '' ;
} else if ( currentHeading ) {
2018-06-25 07:16:13 +00:00
// Note: This assumes that the only possible children of the
// 'body' are block-level elements. I think this is correct,
// because from what I've seen, any inline content is wrapped
// in a <p>.
2020-10-29 22:45:48 +00:00
addTargetBlankAndExternalLinkIcons ( el )
2018-06-25 05:00:12 +00:00
profileContent [ currentHeading ] = (
profileContent [ currentHeading ]
2018-06-25 07:16:13 +00:00
+ '\n\n' + $ . html ( el )
2018-06-25 05:00:12 +00:00
) . trim ( ) ;
} else {
profileError (
'Content is not part of any section: %s' ,
$ . html ( el ) . replace ( /\n/g , '' )
) ;
}
} ) ;
Object . keys ( profileContent ) . forEach ( heading => {
const sectionText = profileContent [ heading ]
. replace ( /<[^>]+>/g , '' )
. trim ( ) ;
if ( ! sectionText ) {
profileError (
2018-06-25 07:16:13 +00:00
'Empty section: "%s". Fill it in or leave it out instead.' ,
2018-06-25 05:00:12 +00:00
heading
) ;
}
} ) ;
2018-06-25 07:16:13 +00:00
// Rewrite profile content to use more code-friendly heading names.
Object . keys ( profileContent ) . forEach ( headingName => {
const headingIdentifier = toIdentifierCase ( headingName ) ;
profileContent [ headingIdentifier ] = profileContent [ headingName ] ;
delete profileContent [ headingName ] ;
} ) ;
if ( readmeEntry && profileContent . companyBlurb ) {
2018-06-25 05:00:12 +00:00
// Check for company profiles that were filled in, but the "incomplete"
// mark was left in the readme, or vice versa.
const isIncomplete = {
readme : readmeEntry . isIncomplete ,
sections : (
profileHeadings . length === 1 &&
profileHeadings [ 0 ] === 'Company blurb'
) ,
2018-06-25 07:16:13 +00:00
content : /⚠/ . test ( profileContent . companyBlurb ) ,
2018-06-25 05:00:12 +00:00
} ;
const incompleteCount = Object . values ( isIncomplete )
. reduce ( ( sum , v ) => sum + ( v ? 1 : 0 ) , 0 ) ;
// incompleteCount === 0: Profile is incomplete; all 3 indicators are consistent
// incompleteCount === 3: Profile is "complete"; all 3 indicators are consistent
if ( incompleteCount === 1 ) {
if ( isIncomplete . readme ) {
profileError (
'Profile looks complete, but the main readme contains a warning emoji.'
) ;
} else if ( isIncomplete . sections ) {
profileError (
'Profile is marked as complete, but it only contains a "Company blurb" heading.'
)
} else { // isIncomplete.content
profileError (
'Profile looks complete, but the "Company blurb" contains a warning emoji.'
) ;
}
} else if ( incompleteCount === 2 ) {
if ( ! isIncomplete . readme ) {
profileError (
'Profile looks incomplete, but the main readme does not contain a warning emoji.'
) ;
} else if ( ! isIncomplete . sections ) {
profileError (
'Profile is marked as incomplete, but it contains multiple sections.'
+ '\nPlease remove the warning emoji from the "Company blurb" section and the main readme.'
)
} else { // ! isIncomplete.content
profileError (
'Profile looks incomplete, but the "Company blurb" does not contain a warning emoji.'
) ;
}
}
}
} ) ;
const profileHeadingCounts = { } ;
Object . keys ( allProfileHeadings ) . forEach ( heading => {
profileHeadingCounts [ heading ] = allProfileHeadings [ heading ] . length ;
} ) ;
if ( errors . length > 0 ) {
return {
ok : false ,
errors ,
profileFilenames ,
profileHeadingCounts ,
}
}
return {
ok : true ,
profileFilenames ,
profileHeadingCounts ,
2018-06-25 07:16:13 +00:00
companies : readmeCompanies ,
2018-08-12 01:41:11 +00:00
readmeContent ,
2018-06-25 05:00:12 +00:00
} ;
} ;
2020-05-06 06:42:21 +00:00
/ * *
* 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 } ;
} ;