Better modal (#2554)

- With examples using `pattern`s, allow building the URL from its component parts, including the query string.
- Provide a button to copy the link, with an animation.

To enable this for other badges, convert them to use a `pattern`: #1961.
This commit is contained in:
Paul Melnikow
2019-01-10 21:04:07 -05:00
committed by GitHub
parent da12f00d87
commit 6c2b040fa6
16 changed files with 1288 additions and 79 deletions

View File

@@ -2,6 +2,13 @@ import React from 'react'
import PropTypes from 'prop-types'
import styled, { css } from 'styled-components'
const noAutocorrect = Object.freeze({
autoComplete: 'off',
autoCorrect: 'off',
autoCapitalize: 'off',
spellCheck: 'false',
})
const nonBreakingSpace = '\u00a0'
const BaseFont = styled.div`
@@ -94,11 +101,13 @@ const VerticalSpace = styled.hr`
`
export {
noAutocorrect,
nonBreakingSpace,
BaseFont,
H2,
H3,
Badge,
StyledInput,
InlineInput,
BlockInput,
VerticalSpace,

View File

@@ -1,6 +1,6 @@
import React from 'react'
import PropTypes from 'prop-types'
import styled from 'styled-components'
import styled, { createGlobalStyle } from 'styled-components'
import groupBy from 'lodash.groupby'
import {
categories,
@@ -21,6 +21,12 @@ import { CategoryHeadings, CategoryNav } from './category-headings'
import BadgeExamples from './badge-examples'
import { BaseFont } from './common'
const GlobalStyle = createGlobalStyle`
* {
box-sizing: border-box;
}
`
const AppContainer = styled(BaseFont)`
text-align: center;
`
@@ -147,13 +153,13 @@ export default class Main extends React.Component {
return (
<AppContainer id="app">
<GlobalStyle />
<Meta />
<Header />
<MarkupModal
example={selectedExample}
onRequestClose={this.dismissMarkupModal}
baseUrl={baseUrl}
key={selectedExample}
/>
<section>
<SuggestionAndSearch

View File

@@ -0,0 +1,48 @@
import React from 'react'
import PropTypes from 'prop-types'
import styled from 'styled-components'
const BuilderOuterContainer = styled.div`
margin-top: 10px;
margin-bottom: 10px;
`
// The inner container is inline-block so that its width matches its columns.
const BuilderInnerContainer = styled.div`
display: inline-block;
padding: 11px 14px 10px;
border-radius: 4px;
background: #eef;
`
const BuilderContainer = ({ children }) => (
<BuilderOuterContainer>
<BuilderInnerContainer>{children}</BuilderInnerContainer>
</BuilderOuterContainer>
)
BuilderContainer.propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]),
}
const labelFont = `
font-family: system-ui;
font-size: 11px;
text-transform: lowercase;
`
const BuilderLabel = styled.label`
${labelFont}
`
const BuilderCaption = styled.span`
${labelFont}
color: #999;
`
export { BuilderContainer, BuilderLabel, BuilderCaption }

View File

@@ -0,0 +1,74 @@
import React from 'react'
import PropTypes from 'prop-types'
import posed from 'react-pose'
import styled from 'styled-components'
const ContentAnchor = styled.span`
position: relative;
display: inline-block;
`
// 100vw allows providing styled content which is wider than its container.
const ContentContainer = styled.span`
width: 100vw;
position: absolute;
left: 50%;
transform: translateX(-50%);
will-change: opacity, top;
pointer-events: none;
`
const PosedContentContainer = posed(ContentContainer)({
hidden: { opacity: 0, transition: { duration: 100 } },
effectStart: { top: '-10px', opacity: 1.0, transition: { duration: 0 } },
effectEnd: { top: '-75px', opacity: 0.5 },
})
// When `trigger()` is called, render copied content that floats up, then
// disappears.
export default class CopiedContentIndicator extends React.Component {
state = {
pose: 'hidden',
}
trigger() {
this.setState({ pose: 'effectStart' })
}
handlePoseComplete = () => {
const { pose } = this.state
if (pose === 'effectStart') {
this.setState({ pose: 'effectEnd' })
} else {
this.setState({ pose: 'hidden' })
}
}
render() {
const { pose } = this.state
return (
<ContentAnchor>
<PosedContentContainer
pose={pose}
onPoseComplete={this.handlePoseComplete}
>
{this.props.copiedContent}
</PosedContentContainer>
{this.props.children}
</ContentAnchor>
)
}
}
CopiedContentIndicator.propTypes = {
copiedContent: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]),
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]),
}

View File

@@ -2,11 +2,15 @@ import React from 'react'
import PropTypes from 'prop-types'
import Modal from 'react-modal'
import styled from 'styled-components'
import { badgeUrlFromPath, badgeUrlFromPattern } from '../../lib/make-badge-url'
import generateAllMarkup from '../lib/generate-image-markup'
import { advertisedStyles } from '../../supported-features.json'
import { Snippet } from './snippet'
import { BaseFont, H3, Badge, BlockInput } from './common'
import {
badgeUrlFromPath,
badgeUrlFromPattern,
} from '../../../lib/make-badge-url'
import { advertisedStyles } from '../../../supported-features.json'
import generateAllMarkup from '../../lib/generate-image-markup'
import { Snippet } from '../snippet'
import { BaseFont, H3, Badge, BlockInput } from '../common'
import MarkupModalContent from './markup-modal-content'
const ContentContainer = styled(BaseFont)`
text-align: center;
@@ -176,6 +180,8 @@ export default class MarkupModal extends React.Component {
const { onRequestClose, example: { title } = {} } = this.props
const { link, badgeUrl, exampleUrl, style } = this.state
const hasModernExample = isOpen && this.props.example.example.pattern
const common = {
autoComplete: 'off',
autoCorrect: 'off',
@@ -190,63 +196,69 @@ export default class MarkupModal extends React.Component {
contentLabel="Example Modal"
ariaHideApp={false}
>
<ContentContainer>
<form action="">
<H3>{title}</H3>
{isOpen && this.renderLivePreview()}
<p>
<label>
Link&nbsp;
<BlockInput
type="url"
value={link}
onChange={event => {
this.setState({ link: event.target.value })
}}
{...common}
/>
</label>
</p>
<p>
<label>
Path&nbsp;
<BlockInput
type="url"
value={badgeUrl}
onChange={event => {
this.setState({ badgeUrl: event.target.value })
}}
{...common}
/>
</label>
</p>
{exampleUrl && (
{hasModernExample ? (
<ContentContainer>
<MarkupModalContent {...this.props} />
</ContentContainer>
) : (
<ContentContainer>
<form action="">
<H3>{title}</H3>
{isOpen && this.renderLivePreview()}
<p>
Example&nbsp;
<Snippet fontSize="10pt" snippet={exampleUrl} />
<label>
Link&nbsp;
<BlockInput
type="url"
value={link}
onChange={event => {
this.setState({ link: event.target.value })
}}
{...common}
/>
</label>
</p>
)}
<p>
<label>
Style&nbsp;
<select
value={style}
onChange={event => {
this.setState({ style: event.target.value })
}}
>
{advertisedStyles.map(style => (
<option key={style} value={style}>
{style}
</option>
))}
</select>
</label>
</p>
{isOpen && this.renderMarkup()}
{isOpen && this.renderDocumentation()}
</form>
</ContentContainer>
<p>
<label>
Path&nbsp;
<BlockInput
type="url"
value={badgeUrl}
onChange={event => {
this.setState({ badgeUrl: event.target.value })
}}
{...common}
/>
</label>
</p>
{exampleUrl && (
<p>
Example&nbsp;
<Snippet fontSize="10pt" snippet={exampleUrl} />
</p>
)}
<p>
<label>
Style&nbsp;
<select
value={style}
onChange={event => {
this.setState({ style: event.target.value })
}}
>
{advertisedStyles.map(style => (
<option key={style} value={style}>
{style}
</option>
))}
</select>
</label>
</p>
{isOpen && this.renderMarkup()}
{isOpen && this.renderDocumentation()}
</form>
</ContentContainer>
)}
</Modal>
)
}

View File

@@ -0,0 +1,177 @@
import React from 'react'
import PropTypes from 'prop-types'
import styled from 'styled-components'
import clipboardCopy from 'clipboard-copy'
import { staticBadgeUrl } from '../../lib/badge-url'
import { generateMarkup } from '../../lib/generate-image-markup'
import { H3, Badge } from '../common'
import PathBuilder from './path-builder'
import QueryStringBuilder from './query-string-builder'
import RequestMarkupButtom from './request-markup-button'
import CopiedContentIndicator from './copied-content-indicator'
const Documentation = styled.div`
max-width: 800px;
margin: 35px auto 20px;
`
export default class MarkupModalContent extends React.Component {
static propTypes = {
// This is an item from the `examples` array within the
// `serviceDefinition` schema.
// https://github.com/badges/shields/blob/master/services/service-definitions.js
example: PropTypes.object,
baseUrl: PropTypes.string.isRequired,
}
indicatorRef = React.createRef()
state = {
path: '',
link: '',
markupFormat: 'link',
message: undefined,
}
get baseUrl() {
const { baseUrl } = this.props
if (baseUrl) {
return baseUrl
} else {
// Default to the current hostname for when there is no `BASE_URL` set
// at build time (as in most PaaS deploys).
const { protocol, hostname } = window.location
return `${protocol}//${hostname}`
}
}
generateBuiltBadgeUrl() {
const { baseUrl } = this
const { path, queryString } = this.state
const suffix = queryString ? `?${queryString}` : ''
return `${baseUrl}${path}.svg${suffix}`
}
renderLivePreview() {
// There are some usability issues here. It would be better if the message
// changed from a validation error to a loading message once the
// parameters were filled in, and also switched back to loading when the
// parameters changed.
const { baseUrl } = this.props
const { pathIsComplete } = this.state
let src
if (pathIsComplete) {
src = this.generateBuiltBadgeUrl()
} else {
src = staticBadgeUrl(
baseUrl,
'preview',
'some parameters missing',
'lightgray'
)
}
return (
<p>
<Badge display="block" src={src} />
</p>
)
}
copyMarkup = async markupFormat => {
const {
example: {
example: { title },
},
} = this.props
const { link } = this.state
const builtBadgeUrl = this.generateBuiltBadgeUrl()
const markup = generateMarkup({
badgeUrl: builtBadgeUrl,
link,
title,
markupFormat,
})
try {
await clipboardCopy(markup)
} catch (e) {
this.setState({
message: 'Copy failed',
markup,
})
return
}
this.setState({ markup })
this.indicatorRef.current.trigger()
}
renderMarkupAndLivePreview() {
const { indicatorRef } = this
const { markup, message, pathIsComplete } = this.state
return (
<div>
{this.renderLivePreview()}
<CopiedContentIndicator ref={indicatorRef} copiedContent="Copied">
<RequestMarkupButtom
isDisabled={!pathIsComplete}
onMarkupRequested={this.copyMarkup}
/>
</CopiedContentIndicator>
{message && (
<div>
<p>{message}</p>
<p>Markup: {markup}</p>
</div>
)}
</div>
)
}
renderDocumentation() {
const {
example: { documentation },
} = this.props
return documentation ? (
<Documentation dangerouslySetInnerHTML={{ __html: documentation }} />
) : null
}
handlePathChange = ({ path, isComplete }) => {
this.setState({ path, pathIsComplete: isComplete })
}
handleQueryStringChange = ({ queryString, isComplete }) => {
this.setState({ queryString, queryStringIsComplete: isComplete })
}
render() {
const {
example: {
title,
example: { pattern, namedParams, queryParams },
},
} = this.props
return (
<form action="">
<H3>{title}</H3>
{this.renderDocumentation()}
<PathBuilder
pattern={pattern}
exampleParams={namedParams}
onChange={this.handlePathChange}
/>
<QueryStringBuilder
exampleParams={queryParams}
onChange={this.handleQueryStringChange}
/>
<div>{this.renderMarkupAndLivePreview()}</div>
</form>
)
}
}

View File

@@ -0,0 +1,182 @@
import React from 'react'
import PropTypes from 'prop-types'
import styled, { css } from 'styled-components'
import pathToRegexp from 'path-to-regexp'
import humanizeString from 'humanize-string'
import { noAutocorrect, StyledInput } from '../common'
import {
BuilderContainer,
BuilderLabel,
BuilderCaption,
} from './builder-common'
const PathBuilderColumn = styled.span`
height: 58px;
float: left;
display: flex;
flex-direction: column;
margin: 5px 0;
${({ withHorizPadding }) =>
withHorizPadding &&
css`
padding: 0 8px;
`};
`
const PathLiteral = styled.div`
margin-top: 20px;
${({ isFirstToken }) =>
isFirstToken &&
css`
margin-left: 3px;
`};
`
const NamedParamLabel = styled(BuilderLabel)`
height: 20px;
width: 100%;
text-align: center;
`
const NamedParamInput = styled(StyledInput)`
width: 100%;
text-align: center;
margin-bottom: 10px;
`
const NamedParamCaption = styled(BuilderCaption)`
width: 100%;
text-align: center;
`
export default class PathBuilder extends React.Component {
static propTypes = {
pattern: PropTypes.string.isRequired,
exampleParams: PropTypes.object.isRequired,
onChange: PropTypes.func,
}
constructor(props) {
super(props)
const { pattern } = props
const tokens = pathToRegexp.parse(pattern)
const namedParams = {}
// `pathToRegexp.parse()` returns a mixed array of strings for literals
// and objects for parameters. Filter out the literals and work with the
// objects.
tokens
.filter(t => typeof t !== 'string')
.forEach(({ name }) => {
namedParams[name] = ''
})
this.state = {
tokens,
namedParams,
}
}
static constructPath({ tokens, namedParams }) {
let isComplete = true
const path = tokens
.map(token => {
if (typeof token === 'string') {
return token
} else {
const { delimiter, name } = token
let value = namedParams[name]
if (!value) {
isComplete = false
value = `:${name}`
}
return `${delimiter}${value}`
}
})
.join('')
return { path, isComplete }
}
getPath(namedParams) {
const { tokens } = this.state
return this.constructor.constructPath({ tokens, namedParams })
}
handleTokenChange = evt => {
const { name, value } = evt.target
const { namedParams: oldNamedParams } = this.state
const namedParams = {
...oldNamedParams,
[name]: value,
}
this.setState({ namedParams })
const { onChange } = this.props
if (onChange) {
const { path, isComplete } = this.getPath(namedParams)
onChange({ path, isComplete })
}
}
renderLiteral(literal, tokenIndex) {
return (
<PathBuilderColumn key={`${tokenIndex}-${literal}`}>
<PathLiteral isFirstToken={tokenIndex === 0}>{literal}</PathLiteral>
</PathBuilderColumn>
)
}
renderNamedParam(token, tokenIndex, namedParamIndex) {
const { delimiter, name } = token
const { exampleParams } = this.props
const exampleValue = exampleParams[name]
const { namedParams } = this.state
const value = namedParams[name]
return (
<React.Fragment key={token.name}>
{this.renderLiteral(delimiter, tokenIndex)}
<PathBuilderColumn withHorizPadding>
<NamedParamLabel htmlFor={name}>
{humanizeString(name)}
</NamedParamLabel>
<NamedParamInput
type="text"
name={name}
value={value}
onChange={this.handleTokenChange}
{...noAutocorrect}
/>
<NamedParamCaption>
{namedParamIndex === 0 ? `e.g. ${exampleValue}` : exampleValue}
</NamedParamCaption>
</PathBuilderColumn>
</React.Fragment>
)
}
render() {
const { tokens } = this.state
let namedParamIndex = 0
return (
<BuilderContainer>
{tokens.map((token, tokenIndex) =>
typeof token === 'string'
? this.renderLiteral(token, tokenIndex)
: this.renderNamedParam(token, tokenIndex, namedParamIndex++)
)}
</BuilderContainer>
)
}
}

View File

@@ -0,0 +1,28 @@
import PathBuilder from './path-builder'
import { test, given } from 'sazerac'
import pathToRegexp from 'path-to-regexp'
describe('<PathBuilder />', function() {
const tokens = pathToRegexp.parse('github/license/:user/:repo')
test(PathBuilder.constructPath, () => {
given({
tokens,
namedParams: {
user: 'paulmelnikow',
repo: 'react-boxplot',
},
}).expect({
path: 'github/license/paulmelnikow/react-boxplot',
isComplete: true,
})
given({
tokens,
namedParams: {
user: 'paulmelnikow',
},
}).expect({
path: 'github/license/paulmelnikow/:repo',
isComplete: false,
})
})
})

View File

@@ -0,0 +1,256 @@
import React from 'react'
import PropTypes from 'prop-types'
import styled from 'styled-components'
import humanizeString from 'humanize-string'
import { stringify as stringifyQueryString } from 'query-string'
import { advertisedStyles } from '../../../supported-features.json'
import { noAutocorrect, StyledInput } from '../common'
import {
BuilderContainer,
BuilderLabel,
BuilderCaption,
} from './builder-common'
const QueryParamLabel = styled(BuilderLabel)`
margin: 5px;
`
const QueryParamInput = styled(StyledInput)`
margin: 5px 10px;
`
const QueryParamCaption = styled(BuilderCaption)`
margin: 5px;
`
const supportedBadgeOptions = [
{ name: 'style', defaultValue: 'flat' },
{ name: 'label', label: 'override label' },
{ name: 'colorB', label: 'override color' },
{ name: 'logo', label: 'named logo' },
{ name: 'logoColor', label: 'override logo color' },
]
function getBadgeOption(name) {
return supportedBadgeOptions.find(opt => opt.name === name)
}
export default class QueryStringBuilder extends React.Component {
constructor(props) {
super(props)
const { exampleParams } = props
const queryParams = {}
Object.entries(exampleParams).forEach(([name, value]) => {
const isStringParam = typeof value === 'string'
queryParams[name] = isStringParam ? '' : true
})
const badgeOptions = {}
supportedBadgeOptions.forEach(({ name, defaultValue = '' }) => {
badgeOptions[name] = defaultValue
})
this.state = { queryParams, badgeOptions }
}
static getQueryString({ queryParams, badgeOptions }) {
const outQuery = {}
let isComplete = true
Object.entries(queryParams).forEach(([name, value]) => {
const isStringParam = typeof value === 'string'
if (isStringParam) {
if (value) {
outQuery[name] = value
} else {
// Omit empty string params.
isComplete = false
}
} else {
// Translate `true` to `null`, which provides an empty query param
// like `?compact_message`. Omit `false`. Omit default values.
if (value) {
outQuery[name] = null
}
}
})
Object.entries(badgeOptions).forEach(([name, value]) => {
const { defaultValue } = getBadgeOption(name)
if (value && value !== defaultValue) {
outQuery[name] = value
}
})
const queryString = stringifyQueryString(outQuery)
return { queryString, isComplete }
}
noteQueryStringChanged({ queryParams, badgeOptions }) {
const { onChange } = this.props
if (onChange) {
const { queryString, isComplete } = this.constructor.getQueryString({
queryParams,
badgeOptions,
})
onChange({ queryString, isComplete })
}
}
handleServiceQueryParamChange = event => {
const { name, type } = event.target
const value =
type === 'checkbox' ? event.target.checked : event.target.value
const { queryParams: oldQueryParams, badgeOptions } = this.state
const queryParams = {
...oldQueryParams,
[name]: value,
}
this.setState({ queryParams })
this.noteQueryStringChanged({ queryParams, badgeOptions })
}
handleBadgeOptionChange = event => {
const { name, value } = event.target
const { badgeOptions: oldBadgeOptions, queryParams } = this.state
const badgeOptions = {
...oldBadgeOptions,
[name]: value,
}
this.setState({ badgeOptions })
this.noteQueryStringChanged({ queryParams, badgeOptions })
}
renderServiceQueryParam({ name, value, isStringParam, stringParamCount }) {
const exampleValue = this.props.exampleParams[name]
return (
<tr key={name}>
<td>
<QueryParamLabel htmlFor={name}>
{humanizeString(name).toLowerCase()}
</QueryParamLabel>
</td>
<td>
{isStringParam && (
<QueryParamCaption>
{stringParamCount === 0 ? `e.g. ${exampleValue}` : exampleValue}
</QueryParamCaption>
)}
</td>
<td>
{isStringParam ? (
<QueryParamInput
type="text"
name={name}
checked={value}
onChange={this.handleServiceQueryParamChange}
{...noAutocorrect}
/>
) : (
<input
type="checkbox"
name={name}
checked={value}
onChange={this.handleServiceQueryParamChange}
/>
)}
</td>
</tr>
)
}
renderBadgeOptionInput(name, value) {
if (name === 'style') {
return (
<select
name="style"
value={value}
onChange={this.handleBadgeOptionChange}
>
{advertisedStyles.map(style => (
<option key={style} value={style}>
{style}
</option>
))}
</select>
)
} else {
return (
<QueryParamInput
type="text"
name={name}
checked={value}
onChange={this.handleBadgeOptionChange}
{...noAutocorrect}
/>
)
}
}
renderBadgeOption(name, value) {
const {
label = humanizeString(name),
defaultValue: hasDefaultValue,
} = getBadgeOption(name)
return (
<tr key={name}>
<td>
<QueryParamLabel htmlFor={name}>{label}</QueryParamLabel>
</td>
<td>
{!hasDefaultValue && <QueryParamCaption>optional</QueryParamCaption>}
</td>
<td>{this.renderBadgeOptionInput(name, value)}</td>
</tr>
)
}
render() {
const { queryParams, badgeOptions } = this.state
const hasQueryParams = Boolean(Object.keys(queryParams).length)
let stringParamCount = 0
return (
<>
{hasQueryParams && (
<BuilderContainer>
<table>
<tbody>
{Object.entries(queryParams).map(([name, value]) => {
const isStringParam = typeof value === 'string'
return this.renderServiceQueryParam({
name,
value,
isStringParam,
stringParamCount: isStringParam
? stringParamCount++
: undefined,
})
})}
</tbody>
</table>
</BuilderContainer>
)}
<BuilderContainer>
<table>
<tbody>
{Object.entries(badgeOptions).map(([name, value]) =>
this.renderBadgeOption(name, value)
)}
</tbody>
</table>
</BuilderContainer>
</>
)
}
}
QueryStringBuilder.propTypes = {
exampleParams: PropTypes.object.isRequired,
onChange: PropTypes.func,
}

View File

@@ -0,0 +1,119 @@
import React from 'react'
import PropTypes from 'prop-types'
import styled from 'styled-components'
import Select, { components } from 'react-select'
const ClickableControl = props => (
<components.Control
{...props}
innerProps={{
onMouseDown: props.selectProps.onControlMouseDown,
}}
/>
)
ClickableControl.propTypes = {
selectProps: PropTypes.object.isRequired,
}
const MarkupFormatSelect = styled(Select)`
width: 200px;
margin-left: auto;
margin-right: auto;
font-family: 'Lato', sans-serif;
font-size: 12px;
.markup-format__control {
background-image: linear-gradient(-180deg, #00aeff 0%, #0076ff 100%);
border: 1px solid rgba(238, 239, 241, 0.8);
border-width: 0;
box-shadow: unset;
cursor: copy;
}
.markup-format__control--is-disabled {
background: rgba(0, 118, 255, 0.3);
cursor: none;
}
.markup-format__placeholder {
color: #eeeff1;
}
.markup-format__indicator {
color: rgba(238, 239, 241, 0.81);
cursor: pointer;
}
.markup-format__indicator:hover {
color: #eeeff1;
}
.markup-format__control--is-focused .markup-format__indicator,
.markup-format__control--is-focused .markup-format__indicator:hover {
color: #ffffff;
}
.markup-format__option {
text-align: left;
cursor: copy;
}
`
const markupOptions = [
{ value: 'markdown', label: 'Copy Markdown' },
{ value: 'rst', label: 'Copy reStructuredText' },
{ value: 'asciidoc', label: 'Copy AsciiDoc' },
{ value: 'html', label: 'Copy HTML' },
]
class GetMarkupButton extends React.PureComponent {
selectRef = React.createRef()
onControlMouseDown = async event => {
const { selectRef } = this
const { onMarkupRequested } = this.props
if (onMarkupRequested) {
await onMarkupRequested('link')
}
selectRef.current.blur()
}
onOptionClick = async ({ value: markupFormat }) => {
const { onMarkupRequested } = this.props
if (onMarkupRequested) {
await onMarkupRequested(markupFormat)
}
}
render() {
const { isDisabled } = this.props
return (
<MarkupFormatSelect
ref={this.selectRef}
options={markupOptions}
placeholder="Copy Badge URL"
value=""
isDisabled={isDisabled}
closeMenuOnScroll
blurInputOnSelect
menuPlacement="auto"
isSearchable={false}
onControlMouseDown={this.onControlMouseDown}
onChange={this.onOptionClick}
classNamePrefix="markup-format"
components={{
Control: ClickableControl,
}}
/>
)
}
}
GetMarkupButton.propTypes = {
onMarkupRequested: PropTypes.func.isRequired,
isDisabled: PropTypes.bool,
}
export default GetMarkupButton

View File

@@ -13,7 +13,7 @@ export default () => (
<meta name="description" content={description} />
<link rel="icon" type="image/png" href="favicon.png" />
<link
href="https://fonts.googleapis.com/css?family=Lekton"
href="https://fonts.googleapis.com/css?family=Lato|Lekton"
rel="stylesheet"
/>
</Head>

View File

@@ -4,6 +4,8 @@ import ClickToSelect from '@mapbox/react-click-to-select'
import styled, { css } from 'styled-components'
const CodeContainer = styled.span`
position: relative;
vertical-align: middle;
display: inline-block;
@@ -21,9 +23,11 @@ const StyledCode = styled.code`
padding: 0.1em 0.3em;
border-radius: 4px;
background: #eef;
font-family: Lekton;
${({ withBackground }) =>
withBackground !== false &&
css`
background: #eef;
`} font-family: Lekton;
font-size: ${({ fontSize }) => fontSize};
white-space: nowrap;

View File

@@ -1,3 +1,17 @@
export function bareLink(badgeUrl, link, title = '') {
return badgeUrl
}
export function html(badgeUrl, link, title) {
// To be more robust, this should escape the title.
const img = `<img alt="${title}" src="${badgeUrl}">`
if (link) {
return `<a href=${link}>${img}</a>`
} else {
return img
}
}
export function markdown(badgeUrl, link, title) {
const withoutLink = `![${title || ''}](${badgeUrl})`
if (link) {
@@ -73,3 +87,14 @@ export default function generateAllMarkup(badgeUrl, link, title) {
fn(badgeUrl, link, title)
)
}
export function generateMarkup({ badgeUrl, link, title, markupFormat }) {
const generatorFn = {
markdown,
rst: reStructuredText,
asciidoc: asciiDoc,
link: bareLink,
html,
}[markupFormat]
return generatorFn(badgeUrl, link, title)
}

270
package-lock.json generated
View File

@@ -1227,6 +1227,24 @@
"lazy-ass": "1.6.0"
}
},
"@emotion/cache": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-10.0.0.tgz",
"integrity": "sha512-1/sT6GNyvWmxCtJek8ZDV+b+a+NMDx8/61UTnnF3rqrTY7bLTjw+fmXO7WgUIH0owuWKxza/J/FfAWC/RU4G7A==",
"dev": true,
"requires": {
"@emotion/sheet": "0.9.2",
"@emotion/stylis": "0.8.3",
"@emotion/utils": "0.11.1",
"@emotion/weak-memoize": "0.2.2"
}
},
"@emotion/hash": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.7.1.tgz",
"integrity": "sha512-OYpa/Sg+2GDX+jibUfpZVn1YqSVRpYmTLF2eyAfrFTIJSbwyIrc+YscayoykvaOME/wV4BV0Sa0yqdMrgse6mA==",
"dev": true
},
"@emotion/is-prop-valid": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.7.3.tgz",
@@ -1242,12 +1260,49 @@
"integrity": "sha512-Qv4LTqO11jepd5Qmlp3M1YEjBumoTHcHFdgPTQ+sFlIL5myi/7xu/POwP7IRu6odBdmLXdtIs1D6TuW6kbwbbg==",
"dev": true
},
"@emotion/serialize": {
"version": "0.11.3",
"resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-0.11.3.tgz",
"integrity": "sha512-6Q+XH/7kMdHwtylwZvdkOVMydaGZ989axQ56NF7urTR7eiDMLGun//pFUy31ha6QR4C6JB+KJVhZ3AEAJm9Z1g==",
"dev": true,
"requires": {
"@emotion/hash": "0.7.1",
"@emotion/memoize": "0.7.1",
"@emotion/unitless": "0.7.3",
"@emotion/utils": "0.11.1",
"csstype": "^2.5.7"
}
},
"@emotion/sheet": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-0.9.2.tgz",
"integrity": "sha512-pVBLzIbC/QCHDKJF2E82V2H/W/B004mDFQZiyo/MSR+VC4pV5JLG0TF/zgQDFvP3fZL/5RTPGEmXlYJBMUuJ+A==",
"dev": true
},
"@emotion/stylis": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.8.3.tgz",
"integrity": "sha512-M3nMfJ6ndJMYloSIbYEBq6G3eqoYD41BpDOxreE8j0cb4fzz/5qvmqU9Mb2hzsXcCnIlGlWhS03PCzVGvTAe0Q==",
"dev": true
},
"@emotion/unitless": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.3.tgz",
"integrity": "sha512-4zAPlpDEh2VwXswwr/t8xGNDGg8RQiPxtxZ3qQEXyQsBV39ptTdESCjuBvGze1nLMVrxmTIKmnO/nAV8Tqjjzg==",
"dev": true
},
"@emotion/utils": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-0.11.1.tgz",
"integrity": "sha512-8M3VN0hetwhsJ8dH8VkVy7xo5/1VoBsDOk/T4SJOeXwTO1c4uIqVNx2qyecLFnnUWD5vvUqHQ1gASSeUN6zcTg==",
"dev": true
},
"@emotion/weak-memoize": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.2.tgz",
"integrity": "sha512-n/VQ4mbfr81aqkx/XmVicOLjviMuy02eenSdJY33SVA7S2J42EU0P1H0mOogfYedb3wXA0d/LVtBrgTSm04WEA==",
"dev": true
},
"@iamstarkov/listr-update-renderer": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@iamstarkov/listr-update-renderer/-/listr-update-renderer-0.4.1.tgz",
@@ -1352,6 +1407,24 @@
"url-template": "^2.0.8"
}
},
"@popmotion/easing": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@popmotion/easing/-/easing-1.0.1.tgz",
"integrity": "sha512-NbIEz9mZAem0F0sjg2v57LbU9Y2Xc50QEX434jYrwEQzXJuJipyUV48Qz4hjHqbB/8xiUj0JMQfRvP8K9nUbxg==",
"dev": true
},
"@popmotion/popcorn": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@popmotion/popcorn/-/popcorn-0.3.1.tgz",
"integrity": "sha512-TRhDPxfzM4CrUff2ELyzDj6xSEp5fcmbnrTO0VkmrN/TxJrAzP2H/VfIDRQQy82Yn65Vwn1ohrIi8eZLqzrCMA==",
"dev": true,
"requires": {
"@popmotion/easing": "^1.0.1",
"framesync": "^4.0.1",
"hey-listen": "^1.0.5",
"style-value-types": "^3.0.7"
}
},
"@samverschueren/stream-to-observable": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz",
@@ -1413,6 +1486,12 @@
"defer-to-connect": "^1.0.1"
}
},
"@types/invariant": {
"version": "2.2.29",
"resolved": "https://registry.npmjs.org/@types/invariant/-/invariant-2.2.29.tgz",
"integrity": "sha512-lRVw09gOvgviOfeUrKc/pmTiRZ7g7oDOU6OAutyuSHpm1/o2RaBQvRhgK8QEdu+FFuw/wnWb29A/iuxv9i8OpQ==",
"dev": true
},
"@types/node": {
"version": "10.12.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.12.tgz",
@@ -3126,6 +3205,12 @@
}
}
},
"classnames": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz",
"integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==",
"dev": true
},
"cli-cursor": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz",
@@ -3179,6 +3264,12 @@
"integrity": "sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=",
"dev": true
},
"clipboard-copy": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/clipboard-copy/-/clipboard-copy-2.0.1.tgz",
"integrity": "sha512-/JBr7ryeWwl2w33SRMYGfOZU5SWPVNtpB9oTxUzFp7olKKd2HM+cnhSMeETblJMnjgqtL581ncI/pcZX7o7Big==",
"dev": true
},
"cliui": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-4.1.0.tgz",
@@ -3743,6 +3834,18 @@
"elliptic": "^6.0.0"
}
},
"create-emotion": {
"version": "10.0.6",
"resolved": "https://registry.npmjs.org/create-emotion/-/create-emotion-10.0.6.tgz",
"integrity": "sha512-pfaSo7swLlmHxizPYF5Oi09e1uPseueX/CirE6mZpPeeoH1Yb4I2cmx0VCN6KlCRko4yKFTErorect9y0+ARZQ==",
"dev": true,
"requires": {
"@emotion/cache": "10.0.0",
"@emotion/serialize": "^0.11.3",
"@emotion/sheet": "0.9.2",
"@emotion/utils": "0.11.1"
}
},
"create-error-class": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/create-error-class/-/create-error-class-3.0.2.tgz",
@@ -3919,6 +4022,12 @@
"cssom": "0.3.x"
}
},
"csstype": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.0.tgz",
"integrity": "sha512-by8hi8BlLbowQq0qtkx54d9aN73R9oUW20HISpka5kmgsR9F7nnxgfsemuR2sdCKZh+CDNf5egW9UZMm4mgJRg==",
"dev": true
},
"currently-unhandled": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz",
@@ -4261,6 +4370,26 @@
}
}
},
"dom-helpers": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz",
"integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==",
"dev": true,
"requires": {
"@babel/runtime": "^7.1.2"
},
"dependencies": {
"@babel/runtime": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.2.0.tgz",
"integrity": "sha512-oouEibCbHMVdZSDlJBO6bZmID/zA/G/Qx3H1d3rSNPTD+L8UNKvCat7aKWSJ74zYbm5zWGh0GQN0hKj8zYFTCg==",
"dev": true,
"requires": {
"regenerator-runtime": "^0.12.0"
}
}
}
},
"dom-serializer": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.0.tgz",
@@ -5828,6 +5957,15 @@
"map-cache": "^0.2.2"
}
},
"framesync": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/framesync/-/framesync-4.0.1.tgz",
"integrity": "sha512-7dF3SXz/xMdwSHFHgj8PV2dAUCFclXWhasAb06rFvAM2pX24m9eDs6X+ikK0kx92w/uIljUi0sBINwcbl0rT2Q==",
"dev": true,
"requires": {
"hey-listen": "^1.0.5"
}
},
"fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@@ -6857,6 +6995,12 @@
"integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=",
"dev": true
},
"hey-listen": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.5.tgz",
"integrity": "sha512-O2iCNxBBGb4hOxL9tUdnoPwDYmZhQ29t5xKV74BVZNdvwCDXCpVYTJ4yoaibc1V0I8Yw3K3nwmvDpoyjnCqUaw==",
"dev": true
},
"history": {
"version": "4.7.2",
"resolved": "https://registry.npmjs.org/history/-/history-4.7.2.tgz",
@@ -7108,6 +7252,15 @@
}
}
},
"humanize-string": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/humanize-string/-/humanize-string-1.0.2.tgz",
"integrity": "sha512-PH5GBkXqFxw5+4eKaKRIkD23y6vRd/IXSl7IldyJxEXpDH9SEIXRORkBtkGni/ae2P7RVOw6Wxypd2tGXhha1w==",
"dev": true,
"requires": {
"decamelize": "^1.0.0"
}
},
"husky": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/husky/-/husky-1.3.1.tgz",
@@ -12355,6 +12508,35 @@
"integrity": "sha512-ARhBOdzS3e41FbkW/XWrTEtukqqLoK5+Z/4UeDaLuSW+39JPeFgs4gCGqsrJHVZX0fUrx//4OF0K1CUGwlIFow==",
"dev": true
},
"popmotion": {
"version": "8.5.5",
"resolved": "https://registry.npmjs.org/popmotion/-/popmotion-8.5.5.tgz",
"integrity": "sha512-YiCinpxop8dkXQJEaCvckY6ac1DeVz/MyQkL+FGV9TYJz4J04dzXE1lnFv6gZ2t2Xl2JCkOFa9vZQ9t8krWqXQ==",
"dev": true,
"requires": {
"@popmotion/easing": "^1.0.1",
"@popmotion/popcorn": "^0.3.0",
"framesync": "^4.0.0",
"hey-listen": "^1.0.5",
"style-value-types": "^3.0.6",
"stylefire": "^2.3.4",
"tslib": "^1.9.1"
}
},
"popmotion-pose": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/popmotion-pose/-/popmotion-pose-3.4.1.tgz",
"integrity": "sha512-DSQaLu/d0duGQYcOFGT4PaZVwQKvP8eG1Pc9UhlSiUiFFY22mjIRGZFIIIEg2tv37it5MoBjv8jcFMRJ0TBQTA==",
"dev": true,
"requires": {
"@popmotion/easing": "^1.0.1",
"hey-listen": "^1.0.5",
"popmotion": "^8.5.0",
"pose-core": "^2.0.0",
"style-value-types": "^3.0.6",
"tslib": "^1.9.1"
}
},
"portfinder": {
"version": "1.0.20",
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.20.tgz",
@@ -12374,6 +12556,18 @@
}
}
},
"pose-core": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/pose-core/-/pose-core-2.0.2.tgz",
"integrity": "sha512-NWR8SELdQ+zhG+xD6nHDCdX/tu3fKAFUQ61hJdBic/7WSy/ABYfqpdQIsepBxPWQhHc8WLgPhRal831K/cWrwA==",
"dev": true,
"requires": {
"@types/invariant": "^2.2.29",
"@types/node": "^10.0.5",
"hey-listen": "^1.0.5",
"tslib": "^1.9.1"
}
},
"posix-character-classes": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz",
@@ -12855,6 +13049,15 @@
"integrity": "sha512-FlsPxavEyMuR6TjVbSSywovXSEyOg6ZDj5+Z8nbsRl9EkOzAhEIcS+GLoQDC5fz/t9suhUXWmUrOBrgeUvrMxw==",
"dev": true
},
"react-input-autosize": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/react-input-autosize/-/react-input-autosize-2.2.1.tgz",
"integrity": "sha512-3+K4CD13iE4lQQ2WlF8PuV5htfmTRLH6MDnfndHM6LuBRszuXnuyIfE7nhSKt8AzRBZ50bu0sAhkNMeS5pxQQA==",
"dev": true,
"requires": {
"prop-types": "^15.5.8"
}
},
"react-is": {
"version": "16.6.3",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.6.3.tgz",
@@ -12890,6 +13093,18 @@
}
}
},
"react-pose": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/react-pose/-/react-pose-4.0.5.tgz",
"integrity": "sha512-6eNI7RZ08ji4uclLU7oz7WZCW6cdG2d4mdJ1VzKyAei7tujoXjul9UjNSpiLYyn/VnIJaMX5R48gH2mzBGPjiA==",
"dev": true,
"requires": {
"@emotion/is-prop-valid": "^0.7.3",
"hey-listen": "^1.0.5",
"popmotion-pose": "^3.4.0",
"tslib": "^1.9.1"
}
},
"react-router": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-4.3.1.tgz",
@@ -12976,6 +13191,21 @@
}
}
},
"react-select": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/react-select/-/react-select-2.2.0.tgz",
"integrity": "sha512-FOnsm/zrJ2pZvYsEfs58Xvru0SHL1jXAZTCFTWcOxmQSnRKgYuXUDFdpDiET90GLtJEF+t6BaZeD43bUH6/NZQ==",
"dev": true,
"requires": {
"classnames": "^2.2.5",
"create-emotion": "^10.0.4",
"memoize-one": "^4.0.0",
"prop-types": "^15.6.0",
"raf": "^3.4.0",
"react-input-autosize": "^2.2.1",
"react-transition-group": "^2.2.1"
}
},
"react-test-renderer": {
"version": "16.6.3",
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.6.3.tgz",
@@ -12988,6 +13218,29 @@
"scheduler": "^0.11.2"
}
},
"react-transition-group": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.5.2.tgz",
"integrity": "sha512-vwHP++S+f6KL7rg8V1mfs62+MBKtbMeZDR8KiNmD7v98Gs3UPGsDZDahPJH2PVprFW5YHJfh6cbNim3zPndaSQ==",
"dev": true,
"requires": {
"dom-helpers": "^3.3.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2",
"react-lifecycles-compat": "^3.0.4"
},
"dependencies": {
"loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"requires": {
"js-tokens": "^3.0.0 || ^4.0.0"
}
}
}
},
"read-all-stdin-sync": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/read-all-stdin-sync/-/read-all-stdin-sync-1.0.5.tgz",
@@ -14693,6 +14946,12 @@
"integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=",
"dev": true
},
"style-value-types": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/style-value-types/-/style-value-types-3.0.7.tgz",
"integrity": "sha512-7vzeicDiPNnJjvTYfJbQhZ7P3OCkXfvkJOJQ+ifFnXNTA/7KBxMZacHLvlRjM5/TtXbVdrZE6u+2nzSUSPrbSQ==",
"dev": true
},
"styled-components": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-4.1.3.tgz",
@@ -14771,6 +15030,17 @@
}
}
},
"stylefire": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/stylefire/-/stylefire-2.3.5.tgz",
"integrity": "sha512-bbXK5toSsuBuPIjSBJwX6hNVschzHHaK62RkU/rSjSIBx6be5V9WyN05Wp0a6RV1GTJfWQUud2AyFuq4xAMQAg==",
"dev": true,
"requires": {
"framesync": "^4.0.0",
"hey-listen": "^1.0.4",
"style-value-types": "^3.0.6"
}
},
"stylis": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-3.5.3.tgz",

