Skip to content

Commit

Permalink
Add [npm] type definitions badge (#1541)
Browse files Browse the repository at this point in the history
Let a package show off its type definitions based on devDependency data in the published npm package.

Close #1252
  • Loading branch information
paulmelnikow committed Mar 12, 2018
1 parent 2d65153 commit 6750115
Show file tree
Hide file tree
Showing 7 changed files with 185 additions and 30 deletions.
9 changes: 9 additions & 0 deletions lib/all-badge-examples.js
Original file line number Diff line number Diff line change
Expand Up @@ -840,6 +840,15 @@ const allBadgeExamples = [
'node'
]
},
{
title: 'npm type definitions',
previewUri: '/npm/types/chalk.svg',
keywords: [
'node',
'typescript',
'flow'
]
},
{
title: 'PyPI',
previewUri: '/pypi/v/nine.svg',
Expand Down
39 changes: 39 additions & 0 deletions lib/npm-badge-helpers.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,46 @@
'use strict';

const { rangeStart, minor } = require('./version');

const defaultNpmRegistryUri = 'https://registry.npmjs.org';

function makePackageDataUrl({ registryUrl, scope, packageName }) {
registryUrl = registryUrl || defaultNpmRegistryUri;
if (scope === undefined) {
// e.g. https://registry.npmjs.org/express/latest
// Use this endpoint as an optimization. It covers the vast majority of
// these badges, and the response is smaller.
return `${registryUrl}/${packageName}/latest`;
} else {
// e.g. https://registry.npmjs.org/@cedx%2Fgulp-david
// because https://registry.npmjs.org/@cedx%2Fgulp-david/latest does not work
const path = encodeURIComponent(`${scope}/${packageName}`);
return `${registryUrl}/@${path}`;
}
}

function typeDefinitions(packageData) {
const { devDependencies } = packageData;

const supportedLanguages = [
{ name: 'TypeScript', range: devDependencies.typescript },
{ name: 'Flow', range: devDependencies['flow-bin'] },
]
.filter(lang => lang.range !== undefined)
.map(({ name, range }) => {
const version = minor(rangeStart(range));
return `${name} v${version}`;
});

if (supportedLanguages.length > 0) {
return supportedLanguages.join(' | ');
} else {
return 'none';
}
}

module.exports = {
defaultNpmRegistryUri,
makePackageDataUrl,
typeDefinitions,
};
10 changes: 10 additions & 0 deletions lib/npm-badge-helpers.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
'use strict';

const { test, given } = require('sazerac');
const { typeDefinitions } = require('./npm-badge-helpers');

describe('NPM badge helpers', function () {
test(typeDefinitions, () => {
given({ devDependencies: { typescript: '^2.4.7' } }).expect('TypeScript v2.4');
});
});
49 changes: 47 additions & 2 deletions lib/version.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ function latest(versions) {
}
return version;
}
exports.latest = latest;

function listCompare(a, b) {
const alen = a.length, blen = b.length;
Expand All @@ -41,7 +40,6 @@ function listCompare(a, b) {
}
return alen - blen;
}
exports.listCompare = listCompare;

// === Private helper functions ===

Expand Down Expand Up @@ -81,3 +79,50 @@ function compareDottedVersion(v1, v2) {
}
return v1 < v2? -1: v1 > v2? 1: 0;
}

// Slice the specified number of dotted parts from the given semver version.
// e.g. slice('2.4.7', 'minor') -> '2.4'
function slice(v, releaseType) {
if (! semver.valid(v)) {
return null;
}

const major = semver.major(v);
const minor = semver.minor(v);
const patch = semver.patch(v);
const prerelease = semver.prerelease(v);

const dottedParts = {
major: [major],
minor: [major, minor],
patch: [major, minor, patch],
}[releaseType];

if (dottedParts === undefined) {
throw Error(`Unknown releaseType: ${releaseType}`);
}

const dotted = dottedParts.join('.');
if (prerelease) {
return `${dotted}-${prerelease.join('.')}`;
} else {
return dotted;
}
}

function minor(v) {
return slice(v, 'minor');
}

function rangeStart(v) {
const range = new semver.Range(v);
return range.set[0][0].semver.version;
}

module.exports = {
latest,
listCompare,
slice,
minor,
rangeStart,
};
15 changes: 14 additions & 1 deletion lib/version.spec.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use strict';

const { test, given } = require('sazerac');
const {latest} = require('./version');
const { latest, slice, rangeStart } = require('./version');

