hyperapp-render
Version:
Render Hyperapp to an HTML string with SSR and Node.js streaming support
277 lines (236 loc) • 6.1 kB
JavaScript
const { isArray } = Array
const { hasOwnProperty } = Object.prototype
const styleNameCache = new Map()
const uppercasePattern = /[A-Z]/g
const msPattern = /^ms-/
// https://www.w3.org/International/questions/qa-escapes#use
const escapeRegExp = /["&'<>]/
// https://www.w3.org/TR/html/syntax.html#void-elements
const voidElements = new Set([
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'link',
'meta',
'param',
'source',
'track',
'wbr',
])
// credits to https://github.com/component/escape-html
export function escapeHtml(value) {
if (value == null) return ''
const str = '' + value
if (typeof value === 'number') {
// better performance for safe values
return str
}
const match = escapeRegExp.exec(str)
if (!match) {
return str
}
let { index } = match
let lastIndex = 0
let out = ''
for (let escape = ''; index < str.length; index++) {
switch (str.charCodeAt(index)) {
case 34: // "
escape = '"'
break
case 38: // &
escape = '&'
break
case 39: // '
escape = ''' // shorter than "'" and "'" plus supports HTML4
break
case 60: // <
escape = '<'
break
case 62: // >
escape = '>'
break
default:
continue
}
if (lastIndex !== index) {
out += str.substring(lastIndex, index)
}
lastIndex = index + 1
out += escape
}
return lastIndex !== index ? out + str.substring(lastIndex, index) : out
}
// credits to https://github.com/jorgebucaran/classcat
export function concatClassNames(value) {
if (typeof value === 'string' || typeof value === 'number') {
return value || ''
}
let out = ''
let delimiter = ''
if (isArray(value)) {
for (let i = 0; i < value.length; i++) {
const name = concatClassNames(value[i])
if (name !== '') {
out += delimiter + name
delimiter = ' '
}
}
} else {
for (const name in value) {
if (hasOwnProperty.call(value, name) && value[name]) {
out += delimiter + name
delimiter = ' '
}
}
}
return out
}
// "backgroundColor" => "background-color"
// "MozTransition" => "-moz-transition"
// "msTransition" => "-ms-transition"
function hyphenateStyleName(styleName) {
if (!styleNameCache.has(styleName)) {
const name = styleName.replace(uppercasePattern, '-$&').toLowerCase().replace(msPattern, '-ms-')
// returns 'undefined' instead of the 'Map' object in IE11
styleNameCache.set(styleName, name)
}
return styleNameCache.get(styleName)
}
export function stringifyStyles(style) {
let out = ''
let delimiter = ''
for (const name in style) {
if (hasOwnProperty.call(style, name)) {
const value = style[name]
if (value != null) {
if (name === 'cssText') {
out += delimiter + value
} else {
out += delimiter + hyphenateStyleName(name) + ':' + value
}
delimiter = ';'
}
}
}
return out
}
// https://www.w3.org/TR/html51/syntax.html#serializing-html-fragments
function renderFragment(name, props, children, stack) {
let out = ''
let footer = ''
if (name) {
out += '<' + name
for (let prop in props) {
if (hasOwnProperty.call(props, prop)) {
let value = props[prop]
if (
value != null &&
prop !== 'key' &&
prop !== 'innerHTML' &&
prop !== '__source' && // babel-plugin-transform-react-jsx-source
!(prop[0] === 'o' && prop[1] === 'n')
) {
if (prop === 'class' || prop === 'className') {
prop = 'class'
value = concatClassNames(value) || false
} else if (prop === 'style' && typeof value === 'object') {
value = stringifyStyles(value) || false
}
if (value !== false) {
out += ' ' + prop
if (value !== true) {
out += '="' + escapeHtml(value) + '"'
}
}
}
}
}
if (voidElements.has(name)) {
out += '/>'
} else {
out += '>'
footer = '</' + name + '>'
}
}
if (children.length > 0) {
stack.push({
childIndex: 0,
children,
footer,
})
} else {
const { innerHTML } = props
if (innerHTML != null) {
out += innerHTML
}
out += footer
}
return out
}
function resolveNode(node, state, actions) {
if (typeof node === 'function') {
return resolveNode(node(state, actions), state, actions)
}
if (node && node.type === 2) {
return resolveNode(node.lazy.view(node.lazy), state, actions)
}
return node
}
export function renderer(view, state, actions) {
const stack = [
{
childIndex: 0,
children: [view],
footer: '',
},
]
let end = false
return (bytes) => {
if (end) {
return null
}
let out = ''
while (out.length < bytes) {
if (stack.length === 0) {
end = true
break
}
const frame = stack[stack.length - 1]
if (frame.childIndex >= frame.children.length) {
out += frame.footer
stack.pop()
} else {
const node = resolveNode(frame.children[frame.childIndex++], state, actions)
if (node != null && typeof node !== 'boolean') {
if (isArray(node)) {
stack.push({
childIndex: 0,
children: node,
footer: '',
})
} else if (node.type === 3) {
out += escapeHtml(node.tag || node.name)
} else if (typeof node === 'object') {
out += renderFragment(
node.tag || node.nodeName,
node.props || node.attributes,
node.children,
stack,
)
} else {
out += escapeHtml(node)
}
}
}
}
return out
}
}
export function renderToString(view, state, actions) {
return renderer(view, state, actions)(Infinity)
}