Commit 1df32177 authored by Mike Greiling's avatar Mike Greiling

Merge branch 'leipert-proper-icon-validator' into 'master'

Proper icon validator

Closes #49236

See merge request gitlab-org/gitlab-ce!20620
parents e6b6c7ac 4ff134df
<script> <script>
/* This is a re-usable vue component for rendering a svg sprite
icon
Sample configuration:
<icon
name="retry"
:size="32"
css-classes="top"
/>
*/
// only allow classes in images.scss e.g. s12 // only allow classes in images.scss e.g. s12
const validSizes = [8, 12, 16, 18, 24, 32, 48, 72]; const validSizes = [8, 12, 16, 18, 24, 32, 48, 72];
let iconValidator = () => true;
/*
During development/tests we want to validate that we are just using icons that are actually defined
*/
if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line global-require
const data = require('@gitlab-org/gitlab-svgs/dist/icons.json');
const { icons } = data;
iconValidator = value => {
if (icons.includes(value)) {
return true;
}
// eslint-disable-next-line no-console
console.warn(`Icon '${value}' is not a known icon of @gitlab/gitlab-svg`);
return false;
};
}
/** This is a re-usable vue component for rendering a svg sprite icon
* @example
* <icon
* name="retry"
* :size="32"
* css-classes="top"
* />
*/
export default { export default {
props: { props: {
name: { name: {
type: String, type: String,
required: true, required: true,
validator: iconValidator,
}, },
size: { size: {
...@@ -83,6 +99,6 @@ export default { ...@@ -83,6 +99,6 @@ export default {
:x="x" :x="x"
:y="y" :y="y"
> >
<use v-bind="{ 'xlink:href':spriteHref }" /> <use v-bind="{ 'xlink:href':spriteHref }"/>
</svg> </svg>
</template> </template>
require 'json'
module IconsHelper module IconsHelper
extend self extend self
include FontAwesome::Rails::IconHelper include FontAwesome::Rails::IconHelper
...@@ -38,6 +40,13 @@ module IconsHelper ...@@ -38,6 +40,13 @@ module IconsHelper
end end
def sprite_icon(icon_name, size: nil, css_class: nil) def sprite_icon(icon_name, size: nil, css_class: nil)
if Gitlab::Sentry.should_raise?
unless known_sprites.include?(icon_name)
exception = ArgumentError.new("#{icon_name} is not a known icon in @gitlab-org/gitlab-svg")
raise exception
end
end
css_classes = size ? "s#{size}" : "" css_classes = size ? "s#{size}" : ""
css_classes << " #{css_class}" unless css_class.blank? css_classes << " #{css_class}" unless css_class.blank?
content_tag(:svg, content_tag(:use, "", { "xlink:href" => "#{sprite_icon_path}##{icon_name}" } ), class: css_classes.empty? ? nil : css_classes) content_tag(:svg, content_tag(:use, "", { "xlink:href" => "#{sprite_icon_path}##{icon_name}" } ), class: css_classes.empty? ? nil : css_classes)
...@@ -134,4 +143,10 @@ module IconsHelper ...@@ -134,4 +143,10 @@ module IconsHelper
icon_class icon_class
end end
private
def known_sprites
@known_sprites ||= JSON.parse(File.read(Rails.root.join('node_modules/@gitlab-org/gitlab-svgs/dist/icons.json')))['icons']
end
end end
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
"webpack-prod": "NODE_ENV=production webpack --config config/webpack.config.js" "webpack-prod": "NODE_ENV=production webpack --config config/webpack.config.js"
}, },
"dependencies": { "dependencies": {
"@gitlab-org/gitlab-svgs": "^1.25.0", "@gitlab-org/gitlab-svgs": "^1.26.0",
"autosize": "^4.0.0", "autosize": "^4.0.0",
"axios": "^0.17.1", "axios": "^0.17.1",
"babel-core": "^6.26.3", "babel-core": "^6.26.3",
......
...@@ -55,6 +55,29 @@ describe IconsHelper do ...@@ -55,6 +55,29 @@ describe IconsHelper do
expect(sprite_icon(icon_name, size: 72, css_class: 'icon-danger').to_s) expect(sprite_icon(icon_name, size: 72, css_class: 'icon-danger').to_s)
.to eq "<svg class=\"s72 icon-danger\"><use xlink:href=\"#{icons_path}##{icon_name}\"></use></svg>" .to eq "<svg class=\"s72 icon-danger\"><use xlink:href=\"#{icons_path}##{icon_name}\"></use></svg>"
end end
describe 'non existing icon' do
non_existing = 'non_existing_icon_sprite'
it 'should raise in development mode' do
allow(Rails.env).to receive(:development?).and_return(true)
expect { sprite_icon(non_existing) }.to raise_error(ArgumentError, /is not a known icon/)
end
it 'should raise in test mode' do
allow(Rails.env).to receive(:test?).and_return(true)
expect { sprite_icon(non_existing) }.to raise_error(ArgumentError, /is not a known icon/)
end
it 'should not raise in production mode' do
allow(Rails.env).to receive(:test?).and_return(false)
allow(Rails.env).to receive(:development?).and_return(false)
expect { sprite_icon(non_existing) }.not_to raise_error
end
end
end end
describe 'file_type_icon_class' do describe 'file_type_icon_class' do
......
...@@ -70,7 +70,7 @@ describe('ideStatusBar', () => { ...@@ -70,7 +70,7 @@ describe('ideStatusBar', () => {
status: { status: {
text: 'success', text: 'success',
details_path: 'test', details_path: 'test',
icon: 'success', icon: 'status_success',
}, },
}, },
}); });
......
...@@ -23,6 +23,6 @@ describe('IDE job description', () => { ...@@ -23,6 +23,6 @@ describe('IDE job description', () => {
}); });
it('renders CI icon', () => { it('renders CI icon', () => {
expect(vm.$el.querySelector('.ci-status-icon .ic-status_passed_borderless')).not.toBe(null); expect(vm.$el.querySelector('.ci-status-icon .ic-status_success_borderless')).not.toBe(null);
}); });
}); });
...@@ -24,7 +24,7 @@ describe('IDE jobs item', () => { ...@@ -24,7 +24,7 @@ describe('IDE jobs item', () => {
}); });
it('renders CI icon', () => { it('renders CI icon', () => {
expect(vm.$el.querySelector('.ic-status_passed_borderless')).not.toBe(null); expect(vm.$el.querySelector('.ic-status_success_borderless')).not.toBe(null);
}); });
it('does not render view logs button if not started', done => { it('does not render view logs button if not started', done => {
......
...@@ -74,7 +74,7 @@ export const jobs = [ ...@@ -74,7 +74,7 @@ export const jobs = [
name: 'test', name: 'test',
path: 'testing', path: 'testing',
status: { status: {
icon: 'status_passed', icon: 'status_success',
text: 'passed', text: 'passed',
}, },
stage: 'test', stage: 'test',
...@@ -86,7 +86,7 @@ export const jobs = [ ...@@ -86,7 +86,7 @@ export const jobs = [
name: 'test 2', name: 'test 2',
path: 'testing2', path: 'testing2',
status: { status: {
icon: 'status_passed', icon: 'status_success',
text: 'passed', text: 'passed',
}, },
stage: 'test', stage: 'test',
...@@ -98,7 +98,7 @@ export const jobs = [ ...@@ -98,7 +98,7 @@ export const jobs = [
name: 'test 3', name: 'test 3',
path: 'testing3', path: 'testing3',
status: { status: {
icon: 'status_passed', icon: 'status_success',
text: 'passed', text: 'passed',
}, },
stage: 'test', stage: 'test',
...@@ -146,7 +146,7 @@ export const fullPipelinesResponse = { ...@@ -146,7 +146,7 @@ export const fullPipelinesResponse = {
}, },
details: { details: {
status: { status: {
icon: 'status_passed', icon: 'status_success',
text: 'passed', text: 'passed',
}, },
stages: [...stages], stages: [...stages],
......
...@@ -20,7 +20,7 @@ describe('Job details header', () => { ...@@ -20,7 +20,7 @@ describe('Job details header', () => {
job: { job: {
status: { status: {
group: 'failed', group: 'failed',
icon: 'ci-status-failed', icon: 'status_failed',
label: 'failed', label: 'failed',
text: 'failed', text: 'failed',
details_path: 'path', details_path: 'path',
......
...@@ -14,7 +14,7 @@ export default { ...@@ -14,7 +14,7 @@ export default {
finished_at: threeWeeksAgo.toISOString(), finished_at: threeWeeksAgo.toISOString(),
queued: 9.54, queued: 9.54,
status: { status: {
icon: 'icon_status_success', icon: 'status_success',
text: 'passed', text: 'passed',
label: 'passed', label: 'passed',
group: 'success', group: 'success',
...@@ -72,7 +72,7 @@ export default { ...@@ -72,7 +72,7 @@ export default {
}, },
details: { details: {
status: { status: {
icon: 'icon_status_success', icon: 'status_success',
text: 'passed', text: 'passed',
label: 'passed', label: 'passed',
group: 'success', group: 'success',
......
...@@ -10,7 +10,7 @@ describe('pipeline graph job component', () => { ...@@ -10,7 +10,7 @@ describe('pipeline graph job component', () => {
id: 4256, id: 4256,
name: 'test', name: 'test',
status: { status: {
icon: 'icon_status_success', icon: 'status_success',
text: 'passed', text: 'passed',
label: 'passed', label: 'passed',
tooltip: 'passed', tooltip: 'passed',
...@@ -65,7 +65,7 @@ describe('pipeline graph job component', () => { ...@@ -65,7 +65,7 @@ describe('pipeline graph job component', () => {
id: 4257, id: 4257,
name: 'test', name: 'test',
status: { status: {
icon: 'icon_status_success', icon: 'status_success',
text: 'passed', text: 'passed',
label: 'passed', label: 'passed',
group: 'success', group: 'success',
...@@ -111,7 +111,7 @@ describe('pipeline graph job component', () => { ...@@ -111,7 +111,7 @@ describe('pipeline graph job component', () => {
id: 4258, id: 4258,
name: 'test', name: 'test',
status: { status: {
icon: 'icon_status_success', icon: 'status_success',
}, },
}, },
}); });
...@@ -125,7 +125,7 @@ describe('pipeline graph job component', () => { ...@@ -125,7 +125,7 @@ describe('pipeline graph job component', () => {
id: 4259, id: 4259,
name: 'test', name: 'test',
status: { status: {
icon: 'icon_status_success', icon: 'status_success',
label: 'success', label: 'success',
tooltip: 'success', tooltip: 'success',
}, },
......
...@@ -10,7 +10,7 @@ describe('job name component', () => { ...@@ -10,7 +10,7 @@ describe('job name component', () => {
propsData: { propsData: {
name: 'foo', name: 'foo',
status: { status: {
icon: 'icon_status_success', icon: 'status_success',
}, },
}, },
}).$mount(); }).$mount();
......
...@@ -13,7 +13,7 @@ export default { ...@@ -13,7 +13,7 @@ export default {
path: '/root/ci-mock/pipelines/123', path: '/root/ci-mock/pipelines/123',
details: { details: {
status: { status: {
icon: 'icon_status_success', icon: 'status_success',
text: 'passed', text: 'passed',
label: 'passed', label: 'passed',
group: 'success', group: 'success',
...@@ -33,7 +33,7 @@ export default { ...@@ -33,7 +33,7 @@ export default {
name: 'test', name: 'test',
size: 1, size: 1,
status: { status: {
icon: 'icon_status_success', icon: 'status_success',
text: 'passed', text: 'passed',
label: 'passed', label: 'passed',
group: 'success', group: 'success',
...@@ -58,7 +58,7 @@ export default { ...@@ -58,7 +58,7 @@ export default {
created_at: '2017-04-13T09:25:18.959Z', created_at: '2017-04-13T09:25:18.959Z',
updated_at: '2017-04-13T09:25:23.118Z', updated_at: '2017-04-13T09:25:23.118Z',
status: { status: {
icon: 'icon_status_success', icon: 'status_success',
text: 'passed', text: 'passed',
label: 'passed', label: 'passed',
group: 'success', group: 'success',
...@@ -78,7 +78,7 @@ export default { ...@@ -78,7 +78,7 @@ export default {
}, },
], ],
status: { status: {
icon: 'icon_status_success', icon: 'status_success',
text: 'passed', text: 'passed',
label: 'passed', label: 'passed',
group: 'success', group: 'success',
...@@ -98,7 +98,7 @@ export default { ...@@ -98,7 +98,7 @@ export default {
name: 'deploy to production', name: 'deploy to production',
size: 1, size: 1,
status: { status: {
icon: 'icon_status_success', icon: 'status_success',
text: 'passed', text: 'passed',
label: 'passed', label: 'passed',
group: 'success', group: 'success',
...@@ -123,7 +123,7 @@ export default { ...@@ -123,7 +123,7 @@ export default {
created_at: '2017-04-19T14:29:46.463Z', created_at: '2017-04-19T14:29:46.463Z',
updated_at: '2017-04-19T14:30:27.498Z', updated_at: '2017-04-19T14:30:27.498Z',
status: { status: {
icon: 'icon_status_success', icon: 'status_success',
text: 'passed', text: 'passed',
label: 'passed', label: 'passed',
group: 'success', group: 'success',
...@@ -145,7 +145,7 @@ export default { ...@@ -145,7 +145,7 @@ export default {
name: 'deploy to staging', name: 'deploy to staging',
size: 1, size: 1,
status: { status: {
icon: 'icon_status_success', icon: 'status_success',
text: 'passed', text: 'passed',
label: 'passed', label: 'passed',
group: 'success', group: 'success',
...@@ -170,7 +170,7 @@ export default { ...@@ -170,7 +170,7 @@ export default {
created_at: '2017-04-18T16:32:08.420Z', created_at: '2017-04-18T16:32:08.420Z',
updated_at: '2017-04-18T16:32:12.631Z', updated_at: '2017-04-18T16:32:12.631Z',
status: { status: {
icon: 'icon_status_success', icon: 'status_success',
text: 'passed', text: 'passed',
label: 'passed', label: 'passed',
group: 'success', group: 'success',
...@@ -190,7 +190,7 @@ export default { ...@@ -190,7 +190,7 @@ export default {
}, },
], ],
status: { status: {
icon: 'icon_status_success', icon: 'status_success',
text: 'passed', text: 'passed',
label: 'passed', label: 'passed',
group: 'success', group: 'success',
......
...@@ -7,7 +7,7 @@ describe('stage column component', () => { ...@@ -7,7 +7,7 @@ describe('stage column component', () => {
id: 4250, id: 4250,
name: 'test', name: 'test',
status: { status: {
icon: 'icon_status_success', icon: 'status_success',
text: 'passed', text: 'passed',
label: 'passed', label: 'passed',
group: 'success', group: 'success',
......
...@@ -18,7 +18,7 @@ describe('Pipeline details header', () => { ...@@ -18,7 +18,7 @@ describe('Pipeline details header', () => {
details: { details: {
status: { status: {
group: 'failed', group: 'failed',
icon: 'ci-status-failed', icon: 'status_failed',
label: 'failed', label: 'failed',
text: 'failed', text: 'failed',
details_path: 'path', details_path: 'path',
......
...@@ -20,7 +20,7 @@ describe('Pipelines stage component', () => { ...@@ -20,7 +20,7 @@ describe('Pipelines stage component', () => {
stage: { stage: {
status: { status: {
group: 'success', group: 'success',
icon: 'icon_status_success', icon: 'status_success',
title: 'success', title: 'success',
}, },
dropdown_path: 'path.json', dropdown_path: 'path.json',
...@@ -84,7 +84,7 @@ describe('Pipelines stage component', () => { ...@@ -84,7 +84,7 @@ describe('Pipelines stage component', () => {
component.stage = { component.stage = {
status: { status: {
group: 'running', group: 'running',
icon: 'running', icon: 'status_running',
title: 'running', title: 'running',
}, },
dropdown_path: 'bar.json', dropdown_path: 'bar.json',
......
...@@ -76,7 +76,7 @@ export default { ...@@ -76,7 +76,7 @@ export default {
path: '/root/acets-app/pipelines/172', path: '/root/acets-app/pipelines/172',
details: { details: {
status: { status: {
icon: 'icon_status_success', icon: 'status_success',
favicon: 'favicon_status_success', favicon: 'favicon_status_success',
text: 'passed', text: 'passed',
label: 'passed', label: 'passed',
...@@ -91,7 +91,7 @@ export default { ...@@ -91,7 +91,7 @@ export default {
name: 'build', name: 'build',
title: 'build: failed', title: 'build: failed',
status: { status: {
icon: 'icon_status_failed', icon: 'status_failed',
favicon: 'favicon_status_failed', favicon: 'favicon_status_failed',
text: 'failed', text: 'failed',
label: 'failed', label: 'failed',
...@@ -106,7 +106,7 @@ export default { ...@@ -106,7 +106,7 @@ export default {
name: 'review', name: 'review',
title: 'review: skipped', title: 'review: skipped',
status: { status: {
icon: 'icon_status_skipped', icon: 'status_skipped',
favicon: 'favicon_status_skipped', favicon: 'favicon_status_skipped',
text: 'skipped', text: 'skipped',
label: 'skipped', label: 'skipped',
......
...@@ -13,7 +13,7 @@ describe('CI Icon component', () => { ...@@ -13,7 +13,7 @@ describe('CI Icon component', () => {
it('should render a span element with an svg', () => { it('should render a span element with an svg', () => {
vm = mountComponent(Component, { vm = mountComponent(Component, {
status: { status: {
icon: 'icon_status_success', icon: 'status_success',
}, },
}); });
...@@ -24,7 +24,7 @@ describe('CI Icon component', () => { ...@@ -24,7 +24,7 @@ describe('CI Icon component', () => {
it('should render a success status', () => { it('should render a success status', () => {
vm = mountComponent(Component, { vm = mountComponent(Component, {
status: { status: {
icon: 'icon_status_success', icon: 'status_success',
group: 'success', group: 'success',
}, },
}); });
...@@ -35,7 +35,7 @@ describe('CI Icon component', () => { ...@@ -35,7 +35,7 @@ describe('CI Icon component', () => {
it('should render a failed status', () => { it('should render a failed status', () => {
vm = mountComponent(Component, { vm = mountComponent(Component, {
status: { status: {
icon: 'icon_status_failed', icon: 'status_failed',
group: 'failed', group: 'failed',
}, },
}); });
...@@ -46,7 +46,7 @@ describe('CI Icon component', () => { ...@@ -46,7 +46,7 @@ describe('CI Icon component', () => {
it('should render success with warnings status', () => { it('should render success with warnings status', () => {
vm = mountComponent(Component, { vm = mountComponent(Component, {
status: { status: {
icon: 'icon_status_warning', icon: 'status_warning',
group: 'warning', group: 'warning',
}, },
}); });
...@@ -57,7 +57,7 @@ describe('CI Icon component', () => { ...@@ -57,7 +57,7 @@ describe('CI Icon component', () => {
it('should render pending status', () => { it('should render pending status', () => {
vm = mountComponent(Component, { vm = mountComponent(Component, {
status: { status: {
icon: 'icon_status_pending', icon: 'status_pending',
group: 'pending', group: 'pending',
}, },
}); });
...@@ -68,7 +68,7 @@ describe('CI Icon component', () => { ...@@ -68,7 +68,7 @@ describe('CI Icon component', () => {
it('should render running status', () => { it('should render running status', () => {
vm = mountComponent(Component, { vm = mountComponent(Component, {
status: { status: {
icon: 'icon_status_running', icon: 'status_running',
group: 'running', group: 'running',
}, },
}); });
...@@ -79,7 +79,7 @@ describe('CI Icon component', () => { ...@@ -79,7 +79,7 @@ describe('CI Icon component', () => {
it('should render created status', () => { it('should render created status', () => {
vm = mountComponent(Component, { vm = mountComponent(Component, {
status: { status: {
icon: 'icon_status_created', icon: 'status_created',
group: 'created', group: 'created',
}, },
}); });
...@@ -90,7 +90,7 @@ describe('CI Icon component', () => { ...@@ -90,7 +90,7 @@ describe('CI Icon component', () => {
it('should render skipped status', () => { it('should render skipped status', () => {
vm = mountComponent(Component, { vm = mountComponent(Component, {
status: { status: {
icon: 'icon_status_skipped', icon: 'status_skipped',
group: 'skipped', group: 'skipped',
}, },
}); });
...@@ -101,7 +101,7 @@ describe('CI Icon component', () => { ...@@ -101,7 +101,7 @@ describe('CI Icon component', () => {
it('should render canceled status', () => { it('should render canceled status', () => {
vm = mountComponent(Component, { vm = mountComponent(Component, {
status: { status: {
icon: 'icon_status_canceled', icon: 'status_canceled',
group: 'canceled', group: 'canceled',
}, },
}); });
...@@ -112,7 +112,7 @@ describe('CI Icon component', () => { ...@@ -112,7 +112,7 @@ describe('CI Icon component', () => {
it('should render status for manual action', () => { it('should render status for manual action', () => {
vm = mountComponent(Component, { vm = mountComponent(Component, {
status: { status: {
icon: 'icon_status_manual', icon: 'status_manual',
group: 'manual', group: 'manual',
}, },
}); });
......
...@@ -12,7 +12,7 @@ describe('Header CI Component', () => { ...@@ -12,7 +12,7 @@ describe('Header CI Component', () => {
props = { props = {
status: { status: {
group: 'failed', group: 'failed',
icon: 'ci-status-failed', icon: 'status_failed',
label: 'failed', label: 'failed',
text: 'failed', text: 'failed',
details_path: 'path', details_path: 'path',
......
...@@ -10,7 +10,7 @@ describe('Sprite Icon Component', function () { ...@@ -10,7 +10,7 @@ describe('Sprite Icon Component', function () {
const IconComponent = Vue.extend(Icon); const IconComponent = Vue.extend(Icon);
icon = mountComponent(IconComponent, { icon = mountComponent(IconComponent, {
name: 'test', name: 'commit',
size: 32, size: 32,
cssClasses: 'extraclasses', cssClasses: 'extraclasses',
}); });
...@@ -30,7 +30,7 @@ describe('Sprite Icon Component', function () { ...@@ -30,7 +30,7 @@ describe('Sprite Icon Component', function () {
it('should have <use> as a child element with the correct href', function () { it('should have <use> as a child element with the correct href', function () {
expect(icon.$el.firstChild.tagName).toBe('use'); expect(icon.$el.firstChild.tagName).toBe('use');
expect(icon.$el.firstChild.getAttribute('xlink:href')).toBe(`${gon.sprite_icons}#test`); expect(icon.$el.firstChild.getAttribute('xlink:href')).toBe(`${gon.sprite_icons}#commit`);
}); });
it('should properly compute iconSizeClass', function () { it('should properly compute iconSizeClass', function () {
...@@ -50,5 +50,13 @@ describe('Sprite Icon Component', function () { ...@@ -50,5 +50,13 @@ describe('Sprite Icon Component', function () {
expect(containsSizeClass).toBe(true); expect(containsSizeClass).toBe(true);
expect(containsCustomClass).toBe(true); expect(containsCustomClass).toBe(true);
}); });
it('`name` validator should return false for non existing icons', () => {
expect(Icon.props.name.validator('non_existing_icon_sprite')).toBe(false);
});
it('`name` validator should return false for existing icons', () => {
expect(Icon.props.name.validator('commit')).toBe(true);
});
}); });
}); });
...@@ -19,7 +19,7 @@ describe('system note component', () => { ...@@ -19,7 +19,7 @@ describe('system note component', () => {
path: '/root', path: '/root',
}, },
note_html: '<p dir="auto">closed</p>', note_html: '<p dir="auto">closed</p>',
system_note_icon_name: 'icon_status_closed', system_note_icon_name: 'status_closed',
created_at: '2017-08-02T10:51:58.559Z', created_at: '2017-08-02T10:51:58.559Z',
}, },
}; };
......
...@@ -78,9 +78,9 @@ ...@@ -78,9 +78,9 @@
lodash "^4.2.0" lodash "^4.2.0"
to-fast-properties "^2.0.0" to-fast-properties "^2.0.0"
"@gitlab-org/gitlab-svgs@^1.25.0": "@gitlab-org/gitlab-svgs@^1.26.0":
version "1.25.0" version "1.26.0"
resolved "https://registry.yarnpkg.com/@gitlab-org/gitlab-svgs/-/gitlab-svgs-1.25.0.tgz#1a82b1be43e1a46e6b0767ef46f26f5fd6bbd101" resolved "https://registry.yarnpkg.com/@gitlab-org/gitlab-svgs/-/gitlab-svgs-1.26.0.tgz#d89c633e866d031a9e4787b05eacc7144c3a7791"
"@sindresorhus/is@^0.7.0": "@sindresorhus/is@^0.7.0":
version "0.7.0" version "0.7.0"
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment