badge-maker
Version:
Shields.io badge library
992 lines (903 loc) • 25.1 kB
JavaScript
import anafanafo from 'anafanafo'
import { brightness } from './color.js'
import { XmlElement, ElementList } from './xml.js'
// https://github.com/badges/shields/pull/1132
const FONT_SCALE_UP_FACTOR = 10
const FONT_SCALE_DOWN_VALUE = 'scale(.1)'
const FONT_FAMILY = 'Verdana,Geneva,DejaVu Sans,sans-serif'
const WIDTH_FONT = '11px Verdana'
const SOCIAL_FONT_FAMILY = 'Helvetica Neue,Helvetica,Arial,sans-serif'
function capitalize(s) {
return `${s.charAt(0).toUpperCase()}${s.slice(1)}`
}
function colorsForBackground(color) {
const brightnessThreshold = 0.69
if (brightness(color) <= brightnessThreshold) {
return { textColor: '#fff', shadowColor: '#010101' }
} else {
return { textColor: '#333', shadowColor: '#ccc' }
}
}
function roundUpToOdd(val) {
return val % 2 === 0 ? val + 1 : val
}
function preferredWidthOf(str, options) {
// Increase chances of pixel grid alignment.
return roundUpToOdd(anafanafo(str, options) | 0)
}
function createAccessibleText({ label, message }) {
const labelPrefix = label ? `${label}: ` : ''
return labelPrefix + message
}
function hasLinks({ links }) {
const [leftLink, rightLink] = links || []
const hasLeftLink = leftLink && leftLink.length
const hasRightLink = rightLink && rightLink.length
const hasLink = hasLeftLink && hasRightLink
return { hasLink, hasLeftLink, hasRightLink }
}
function shouldWrapBodyWithLink({ links }) {
const { hasLeftLink, hasRightLink } = hasLinks({ links })
return hasLeftLink && !hasRightLink
}
function getLogoElement({ logo, horizPadding, badgeHeight, logoWidth }) {
const logoHeight = 14
if (!logo) return ''
return new XmlElement({
name: 'image',
attrs: {
x: horizPadding,
y: 0.5 * (badgeHeight - logoHeight),
width: logoWidth,
height: logoHeight,
href: logo,
},
})
}
function renderBadge(
{ links, leftWidth, rightWidth, height, accessibleText },
content,
) {
const width = leftWidth + rightWidth
const leftLink = links[0]
const { hasLink } = hasLinks({ links })
const title = hasLink
? ''
: new XmlElement({ name: 'title', content: [accessibleText] })
const body = shouldWrapBodyWithLink({ links })
? new XmlElement({
name: 'a',
content,
attrs: { target: '_blank', href: leftLink },
})
: new ElementList({ content })
const svgAttrs = {
xmlns: 'http://www.w3.org/2000/svg',
width,
height,
}
if (!hasLink) {
svgAttrs.role = 'img'
svgAttrs['aria-label'] = accessibleText
}
const svg = new XmlElement({
name: 'svg',
content: [title, body],
attrs: svgAttrs,
})
return svg.render()
}
class Badge {
static get height() {
throw new Error('Not implemented')
}
static get verticalMargin() {
throw new Error('Not implemented')
}
static get shadow() {
throw new Error('Not implemented')
}
constructor({
label,
message,
links,
logo,
logoWidth,
logoPadding,
color = '#4c1',
labelColor,
idSuffix = '',
}) {
const horizPadding = 5
const hasLogo = !!logo
const totalLogoWidth = logoWidth + logoPadding
const accessibleText = createAccessibleText({ label, message })
const hasLabel = label.length || labelColor
if (labelColor == null) {
labelColor = '#555'
}
labelColor = hasLabel || hasLogo ? labelColor : color
const labelMargin = totalLogoWidth + 1
const labelWidth = label.length
? preferredWidthOf(label, { font: WIDTH_FONT })
: 0
const leftWidth = hasLabel
? labelWidth + 2 * horizPadding + totalLogoWidth
: 0
const messageWidth = preferredWidthOf(message, { font: WIDTH_FONT })
let messageMargin = leftWidth - (message.length ? 1 : 0)
if (!hasLabel) {
if (hasLogo) {
messageMargin = messageMargin + totalLogoWidth + horizPadding
} else {
messageMargin = messageMargin + 1
}
}
let rightWidth = messageWidth + 2 * horizPadding
if (hasLogo && !hasLabel) {
rightWidth += totalLogoWidth + (message.length ? horizPadding - 1 : 0)
}
const width = leftWidth + rightWidth
this.horizPadding = horizPadding
this.labelMargin = labelMargin
this.messageMargin = messageMargin
this.links = links
this.labelWidth = labelWidth
this.messageWidth = messageWidth
this.leftWidth = leftWidth
this.rightWidth = rightWidth
this.width = width
this.labelColor = labelColor
this.color = color
this.label = label
this.message = message
this.accessibleText = accessibleText
this.idSuffix = idSuffix
this.logoElement = getLogoElement({
logo,
horizPadding,
badgeHeight: this.constructor.height,
logoWidth,
})
this.foregroundGroupElement = this.getForegroundGroupElement()
}
static render(params) {
return new this(params).render()
}
getTextElement({ leftMargin, content, link, color, textWidth, linkWidth }) {
if (!content.length) return ''
const { textColor, shadowColor } = colorsForBackground(color)
const x =
FONT_SCALE_UP_FACTOR * (leftMargin + 0.5 * textWidth + this.horizPadding)
const text = new XmlElement({
name: 'text',
content: [content],
attrs: {
x,
y: 140 + this.constructor.verticalMargin,
transform: FONT_SCALE_DOWN_VALUE,
fill: textColor,
textLength: FONT_SCALE_UP_FACTOR * textWidth,
},
})
const shadowText = new XmlElement({
name: 'text',
content: [content],
attrs: {
'aria-hidden': 'true',
x,
y: 150 + this.constructor.verticalMargin,
fill: shadowColor,
'fill-opacity': '.3',
transform: FONT_SCALE_DOWN_VALUE,
textLength: FONT_SCALE_UP_FACTOR * textWidth,
},
})
const shadow = this.constructor.shadow ? shadowText : ''
if (!link) {
return new ElementList({ content: [shadow, text] })
}
const rect = new XmlElement({
name: 'rect',
attrs: {
width: linkWidth,
x: leftMargin > 1 ? leftMargin + 1 : 0,
height: this.constructor.height,
fill: 'rgba(0,0,0,0)',
},
})
return new XmlElement({
name: 'a',
content: [rect, shadow, text],
attrs: { target: '_blank', href: link },
})
}
getLabelElement() {
const leftLink = this.links[0]
return this.getTextElement({
leftMargin: this.labelMargin,
content: this.label,
link: !shouldWrapBodyWithLink({ links: this.links })
? leftLink
: undefined,
color: this.labelColor,
textWidth: this.labelWidth,
linkWidth: this.leftWidth,
})
}
getMessageElement() {
const rightLink = this.links[1]
return this.getTextElement({
leftMargin: this.messageMargin,
content: this.message,
link: rightLink,
color: this.color,
textWidth: this.messageWidth,
linkWidth: this.rightWidth,
})
}
getClipPathElement(rx) {
return new XmlElement({
name: 'clipPath',
content: [
new XmlElement({
name: 'rect',
attrs: {
width: this.width,
height: this.constructor.height,
rx,
fill: '#fff',
},
}),
],
attrs: { id: `r${this.idSuffix}` },
})
}
getBackgroundGroupElement({ withGradient, attrs }) {
const leftRect = new XmlElement({
name: 'rect',
attrs: {
width: this.leftWidth,
height: this.constructor.height,
fill: this.labelColor,
},
})
const rightRect = new XmlElement({
name: 'rect',
attrs: {
x: this.leftWidth,
width: this.rightWidth,
height: this.constructor.height,
fill: this.color,
},
})
const gradient = new XmlElement({
name: 'rect',
attrs: {
width: this.width,
height: this.constructor.height,
fill: `url(#s${this.idSuffix})`,
},
})
const content = withGradient
? [leftRect, rightRect, gradient]
: [leftRect, rightRect]
return new XmlElement({ name: 'g', content, attrs })
}
getForegroundGroupElement() {
return new XmlElement({
name: 'g',
content: [
this.logoElement,
this.getLabelElement(),
this.getMessageElement(),
],
attrs: {
fill: '#fff',
'text-anchor': 'middle',
'font-family': FONT_FAMILY,
'text-rendering': 'geometricPrecision',
'font-size': 110,
},
})
}
render() {
throw new Error('Not implemented')
}
}
class Plastic extends Badge {
static get height() {
return 18
}
static get verticalMargin() {
return -10
}
static get shadow() {
return true
}
render() {
const gradient = new XmlElement({
name: 'linearGradient',
content: [
new XmlElement({
name: 'stop',
attrs: { offset: 0, 'stop-color': '#fff', 'stop-opacity': '.7' },
}),
new XmlElement({
name: 'stop',
attrs: { offset: '.1', 'stop-color': '#aaa', 'stop-opacity': '.1' },
}),
new XmlElement({
name: 'stop',
attrs: { offset: '.9', 'stop-color': '#000', 'stop-opacity': '.3' },
}),
new XmlElement({
name: 'stop',
attrs: { offset: 1, 'stop-color': '#000', 'stop-opacity': '.5' },
}),
],
attrs: { id: `s${this.idSuffix}`, x2: 0, y2: '100%' },
})
const clipPath = this.getClipPathElement(4)
const backgroundGroup = this.getBackgroundGroupElement({
withGradient: true,
attrs: { 'clip-path': `url(#r${this.idSuffix})` },
})
return renderBadge(
{
links: this.links,
leftWidth: this.leftWidth,
rightWidth: this.rightWidth,
accessibleText: this.accessibleText,
height: this.constructor.height,
},
[gradient, clipPath, backgroundGroup, this.foregroundGroupElement],
)
}
}
class Flat extends Badge {
static get height() {
return 20
}
static get verticalMargin() {
return 0
}
static get shadow() {
return true
}
render() {
const gradient = new XmlElement({
name: 'linearGradient',
content: [
new XmlElement({
name: 'stop',
attrs: { offset: 0, 'stop-color': '#bbb', 'stop-opacity': '.1' },
}),
new XmlElement({
name: 'stop',
attrs: { offset: 1, 'stop-opacity': '.1' },
}),
],
attrs: { id: `s${this.idSuffix}`, x2: 0, y2: '100%' },
})
const clipPath = this.getClipPathElement(3)
const backgroundGroup = this.getBackgroundGroupElement({
withGradient: true,
attrs: { 'clip-path': `url(#r${this.idSuffix})` },
})
return renderBadge(
{
links: this.links,
leftWidth: this.leftWidth,
rightWidth: this.rightWidth,
accessibleText: this.accessibleText,
height: this.constructor.height,
},
[gradient, clipPath, backgroundGroup, this.foregroundGroupElement],
)
}
}
class FlatSquare extends Badge {
static get height() {
return 20
}
static get verticalMargin() {
return 0
}
static get shadow() {
return false
}
render() {
const backgroundGroup = this.getBackgroundGroupElement({
withGradient: false,
attrs: { 'shape-rendering': 'crispEdges' },
})
return renderBadge(
{
links: this.links,
leftWidth: this.leftWidth,
rightWidth: this.rightWidth,
accessibleText: this.accessibleText,
height: this.constructor.height,
},
[backgroundGroup, this.foregroundGroupElement],
)
}
}
function social({
label,
message,
links = [],
logo,
logoWidth,
logoPadding,
color = '#4c1',
labelColor = '#555',
idSuffix = '',
}) {
// Social label is styled with a leading capital. Convert to caps here so
// width can be measured using the correct characters.
label = capitalize(label)
const externalHeight = 20
const internalHeight = 19
const labelHorizPadding = 5
const messageHorizPadding = 4
const horizGutter = 6
const totalLogoWidth = logoWidth + logoPadding
const hasMessage = message.length
const font = 'bold 11px Helvetica'
const labelTextWidth = preferredWidthOf(label, { font })
const messageTextWidth = preferredWidthOf(message, { font })
const labelRectWidth = labelTextWidth + totalLogoWidth + 2 * labelHorizPadding
const messageRectWidth = messageTextWidth + 2 * messageHorizPadding
const [leftLink, rightLink] = links
const { hasLeftLink, hasRightLink, hasLink } = hasLinks({ links })
const accessibleText = createAccessibleText({ label, message })
function getMessageBubble() {
if (!hasMessage) return ''
const messageBubbleMainX = labelRectWidth + horizGutter + 0.5
const messageBubbleNotchX = labelRectWidth + horizGutter
const content = [
new XmlElement({
name: 'rect',
attrs: {
x: messageBubbleMainX,
y: 0.5,
width: messageRectWidth,
height: internalHeight,
rx: 2,
fill: '#fafafa',
},
}),
new XmlElement({
name: 'rect',
attrs: {
x: messageBubbleNotchX,
y: 7.5,
width: 0.5,
height: 5,
stroke: '#fafafa',
},
}),
new XmlElement({
name: 'path',
attrs: {
d: `M${messageBubbleMainX} 6.5 l-3 3v1 l3 3`,
fill: '#fafafa',
},
}),
]
return new ElementList({ content })
}
function getLabelText() {
const labelTextX =
FONT_SCALE_UP_FACTOR *
(totalLogoWidth + labelTextWidth / 2 + labelHorizPadding)
const labelTextLength = FONT_SCALE_UP_FACTOR * labelTextWidth
const shouldWrapWithLink = hasLeftLink && !shouldWrapBodyWithLink({ links })
const rect = new XmlElement({
name: 'rect',
attrs: {
id: `llink${idSuffix}`,
stroke: '#d5d5d5',
fill: `url(#a${idSuffix})`,
x: '.5',
y: '.5',
width: labelRectWidth,
height: internalHeight,
rx: 2,
},
})
const shadow = new XmlElement({
name: 'text',
content: [label],
attrs: {
'aria-hidden': 'true',
x: labelTextX,
y: 150,
fill: '#fff',
transform: FONT_SCALE_DOWN_VALUE,
textLength: labelTextLength,
},
})
const text = new XmlElement({
name: 'text',
content: [label],
attrs: {
x: labelTextX,
y: 140,
transform: FONT_SCALE_DOWN_VALUE,
textLength: labelTextLength,
},
})
return shouldWrapWithLink
? new XmlElement({
name: 'a',
content: [shadow, text, rect],
attrs: { target: '_blank', href: leftLink },
})
: new ElementList({ content: [rect, shadow, text] })
}
function getMessageText() {
if (!hasMessage) return ''
const messageTextX =
FONT_SCALE_UP_FACTOR *
(labelRectWidth + horizGutter + messageRectWidth / 2)
const messageTextLength = FONT_SCALE_UP_FACTOR * messageTextWidth
const rect = new XmlElement({
name: 'rect',
attrs: {
width: messageRectWidth + 1,
x: labelRectWidth + horizGutter,
height: internalHeight + 1,
fill: 'rgba(0,0,0,0)',
},
})
const shadow = new XmlElement({
name: 'text',
content: [message],
attrs: {
'aria-hidden': 'true',
x: messageTextX,
y: 150,
fill: '#fff',
transform: FONT_SCALE_DOWN_VALUE,
textLength: messageTextLength,
},
})
const text = new XmlElement({
name: 'text',
content: [message],
attrs: {
id: `rlink${idSuffix}`,
x: messageTextX,
y: 140,
transform: FONT_SCALE_DOWN_VALUE,
textLength: messageTextLength,
},
})
return hasRightLink
? new XmlElement({
name: 'a',
content: [rect, shadow, text],
attrs: { target: '_blank', href: rightLink },
})
: new ElementList({ content: [shadow, text] })
}
const style = new XmlElement({
name: 'style',
content: [
`a:hover #llink${idSuffix}{fill:url(#b${idSuffix});stroke:#ccc}a:hover #rlink${idSuffix}{fill:#4183c4}`,
],
})
const gradients = new ElementList({
content: [
new XmlElement({
name: 'linearGradient',
content: [
new XmlElement({
name: 'stop',
attrs: {
offset: 0,
'stop-color': '#fcfcfc',
'stop-opacity': 0,
},
}),
new XmlElement({
name: 'stop',
attrs: { offset: 1, 'stop-opacity': '.1' },
}),
],
attrs: { id: `a${idSuffix}`, x2: 0, y2: '100%' },
}),
new XmlElement({
name: 'linearGradient',
content: [
new XmlElement({
name: 'stop',
attrs: { offset: 0, 'stop-color': '#ccc', 'stop-opacity': '.1' },
}),
new XmlElement({
name: 'stop',
attrs: { offset: 1, 'stop-opacity': '.1' },
}),
],
attrs: { id: `b${idSuffix}`, x2: 0, y2: '100%' },
}),
],
})
const labelRect = new XmlElement({
name: 'rect',
attrs: {
stroke: 'none',
fill: '#fcfcfc',
x: 0.5,
y: 0.5,
width: labelRectWidth,
height: internalHeight,
rx: 2,
},
})
const messageBubble = getMessageBubble()
const labelText = getLabelText()
const messageText = getMessageText()
const backgroundGroup = new XmlElement({
name: 'g',
content: [labelRect, messageBubble],
attrs: { stroke: '#d5d5d5' },
})
const foregroundGroup = new XmlElement({
name: 'g',
content: [labelText, messageText],
attrs: {
'aria-hidden': `${!hasLink}`,
fill: '#333',
'text-anchor': 'middle',
'font-family': SOCIAL_FONT_FAMILY,
'text-rendering': 'geometricPrecision',
'font-weight': 700,
'font-size': '110px',
'line-height': '14px',
},
})
const logoElement = getLogoElement({
logo,
horizPadding: labelHorizPadding,
badgeHeight: externalHeight,
logoWidth,
})
return renderBadge(
{
links,
leftWidth: labelRectWidth + 1,
rightWidth: hasMessage ? horizGutter + messageRectWidth : 0,
accessibleText,
height: externalHeight,
},
[style, gradients, backgroundGroup, logoElement, foregroundGroup],
)
}
function forTheBadge({
label,
message,
links,
logo,
logoWidth,
color = '#4c1',
labelColor,
}) {
const FONT_SIZE = 10
const BADGE_HEIGHT = 28
const TEXT_MARGIN = 12
const LOGO_MARGIN = 9
const LOGO_TEXT_GUTTER = 6
const LETTER_SPACING = 1.25
// Prepare content. For the Badge is styled in all caps. It's important to to
// convert to uppercase first so the widths can be measured using the correct
// symbols.
label = label.toUpperCase()
message = message.toUpperCase()
const [leftLink, rightLink] = links
const { hasLeftLink, hasRightLink } = hasLinks({ links })
const outLabelColor = labelColor || '#555'
// Compute text width.
// TODO: This really should count the symbols rather than just using `.length`.
// https://mathiasbynens.be/notes/javascript-unicode
// This is not using `preferredWidthOf()` as it tends to produce larger
// inconsistencies in the letter spacing. The badges look fine, however if you
// replace `textLength` with `letterSpacing` in the rendered SVG, you can see
// the discrepancy. Ideally, swapping out `textLength` for `letterSpacing`
// should not affect the appearance.
const labelTextWidth = label.length
? (anafanafo(label, { font: `${FONT_SIZE}px Verdana` }) | 0) +
LETTER_SPACING * label.length
: 0
const messageTextWidth = message.length
? (anafanafo(message, { font: `bold ${FONT_SIZE}px Verdana` }) | 0) +
LETTER_SPACING * message.length
: 0
// Compute horizontal layout.
// If a `labelColor` is set, the logo is always set against it, even when
// there is no label. When `needsLabelRect` is true, render a label rect and a
// message rect; when false, only a message rect.
const hasLabel = Boolean(label.length)
const noText = !hasLabel && !message
const needsLabelRect = hasLabel || (logo && labelColor)
const gutter = noText ? LOGO_TEXT_GUTTER - LOGO_MARGIN : LOGO_TEXT_GUTTER
let logoMinX, labelTextMinX
if (logo) {
logoMinX = LOGO_MARGIN
labelTextMinX = logoMinX + logoWidth + gutter
} else {
labelTextMinX = TEXT_MARGIN
}
let labelRectWidth, messageTextMinX, messageRectWidth
if (needsLabelRect) {
if (hasLabel) {
labelRectWidth = labelTextMinX + labelTextWidth + TEXT_MARGIN
} else {
labelRectWidth = 2 * LOGO_MARGIN + logoWidth
}
messageTextMinX = labelRectWidth + TEXT_MARGIN
messageRectWidth = 2 * TEXT_MARGIN + messageTextWidth
} else {
if (logo) {
messageTextMinX = TEXT_MARGIN + logoWidth + gutter
messageRectWidth = 2 * TEXT_MARGIN + logoWidth + gutter + messageTextWidth
} else {
messageTextMinX = TEXT_MARGIN
messageRectWidth = 2 * TEXT_MARGIN + messageTextWidth
}
}
const logoElement = getLogoElement({
logo,
horizPadding: logoMinX,
badgeHeight: BADGE_HEIGHT,
logoWidth,
})
function getLabelElement() {
const { textColor } = colorsForBackground(outLabelColor)
const midX = labelTextMinX + 0.5 * labelTextWidth
const text = new XmlElement({
name: 'text',
content: [label],
attrs: {
transform: FONT_SCALE_DOWN_VALUE,
x: FONT_SCALE_UP_FACTOR * midX,
y: 175,
textLength: FONT_SCALE_UP_FACTOR * labelTextWidth,
fill: textColor,
},
})
if (hasLeftLink && !shouldWrapBodyWithLink({ links })) {
const rect = new XmlElement({
name: 'rect',
attrs: {
width: labelRectWidth,
height: BADGE_HEIGHT,
fill: 'rgba(0,0,0,0)',
},
})
return new XmlElement({
name: 'a',
content: [rect, text],
attrs: {
target: '_blank',
href: leftLink,
},
})
} else {
return text
}
}
function getMessageElement() {
const { textColor } = colorsForBackground(color)
const midX = messageTextMinX + 0.5 * messageTextWidth
const text = new XmlElement({
name: 'text',
content: [message],
attrs: {
transform: FONT_SCALE_DOWN_VALUE,
x: FONT_SCALE_UP_FACTOR * midX,
y: 175,
textLength: FONT_SCALE_UP_FACTOR * messageTextWidth,
fill: textColor,
'font-weight': 'bold',
},
})
if (hasRightLink) {
const rect = new XmlElement({
name: 'rect',
attrs: {
width: messageRectWidth,
height: BADGE_HEIGHT,
x: labelRectWidth || 0,
fill: 'rgba(0,0,0,0)',
},
})
return new XmlElement({
name: 'a',
content: [rect, text],
attrs: {
target: '_blank',
href: rightLink,
},
})
} else {
return text
}
}
let backgroundContent
if (needsLabelRect) {
backgroundContent = [
new XmlElement({
name: 'rect',
attrs: {
width: labelRectWidth,
height: BADGE_HEIGHT,
fill: outLabelColor,
},
}),
new XmlElement({
name: 'rect',
attrs: {
x: labelRectWidth,
width: messageRectWidth,
height: BADGE_HEIGHT,
fill: color,
},
}),
]
} else {
backgroundContent = [
new XmlElement({
name: 'rect',
attrs: {
width: messageRectWidth,
height: BADGE_HEIGHT,
fill: color,
},
}),
]
}
const backgroundGroup = new XmlElement({
name: 'g',
content: backgroundContent,
attrs: {
'shape-rendering': 'crispEdges',
},
})
const foregroundGroup = new XmlElement({
name: 'g',
content: [
logoElement,
hasLabel ? getLabelElement() : '',
getMessageElement(),
],
attrs: {
fill: '#fff',
'text-anchor': 'middle',
'font-family': FONT_FAMILY,
'text-rendering': 'geometricPrecision',
'font-size': FONT_SCALE_UP_FACTOR * FONT_SIZE,
},
})
// Render.
return renderBadge(
{
links,
leftWidth: labelRectWidth || 0,
rightWidth: messageRectWidth,
accessibleText: createAccessibleText({ label, message }),
height: BADGE_HEIGHT,
},
[backgroundGroup, foregroundGroup],
)
}
export default {
plastic: params => Plastic.render(params),
flat: params => Flat.render(params),
'flat-square': params => FlatSquare.render(params),
social,
'for-the-badge': forTheBadge,
}