nlm
Version:
Lifecycle manager for node projects
329 lines (287 loc) • 8.96 kB
JavaScript
/*
* Copyright (c) 2015, Groupon, Inc.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
*
* Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* Neither the name of GROUPON nor the names of its contributors may be
* used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
* IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
* TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
* PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
* TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
* LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
;
const debug = require('debug')('nlm:badges');
const debugStep = require('debug')('nlm:step');
const path = require('path');
const fs = require('fs');
/**
* @param {URL} urlObj
* @param {number?} sections
* @returns {string}
*/
function getLastPaths(urlObj, sections = 1) {
let { pathname } = urlObj;
if (pathname.endsWith('/')) {
pathname = pathname.substring(0, pathname.length - 1);
}
return pathname
.split('/')
.slice(-1 * sections)
.join('/');
}
/**
* @param {string} key
* @param {string} value
* @returns {{value: string, key: string, url: string}}
*/
function extractUrl(key, value) {
let url = value;
let urlObject;
try {
urlObject = new URL(value);
} catch (e) {
/**/
}
if (urlObject) {
const { host } = urlObject;
// slack
if (host.includes('slack')) {
key = 'slack';
value = getLastPaths(urlObject, 1);
} else if (host.includes('jira')) {
key = 'jira';
value = getLastPaths(urlObject, 1);
} else if (host.includes('github')) {
key = 'github';
value = getLastPaths(urlObject, 3);
}
} else if (/@/.test(value) && !value.startsWith('mailto:')) {
url = `mailto:${value}`;
}
return { key, value, url };
}
/**
* @param {string} str
* @returns {string}
*/
function encode(str) {
return encodeURIComponent(str).replace(/-/g, '--');
}
function getLogoId(label) {
switch (label) {
case 'node':
return 'node.js';
default:
return label;
}
}
/**
* @param {string} label
* @param {string} message
* @param {string} color
* @return {string}
*/
function shieldsBadge(label, message, color) {
return `}-${encode(message)}-${color}?logo=${getLogoId(label)}&logoColor=white)`;
}
/**
* @param {packageJson} pkg
* @param {{nlmOptions?: { badges?: {npm : {enable?: boolean, color?: string}}}}} options
* @returns {Map<any, any>}
*/
function npmBadges(pkg, options) {
const { nlmOptions = {} } = options;
const opts = {
color: 'blue',
enable: true,
...((nlmOptions.badges && nlmOptions.badges.npm) || {}),
};
const badges = new Map();
if (!opts.enable) {
return badges;
}
debug('version', pkg.version);
debug('next version', options.nextVersion);
const version = options.nextVersion || pkg.version;
return Object.entries({
...(version && { version }),
...pkg.engines,
}).reduce((acc, [key, value]) => {
acc.set(key, shieldsBadge(key, value, opts.color));
return acc;
}, badges);
}
/**
* @param {{bugs: Record<string, string>}} pkg
* @param {{nlmOptions?: { badges?: {meta: {color?: string, enable?: boolean, url?: boolean}}}}} options
* @returns {Map<any, any>}
*/
function metaInfoBadges(pkg, options) {
const { nlmOptions = {} } = options;
const badges = new Map();
const opts = {
color: 'F4D03F',
enable: true,
url: true,
...((nlmOptions.badges && nlmOptions.badges.meta) || {}),
};
return opts.enable && pkg.bugs
? Object.entries(pkg.bugs).reduce((acc, [key, value]) => {
const data = extractUrl(key, value);
const badge = shieldsBadge(data.key, data.value, opts.color);
const finalBadge = opts.url ? `[${badge}](${data.url})` : badge;
acc.set(data.key, finalBadge);
return acc;
}, badges)
: badges;
}
/**
* @param {number} percentage
* @param {[number, string][]} thresholds
* @returns {string}
*/
function getCoverageColor(percentage, thresholds) {
const sorted = thresholds.sort(([old_pct], [new_pct]) => {
if (Number(old_pct) < Number(new_pct)) return +1;
if (Number(old_pct) > Number(new_pct)) return -1;
return 0;
});
let selectedColor;
for (const [pct, color] of sorted) {
if (percentage <= pct) {
selectedColor = color;
} else {
break;
}
}
return selectedColor;
}
/**
* @param {packageJson} pkg
* @param {{nlmOptions?: { badges?: {coverage: {enable?: boolean, thresholds?: [number, string][]}}}}} options
* @param {string} cwd
* @returns {Map<any, any>}
*/
function codeCoverageBadges(pkg, options, cwd) {
const { nlmOptions = {} } = options;
const badges = new Map();
const coverageFile = path.join(cwd, 'coverage/coverage-summary.json');
const opts = {
thresholds: [
[95, 'success'],
[90, 'green'],
[75, 'yellow'],
[50, 'critical'],
],
enable: true,
...((nlmOptions.badges && nlmOptions.badges.coverage) || {}),
};
if (opts.enable && fs.existsSync(coverageFile)) {
let result;
try {
result = JSON.parse(fs.readFileSync(coverageFile, 'utf-8'));
const { pct } = result.total.lines;
if (pct == null) {
return badges;
}
const color = getCoverageColor(pct, opts.thresholds);
badges.set('coverage', shieldsBadge('coverage', `${pct}%`, color));
} catch (e) {
/**/
}
}
return badges;
}
/**
* @param {string?} key
* @returns {RegExp}
*/
function getShieldRegexp(key) {
// /[\[ !]*\[nlm-[\w\s-_s]+]\(https:\/\/img\.shields\.io\/badge\/[\w:\/\-@.%=]+-[\w\S]+-\w+\?[\w\-_.&=]+\)]?(\([\w:\/\-@.%=]+\))?/
const id = /[\w\s-_s]+/;
const shieldsUri = /https:\/\/img\.shields\.io\/badge/;
const message = /[\w\S]+/;
const color = /\w+/;
const encoded = /[\w:\/\-@.%=]+/;
const url = /[\w:\/\-@.%=]+/;
const logo = /[?\w\-_.&=]+/;
const label = key ? encode(key) : encoded.source;
return new RegExp(
`[\\[ !]*\\[nlm-${id.source}]\\(${shieldsUri.source}\\/${label}-${message.source}-${color.source}${logo.source}\\)]?(\\(${url.source}\\))?`,
'g'
);
}
async function generateBadges(cwd, pkg, options) {
debugStep('generate badges');
const { nlmOptions = {} } = options;
debug('badges options', nlmOptions.badges);
if (nlmOptions.badges && nlmOptions.badges.enable === 'false') {
return null;
}
let content;
const readmePath = path.join(cwd, 'README.md');
try {
content = fs.readFileSync(readmePath, 'utf-8');
} catch (e) {
content = '';
}
const STARTS_WITH_IMAGE_REGEXP =
/^[\[ ]?!\[[\w\s-_s]+]\([\w:\/\-.%=]+[?\w\-_.&=]?\)]?(\([\w:\/\-.%=@]+\))?/;
const LAST_BADGES_REGEXP = new RegExp(
`${STARTS_WITH_IMAGE_REGEXP.source}([\\s\\n]*${getShieldRegexp().source})*`,
'g'
);
const badges = new Map([
...npmBadges(pkg, options),
...codeCoverageBadges(pkg, options, cwd),
...metaInfoBadges(pkg, options),
]);
for (const [id, badge] of badges) {
const shieldRegexp = getShieldRegexp(id);
// check 1 - badge doesn't exist
if (!content.includes(badge)) {
// check 2 - badge is marked with `nlm-` and from shields.io
if (shieldRegexp.test(content)) {
// update badge
content = content.replace(shieldRegexp, badge);
} else {
// check 3 - readme starts with an image badge
if (STARTS_WITH_IMAGE_REGEXP.test(content)) {
const regexp = LAST_BADGES_REGEXP.test(content)
? LAST_BADGES_REGEXP
: STARTS_WITH_IMAGE_REGEXP;
// inject badge after previous badge
content = content.replace(regexp, `$&\n${badge}`);
} else {
// prepend badge
content = `${badge}\n${content}`;
}
}
}
}
fs.writeFileSync(readmePath, content);
return content;
}
module.exports = { generateBadges };