View File

@@ -129,6 +129,7 @@
"chai-string": "^1.4.0",
"chainsmoker": "^0.1.0",
"child-process-promise": "^2.2.1",
"clipboard-copy": "^2.0.1",
"concurrently": "^4.1.0",
"danger": "^7.0.2",
"danger-plugin-no-test-shortcuts": "^2.0.0",
@@ -151,6 +152,7 @@
"fetch-ponyfill": "^6.0.0",
"fs-readfile-promise": "^3.0.1",
"got": "^9.5.0",
"humanize-string": "^1.0.2",
"husky": "^1.3.1",
"icedfrisby": "2.0.0-alpha.2",
"icedfrisby-nock": "^1.1.0",
@@ -185,7 +187,9 @@
"react": "^16.7.0",
"react-dom": "^16.7.0",
"react-modal": "^3.8.1",
"react-pose": "^4.0.4",
"react-router-dom": "^4.3.1",
"react-select": "^2.1.2",
"read-all-stdin-sync": "^1.0.5",
"redis-server": "^1.2.2",
"rimraf": "^2.6.3",

View File

@@ -6,30 +6,25 @@ const { keywords, fetch, render } = require('./azure-devops-helpers')
const documentation = `
<p>
To obtain your own badge, you need to get 3 pieces of information:
<code>ORGANIZATION</code>, <code>PROJECT_ID</code> and <code>DEFINITION_ID</code>.
A badge requires three pieces of information: <code>ORGANIZATION</code>,
<code>PROJECT_ID</code> and <code>DEFINITION_ID</code>.
</p>
<p>
First, you need to edit your build definition and look at the url:
To start, edit your build definition and look at the url:
</p>
<img
src="https://user-images.githubusercontent.com/3749820/47259976-e2d9ec80-d4b2-11e8-92cc-7c81089a7a2c.png"
alt="ORGANIZATION is after the dev.azure.com part, PROJECT_NAME is right after that, DEFINITION_ID is at the end after the id= part." />
<p>
Then, you can get the <code>PROJECT_ID</code> from the <code>PROJECT_NAME</code> using Azure DevOps REST API.
Just access to: <code>https://dev.azure.com/ORGANIZATION/_apis/projects/PROJECT_NAME</code>.
Then use the Azure DevOps REST API to translate the
<code>PROJECT_NAME</code> to a <code>PROJECT_ID</code>.
</p>
<p>
Navigate to <code>https://dev.azure.com/ORGANIZATION/_apis/projects/PROJECT_NAME</code>
</p>
<img
src="https://user-images.githubusercontent.com/3749820/47266325-1d846900-d535-11e8-9211-2ee72fb91877.png"
alt="PROJECT_ID is in the id property of the API response." />
<p>
Your badge will then have the form:
<code>https://img.shields.io/vso/build/ORGANIZATION/PROJECT_ID/DEFINITION_ID.svg</code>.
</p>
<p>
Optionally, you can specify a named branch:
<code>https://img.shields.io/vso/build/ORGANIZATION/PROJECT_ID/DEFINITION_ID/NAMED_BRANCH.svg</code>.
</p>
`
module.exports = class AzureDevOpsBuild extends BaseSvgService {