mirror of
https://github.com/remoteintech/remote-jobs
synced 2024-12-26 12:23:09 +00:00
334 lines
6.8 KiB
JavaScript
Executable file
334 lines
6.8 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
|
|
|
const fs = require( 'fs' );
|
|
const path = require( 'path' );
|
|
|
|
const cheerio = require( 'cheerio' );
|
|
const marked = require( 'marked' );
|
|
|
|
let errorCount = 0;
|
|
|
|
|
|
/**
|
|
* Accept an optional directory name where the content files live.
|
|
*/
|
|
const contentPath = (
|
|
process.argv[ 2 ]
|
|
? path.resolve( process.argv[ 2 ] )
|
|
: path.join( __dirname, '..' )
|
|
);
|
|
|
|
|
|
/**
|
|
* Define the heading names expected in company profiles.
|
|
*/
|
|
const headingsRequired = [
|
|
'Company blurb',
|
|
];
|
|
const headingsOptional = [
|
|
'Company size',
|
|
'Remote status',
|
|
'Region',
|
|
'Company technologies',
|
|
'Office locations',
|
|
'How to apply',
|
|
];
|
|
|
|
|
|
/**
|
|
* Utility functions
|
|
*/
|
|
|
|
function companyNameToProfileFilename( companyName ) {
|
|
return companyName.toLowerCase()
|
|
.replace( /&/g, ' and ' )
|
|
.replace( /'/g, '' )
|
|
.replace( /[^a-z0-9]+/gi, '-' )
|
|
.replace( /^-|-$/g, '' );
|
|
}
|
|
|
|
|
|
/**
|
|
* 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 = [];
|
|
|
|
const readmeMarkdown = fs.readFileSync(
|
|
path.join( contentPath, 'README.md' ),
|
|
'utf8'
|
|
);
|
|
|
|
const $ = cheerio.load( marked( readmeMarkdown ) );
|
|
|
|
function readmeError( msg, ...params ) {
|
|
errorCount++;
|
|
console.log(
|
|
'README.md: ' + msg,
|
|
...params
|
|
);
|
|
}
|
|
|
|
let lastCompanyName = null;
|
|
|
|
$( 'tr' ).each( ( i, tr ) => {
|
|
if ( i === 0 ) {
|
|
// Skip the table header row.
|
|
return;
|
|
}
|
|
const $td = $( tr ).children( 'td' );
|
|
if ( $td.length !== 3 ) {
|
|
readmeError(
|
|
'Expected 3 table cells but found %d: %s',
|
|
$td.length,
|
|
$( tr ).html().replace( /\n/g, '' )
|
|
);
|
|
}
|
|
|
|
const entry = {
|
|
name: $td.eq( 0 ).text().replace( '\u26a0', '' ).trim(),
|
|
website: $td.eq( 1 ).text(),
|
|
shortRegion: $td.eq( 2 ).text(),
|
|
};
|
|
|
|
if ( ! entry.name ) {
|
|
readmeError(
|
|
'Missing company name: %s',
|
|
$( tr ).html().replace( /\n/g, '' )
|
|
);
|
|
}
|
|
|
|
if (
|
|
lastCompanyName &&
|
|
entry.name.toLowerCase() < lastCompanyName.toLowerCase()
|
|
) {
|
|
readmeError(
|
|
'Company is listed out of order: "%s" (should be before "%s")',
|
|
entry.name,
|
|
lastCompanyName
|
|
);
|
|
}
|
|
lastCompanyName = entry.name;
|
|
|
|
const profileLink = $td.eq( 0 ).find( 'a' ).attr( 'href' );
|
|
|
|
if ( profileLink ) {
|
|
const match = profileLink.match( /^\/company-profiles\/(.*\.md)$/ );
|
|
|
|
if ( match ) {
|
|
entry.linkedFilename = match[ 1 ];
|
|
if ( profileFilenames.indexOf( entry.linkedFilename ) === -1 ) {
|
|
readmeError(
|
|
'Broken link to company "%s": "%s"',
|
|
entry.name,
|
|
profileLink
|
|
);
|
|
}
|
|
} else {
|
|
readmeError(
|
|
'Invalid link to company "%s": "%s"',
|
|
entry.name,
|
|
profileLink
|
|
);
|
|
}
|
|
} else {
|
|
readmeError(
|
|
'Company "%s" has no linked Markdown profile ("%s.md")',
|
|
entry.name,
|
|
companyNameToProfileFilename( entry.name )
|
|
);
|
|
}
|
|
|
|
readmeCompanies.push( entry );
|
|
} );
|
|
|
|
|
|
/**
|
|
* Scan the individual Markdown files containing the company profiles.
|
|
*/
|
|
|
|
const allProfileHeadings = {};
|
|
|
|
profileFilenames.forEach( filename => {
|
|
function error( msg, ...params ) {
|
|
errorCount++;
|
|
console.log(
|
|
filename + ': ' + msg,
|
|
...params
|
|
);
|
|
}
|
|
|
|
const profileMarkdown = fs.readFileSync(
|
|
path.join( profilesPath, filename ),
|
|
'utf8'
|
|
);
|
|
const $ = cheerio.load( marked( profileMarkdown ) );
|
|
|
|
let hasTitleError = false;
|
|
|
|
if ( $( 'h1' ).length !== 1 ) {
|
|
error(
|
|
'Expected 1 first-level heading but found %d',
|
|
$( 'h1' ).length
|
|
);
|
|
hasTitleError = true;
|
|
}
|
|
|
|
if ( ! $( 'h1' ).parent().is( 'body' ) ) {
|
|
error(
|
|
'The main title is wrapped inside of another element.'
|
|
);
|
|
}
|
|
|
|
const companyName = $( 'h1' ).text();
|
|
|
|
if ( ! /[a-z]/i.test( companyName ) ) {
|
|
error(
|
|
'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 + '-'
|
|
) {
|
|
error(
|
|
'Expected filename "%s.md" for company "%s"',
|
|
filenameExpected,
|
|
companyName
|
|
);
|
|
}
|
|
|
|
if (
|
|
filename !== 'example.md' &&
|
|
! readmeCompanies.some( entry => entry.linkedFilename === filename )
|
|
) {
|
|
error( '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' ) ) {
|
|
error(
|
|
'The section heading for "%s" is wrapped inside of another element.',
|
|
headingName
|
|
);
|
|
}
|
|
|
|
if ( profileHeadings.indexOf( headingName ) >= 0 ) {
|
|
error(
|
|
'Duplicate section: "%s".',
|
|
headingName
|
|
);
|
|
}
|
|
|
|
if (
|
|
headingsRequired.indexOf( headingName ) === -1 &&
|
|
headingsOptional.indexOf( headingName ) === -1
|
|
) {
|
|
error(
|
|
'Invalid section: "%s". Expected one of: %s',
|
|
headingName,
|
|
JSON.stringify( headingsRequired.concat( headingsOptional ) )
|
|
);
|
|
}
|
|
|
|
// Track headings for this profile
|
|
profileHeadings.push( headingName );
|
|
|
|
// Track headings across all profiles
|
|
if ( ! allProfileHeadings[ headingName ] ) {
|
|
allProfileHeadings[ headingName ] = [];
|
|
}
|
|
allProfileHeadings[ headingName ].push( filename );
|
|
} );
|
|
|
|
headingsRequired.forEach( headingName => {
|
|
if ( profileHeadings.indexOf( headingName ) === -1 ) {
|
|
error(
|
|
'Required section "%s" not found.',
|
|
headingName
|
|
);
|
|
}
|
|
} );
|
|
|
|
// Build and validate the content of each section in this profile.
|
|
|
|
const profileContent = {};
|
|
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 ) {
|
|
profileContent[ currentHeading ] = (
|
|
profileContent[ currentHeading ]
|
|
+ '\n' + $.html( el )
|
|
).trim();
|
|
} else {
|
|
error(
|
|
'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 ) {
|
|
error(
|
|
'Empty section: "%s". Leave it out instead.',
|
|
heading
|
|
);
|
|
}
|
|
} );
|
|
} );
|
|
|
|
if ( process.env.REPORT_PROFILE_HEADINGS ) {
|
|
console.log();
|
|
console.log(
|
|
'Profile headings by count (%d total profiles):',
|
|
profileFilenames.length
|
|
);
|
|
Object.keys( allProfileHeadings ).forEach( heading => {
|
|
console.log( '%s: %d', heading, allProfileHeadings[ heading ].length )
|
|
} );
|
|
}
|
|
|
|
console.log();
|
|
console.log(
|
|
'%d problem%s detected',
|
|
errorCount,
|
|
( errorCount === 1 ? '' : 's' )
|
|
);
|
|
|
|
process.exitCode = Math.min( errorCount, 99 );
|