describe('Version helpers', function () {
test(latest, () => {
Expand Down Expand Up @@ -31,4 +31,17 @@ describe('Version helpers', function () {
// Simple (one-number) versions
given(['2', '10', '1']).expect('10');
});

test(slice, () => {
given('2.4.7', 'major').expect('2');
given('2.4.7', 'minor').expect('2.4');
given('2.4.7', 'patch').expect('2.4.7');
given('2.4.7-alpha.1', 'major').expect('2-alpha.1');
given('2.4.7-alpha.1', 'minor').expect('2.4-alpha.1');
given('2.4.7-alpha.1', 'patch').expect('2.4.7-alpha.1');
});

test(rangeStart, () => {
given('^2.4.7').expect('2.4.7');
});
});
83 changes: 56 additions & 27 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ const {
mapNpmDownloads
} = require('./lib/npm-provider');
const {
defaultNpmRegistryUri
defaultNpmRegistryUri,
makePackageDataUrl: makeNpmPackageDataUrl,
typeDefinitions: npmTypeDefinitions,
} = require('./lib/npm-badge-helpers');
const {
teamcityBadge
Expand Down Expand Up @@ -1719,19 +1721,11 @@ cache({
handler: function(queryParams, match, sendBadge, request) {
// e.g. cycle, core, svg
const [, scope, packageName, format ] = match;
const registryUri = queryParams.registry_uri || defaultNpmRegistryUri;
let apiUrl;
if (scope === undefined) {
// e.g. https://registry.npmjs.org/express/latest
// Use this endpoint as an optimization. It covers the vast majority of
// these badges, and the response is smaller.
apiUrl = `${registryUri}/${packageName}/latest`;
} else {
// e.g. https://registry.npmjs.org/@cedx%2Fgulp-david
// because https://registry.npmjs.org/@cedx%2Fgulp-david/latest does not work
const path = encodeURIComponent(`${scope}/${packageName}`);
apiUrl = `${registryUri}/@${path}`;
}
const apiUrl = makeNpmPackageDataUrl({
registryUrl: queryParams.registry_uri,
scope,
packageName,
});
const badgeData = getBadgeData('license', queryParams);
request(apiUrl, { headers: { 'Accept': '*/*' } }, function(err, res, buffer) {
if (err != null) {
Expand Down Expand Up @@ -1781,20 +1775,12 @@ cache({
handler: function(queryParams, match, sendBadge, request) {
// e.g. @stdlib, stdlib, next, svg
const [, scope, packageName, tag, format] = match;
const registryUri = queryParams.registry_uri || defaultNpmRegistryUri;
const apiUrl = makeNpmPackageDataUrl({
registryUrl: queryParams.registry_uri,
scope,
packageName,
});
const registryTag = tag || 'latest';
let apiUrl;
if (scope === undefined) {
// e.g. https://registry.npmjs.org/express/latest
// Use this endpoint as an optimization. It covers the vast majority of
// these badges, and the response is smaller.
apiUrl = `${registryUri}/${packageName}/${registryTag}`;
} else {
// e.g. https://registry.npmjs.org/@cedx%2Fgulp-david
// because https://registry.npmjs.org/@cedx%2Fgulp-david/latest does not work
const path = encodeURIComponent(`${scope}/${packageName}`);
apiUrl = `${registryUri}/@${path}`;
}
const name = tag ? `node@${tag}` : 'node';
const badgeData = getBadgeData(name, queryParams);
// Using the Accept header because of this bug:
Expand Down Expand Up @@ -1863,6 +1849,49 @@ cache({
}
}));

// npm type definition integration.
camp.route(/^\/npm\/types\/(?:@([^/]+)\/)?([^/]+)\.(svg|png|gif|jpg|json)$/,
cache({
queryParams: ['registry_uri'],
handler: (queryParams, match, sendBadge, request) => {
// e.g. cycle, core, svg
const [, scope, packageName, format ] = match;
const apiUrl = makeNpmPackageDataUrl({
registryUrl: queryParams.registry_uri,
scope,
packageName,
});
const badgeData = getBadgeData('type definitions', queryParams);
request(apiUrl, { headers: { 'Accept': '*/*' } }, function(err, res, buffer) {
if (checkErrorResponse(badgeData, err, res, 'package not found')) {
sendBadge(format, badgeData);
return;
}
try {
const data = JSON.parse(buffer);
let packageData;
if (scope === undefined) {
packageData = data;
} else {
const latestVersion = data['dist-tags'].latest;
packageData = data.versions[latestVersion];
}
const typeDefinitions = npmTypeDefinitions(packageData);
if (typeDefinitions === 'none') {
badgeData.colorscheme = 'lightgray';
} else {
badgeData.colorscheme = 'blue';
}
badgeData.text[1] = typeDefinitions;
sendBadge(format, badgeData);
} catch(e) {
badgeData.text[1] = 'invalid';
sendBadge(format, badgeData);
}
});
}
}));

// Anaconda Cloud / conda package manager integration
camp.route(/^\/conda\/([dvp]n?)\/([^/]+)\/([^/]+)\.(svg|png|gif|jpg|json)$/,
cache(function(queryData, match, sendBadge, request) {
Expand Down
10 changes: 10 additions & 0 deletions service-tests/npm.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const t = new ServiceTester({ id: 'npm', title: 'NPM' });
module.exports = t;
const colorsB = mapValues(colorscheme, 'colorB');

const isTypeDefinition = Joi.string().regex(/^(Flow|TypeScript) v?[0-9]+.[0-9]( (Flow|TypeScript) v?[0-9]+.[0-9])?$/);

t.create('total downloads of left-pad')
.get('/dt/left-pad.json?style=_shields_test')
.expectJSONTypes(Joi.object().keys({ name: 'downloads', value: isMetric, colorB: colorsB.brightgreen }));
Expand Down Expand Up @@ -165,3 +167,11 @@ t.create('license when network is off')
.get('/l/pakage-network-off.json?style=_shields_test')
.networkOff()
.expectJSON({ name: 'license', value: 'inaccessible', colorB: colorsB.lightgrey });

t.create('types')
.get('/types/chalk.json')
.expectJSONTypes(Joi.object().keys({ name: 'type definitions', value: isTypeDefinition }));

t.create('no types')
.get('/types/left-pad.json')
.expectJSON({ name: 'type definitions', value: 'none' });

0 comments on commit 6750115

Please sign in to comment.