diff --git a/badge-maker/CHANGELOG.md b/badge-maker/CHANGELOG.md index 2d99050baff6c..3cf6c3c37f06f 100644 --- a/badge-maker/CHANGELOG.md +++ b/badge-maker/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 4.0.0 [WIP] + +- Drop compatibility with Node 10 + ## 3.3.1 - Improve font measuring in for-the-badge and social styles diff --git a/badge-maker/lib/badge-renderers.js b/badge-maker/lib/badge-renderers.js index 6aba74624ac16..9d841f228d9fd 100644 --- a/badge-maker/lib/badge-renderers.js +++ b/badge-maker/lib/badge-renderers.js @@ -2,23 +2,22 @@ const anafanafo = require('anafanafo') const { brightness } = require('./color') -const { XmlElement, escapeXml } = require('./xml') +const { XmlElement, ElementList } = require('./xml') // 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 fontFamily = `font-family="${FONT_FAMILY}"` -const socialFontFamily = - 'font-family="Helvetica Neue,Helvetica,Arial,sans-serif"' -const brightnessThreshold = 0.69 +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 { @@ -53,127 +52,61 @@ function shouldWrapBodyWithLink({ links }) { return hasLeftLink && !hasRightLink } -function renderAriaAttributes({ accessibleText, links }) { - const { hasLink } = hasLinks({ links }) - return hasLink ? '' : `role="img" aria-label="${escapeXml(accessibleText)}"` +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, + 'xlink:href': logo, + }, + }) } -function renderTitle({ accessibleText, links }) { +function renderBadge( + { links, leftWidth, rightWidth, height, accessibleText }, + content +) { + const width = leftWidth + rightWidth + const leftLink = links[0] const { hasLink } = hasLinks({ links }) - return hasLink ? '' : `${escapeXml(accessibleText)}` -} -function renderLogo({ - logo, - badgeHeight, - horizPadding, - logoWidth = 14, - logoPadding = 0, -}) { - if (logo) { - const logoHeight = 14 - const y = (badgeHeight - logoHeight) / 2 - const x = horizPadding - return { - hasLogo: true, - totalLogoWidth: logoWidth + logoPadding, - renderedLogo: ``, - } - } else { - return { hasLogo: false, totalLogoWidth: 0, renderedLogo: '' } - } -} + const title = hasLink + ? '' + : new XmlElement({ name: 'title', content: [accessibleText] }) -function renderLink({ - link, - height, - textLength, - horizPadding, - leftMargin, - renderedText, -}) { - const rectHeight = height - const rectWidth = textLength + horizPadding * 2 - const rectX = leftMargin > 1 ? leftMargin + 1 : 0 - return ` - - ${renderedText} - ` -} + const body = shouldWrapBodyWithLink({ links }) + ? new XmlElement({ + name: 'a', + content, + attrs: { target: '_blank', 'xlink:href': leftLink }, + }) + : new ElementList({ content }) -function renderText({ - leftMargin, - horizPadding = 0, - content, - link, - height, - verticalMargin = 0, - shadow = false, - color, -}) { - if (!content.length) { - return { renderedText: '', width: 0 } + const svgAttrs = { + xmlns: 'http://www.w3.org/2000/svg', + 'xmlns:xlink': 'http://www.w3.org/1999/xlink', + width, + height, } - - const textLength = preferredWidthOf(content, { font: '11px Verdana' }) - const escapedContent = escapeXml(content) - - const shadowMargin = 150 + verticalMargin - const textMargin = 140 + verticalMargin - - const outTextLength = 10 * textLength - const x = 10 * (leftMargin + 0.5 * textLength + horizPadding) - - let renderedText = '' - const { textColor, shadowColor } = colorsForBackground(color) - if (shadow) { - renderedText = `` + if (!hasLink) { + svgAttrs.role = 'img' + svgAttrs['aria-label'] = accessibleText } - renderedText += `${escapedContent}` - return { - renderedText: link - ? renderLink({ - link, - height, - textLength, - horizPadding, - leftMargin, - renderedText, - }) - : renderedText, - width: textLength, - } -} - -function renderBadge( - { links, leftWidth, rightWidth, height, accessibleText }, - main -) { - const width = leftWidth + rightWidth - const leftLink = escapeXml(links[0]) - - return ` - - - ${renderTitle({ accessibleText, links })} - ${ - shouldWrapBodyWithLink({ links }) - ? `${main}` - : main - } - ` + const svg = new XmlElement({ + name: 'svg', + content: [title, body], + attrs: svgAttrs, + }) + return svg.render() } class Badge { - static get fontFamily() { - throw new Error('Not implemented') - } - static get height() { throw new Error('Not implemented') } @@ -197,41 +130,25 @@ class Badge { labelColor, }) { const horizPadding = 5 - const { hasLogo, totalLogoWidth, renderedLogo } = renderLogo({ - logo, - badgeHeight: this.constructor.height, - horizPadding, - logoWidth, - logoPadding, - }) + const hasLogo = !!logo + const totalLogoWidth = logoWidth + logoPadding + const accessibleText = createAccessibleText({ label, message }) + const hasLabel = label.length || labelColor if (labelColor == null) { labelColor = '#555' } - - const [leftLink, rightLink] = links - labelColor = hasLabel || hasLogo ? labelColor : color - labelColor = escapeXml(labelColor) - color = escapeXml(color) const labelMargin = totalLogoWidth + 1 - - const { renderedText: renderedLabel, width: labelWidth } = renderText({ - leftMargin: labelMargin, - horizPadding, - content: label, - link: !shouldWrapBodyWithLink({ links }) && leftLink, - height: this.constructor.height, - verticalMargin: this.constructor.verticalMargin, - shadow: this.constructor.shadow, - color: labelColor, - }) - + 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) { @@ -240,18 +157,6 @@ class Badge { messageMargin = messageMargin + 1 } } - - const { renderedText: renderedMessage, width: messageWidth } = renderText({ - leftMargin: messageMargin, - horizPadding, - content: message, - link: rightLink, - height: this.constructor.height, - verticalMargin: this.constructor.verticalMargin, - shadow: this.constructor.shadow, - color, - }) - let rightWidth = messageWidth + 2 * horizPadding if (hasLogo && !hasLabel) { rightWidth += totalLogoWidth + horizPadding - 1 @@ -259,9 +164,12 @@ class Badge { const width = leftWidth + rightWidth - const accessibleText = createAccessibleText({ label, message }) - + 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 @@ -270,25 +178,174 @@ class Badge { this.label = label this.message = message this.accessibleText = accessibleText - this.renderedLogo = renderedLogo - this.renderedLabel = renderedLabel - this.renderedMessage = renderedMessage + + 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', 'xlink: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.messageColor, + 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' }, + }) + } + + 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)', + }, + }) + 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 fontFamily() { - return fontFamily - } - static get height() { return 18 } @@ -302,6 +359,36 @@ class Plastic extends Badge { } 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', x2: 0, y2: '100%' }, + }) + + const clipPath = this.getClipPathElement(4) + + const backgroundGroup = this.getBackgroundGroupElement({ + withGradient: true, + attrs: { 'clip-path': 'url(#r)' }, + }) + return renderBadge( { links: this.links, @@ -310,38 +397,12 @@ class Plastic extends Badge { accessibleText: this.accessibleText, height: this.constructor.height, }, - ` - - - - - - - - - - - - - - - - - - - ${this.renderedLogo} - ${this.renderedLabel} - ${this.renderedMessage} - ` + [gradient, clipPath, backgroundGroup, this.foregroundGroupElement] ) } } class Flat extends Badge { - static get fontFamily() { - return fontFamily - } - static get height() { return 20 } @@ -355,6 +416,28 @@ class Flat extends Badge { } 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', x2: 0, y2: '100%' }, + }) + + const clipPath = this.getClipPathElement(3) + + const backgroundGroup = this.getBackgroundGroupElement({ + withGradient: true, + attrs: { 'clip-path': 'url(#r)' }, + }) + return renderBadge( { links: this.links, @@ -363,36 +446,12 @@ class Flat extends Badge { accessibleText: this.accessibleText, height: this.constructor.height, }, - ` - - - - - - - - - - - - - - - - - ${this.renderedLogo} - ${this.renderedLabel} - ${this.renderedMessage} - ` + [gradient, clipPath, backgroundGroup, this.foregroundGroupElement] ) } } class FlatSquare extends Badge { - static get fontFamily() { - return fontFamily - } - static get height() { return 20 } @@ -406,6 +465,11 @@ class FlatSquare extends Badge { } render() { + const backgroundGroup = this.getBackgroundGroupElement({ + withGradient: false, + attrs: { 'shape-rendering': 'crispEdges' }, + }) + return renderBadge( { links: this.links, @@ -414,17 +478,7 @@ class FlatSquare extends Badge { accessibleText: this.accessibleText, height: this.constructor.height, }, - ` - - - - - - - ${this.renderedLogo} - ${this.renderedLabel} - ${this.renderedMessage} - ` + [backgroundGroup, this.foregroundGroupElement] ) } } @@ -448,13 +502,7 @@ function social({ const labelHorizPadding = 5 const messageHorizPadding = 4 const horizGutter = 6 - const { totalLogoWidth, renderedLogo } = renderLogo({ - logo, - badgeHeight: externalHeight, - horizPadding: labelHorizPadding, - logoWidth, - logoPadding, - }) + const totalLogoWidth = logoWidth + logoPadding const hasMessage = message.length const font = 'bold 11px Helvetica' @@ -463,75 +511,235 @@ function social({ const labelRectWidth = labelTextWidth + totalLogoWidth + 2 * labelHorizPadding const messageRectWidth = messageTextWidth + 2 * messageHorizPadding - let [leftLink, rightLink] = links - leftLink = escapeXml(leftLink) - rightLink = escapeXml(rightLink) + const [leftLink, rightLink] = links const { hasLeftLink, hasRightLink, hasLink } = hasLinks({ links }) const accessibleText = createAccessibleText({ label, message }) - function renderMessageBubble() { + function getMessageBubble() { + if (!hasMessage) return '' + const messageBubbleMainX = labelRectWidth + horizGutter + 0.5 const messageBubbleNotchX = labelRectWidth + horizGutter - return ` - - - - ` + 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`, + stroke: 'd5d5d5', + fill: '#fafafa', + }, + }), + ] + return new ElementList({ content }) } - function renderLabelText() { + function getLabelText() { const labelTextX = - 10 * (totalLogoWidth + labelTextWidth / 2 + labelHorizPadding) - const labelTextLength = 10 * labelTextWidth - const escapedLabel = escapeXml(label) + FONT_SCALE_UP_FACTOR * + (totalLogoWidth + labelTextWidth / 2 + labelHorizPadding) + const labelTextLength = FONT_SCALE_UP_FACTOR * labelTextWidth const shouldWrapWithLink = hasLeftLink && !shouldWrapBodyWithLink({ links }) - const rect = `` - const shadow = `` - const text = `${escapedLabel}` + const rect = new XmlElement({ + name: 'rect', + attrs: { + id: 'llink', + stroke: '#d5d5d5', + fill: 'url(#a)', + 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 - ? ` - - ${shadow} - ${text} - ${rect} - - ` - : ` - ${rect} - ${shadow} - ${text} - ` - } - - function renderMessageText() { + ? new XmlElement({ + name: 'a', + content: [shadow, text, rect], + attrs: { target: '_blank', 'xlink:href': leftLink }, + }) + : new ElementList({ content: [rect, shadow, text] }) + } + + function getMessageText() { + if (!hasMessage) return '' + const messageTextX = - 10 * (labelRectWidth + horizGutter + messageRectWidth / 2) - const messageTextLength = 10 * messageTextWidth - const escapedMessage = escapeXml(message) + FONT_SCALE_UP_FACTOR * + (labelRectWidth + horizGutter + messageRectWidth / 2) + const messageTextLength = FONT_SCALE_UP_FACTOR * messageTextWidth - const rect = `` - const shadow = `` - const text = `${escapedMessage}` + 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', + x: messageTextX, + y: 140, + transform: FONT_SCALE_DOWN_VALUE, + textLength: messageTextLength, + }, + }) return hasRightLink - ? ` - - ${rect} - ${shadow} - ${text} - - ` - : ` - ${shadow} - ${text} - ` + ? new XmlElement({ + name: 'a', + content: [rect, shadow, text], + attrs: { target: '_blank', 'xlink:href': rightLink }, + }) + : new ElementList({ content: [shadow, text] }) } + const style = new XmlElement({ + name: 'style', + content: [ + 'a:hover #llink{fill:url(#b);stroke:#ccc}a:hover #rlink{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', 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', 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, @@ -540,26 +748,7 @@ function social({ accessibleText, height: externalHeight, }, - ` - - - - - - - - - - - - ${hasMessage ? renderMessageBubble() : ''} - - ${renderedLogo} - - ${renderLabelText()} - ${hasMessage ? renderMessageText() : ''} - - ` + [style, gradients, backgroundGroup, logoElement, foregroundGroup] ) } @@ -574,7 +763,6 @@ function forTheBadge({ }) { const FONT_SIZE = 10 const BADGE_HEIGHT = 28 - const LOGO_HEIGHT = 14 const TEXT_MARGIN = 12 const LOGO_MARGIN = 9 const LOGO_TEXT_GUTTER = 6 @@ -641,15 +829,11 @@ function forTheBadge({ } } - const logoElement = new XmlElement({ - name: 'image', - attrs: { - x: logoMinX, - y: 0.5 * (BADGE_HEIGHT - LOGO_HEIGHT), - width: logoWidth, - height: LOGO_HEIGHT, - 'xlink:href': logo, - }, + const logoElement = getLogoElement({ + logo, + horizPadding: logoMinX, + badgeHeight: BADGE_HEIGHT, + logoWidth, }) function getLabelElement() { @@ -772,7 +956,7 @@ function forTheBadge({ const foregroundGroup = new XmlElement({ name: 'g', content: [ - logo ? logoElement : '', + logoElement, hasLabel ? getLabelElement() : '', getMessageElement(), ], @@ -794,7 +978,7 @@ function forTheBadge({ accessibleText: createAccessibleText({ label, message }), height: BADGE_HEIGHT, }, - [backgroundGroup.render(), foregroundGroup.render()].join('') + [backgroundGroup, foregroundGroup] ) } diff --git a/badge-maker/lib/xml.js b/badge-maker/lib/xml.js index 1d30fa501ea67..1916113c47317 100644 --- a/badge-maker/lib/xml.js +++ b/badge-maker/lib/xml.js @@ -58,7 +58,7 @@ class XmlElement { if (this.content.length > 0) { const content = this.content .map(function (el) { - if (el instanceof XmlElement) { + if (typeof el.render === 'function') { return el.render() } else { return escapeXml(el) @@ -73,4 +73,24 @@ class XmlElement { } } -module.exports = { escapeXml, stripXmlWhitespace, XmlElement } +/** + * Convenience class. Sometimes it is useful to return an object that behaves + * like an XmlElement but renders multiple XML tags (not wrapped in a ). + */ +class ElementList { + constructor({ content = [] }) { + this.content = content + } + + render() { + return this.content.reduce( + (acc, el) => + typeof el.render === 'function' + ? acc + el.render() + : acc + escapeXml(el), + '' + ) + } +} + +module.exports = { escapeXml, stripXmlWhitespace, XmlElement, ElementList }