var fs = require('fs')
var path = require('path')
var pull = require('pull-stream')
var cat = require('pull-cat')
var paramap = require('pull-paramap')
var h = require('hyperscript')
var ph = require('pull-hyperscript')
var marked = require('../vendor/ssb-marked')
var qs = require('querystring')
var u = require('./util')
var multicb = require('multicb')
var RenderMsg = require('./render-msg')
var Highlight = require('highlight.js')
var md = require('ssb-markdown')
var Diff = require('diff')
var maxWordLength = 80
var truncateWordsRe = new RegExp('[^\\s<>&"]{' + (maxWordLength+1) + ',}')
function truncateWord(word) {
var len = maxWordLength - 5 // "…[##]"
var truncatedLength = word.length - len
return word.substr(0, len) + '…[' + truncatedLength + ']'
}
function truncateWords(text) {
// truncate long words to avoid horizontal scrollbar
return text.replace(truncateWordsRe, truncateWord)
}
module.exports = Render
function MdRenderer(render) {
marked.Renderer.call(this, {})
this.render = render
}
MdRenderer.prototype = new marked.Renderer()
MdRenderer.prototype.urltransform = function (href) {
return this.render.toUrl(href)
}
MdRenderer.prototype.image = function (ref, title, text) {
if (ref[0] !== '&') return this.link(ref, title, text)
var alt = this.render.getImageAlt(ref, text)
return (/^video:/.test(text) ? h('video', {
controls: 'controls',
src: this.render.toUrl(ref),
title: u.unescapeHTML(title) || undefined
}, '\n' + alt) : /^audio:/.test(text) ? h('audio', {
controls: 'controls',
src: this.render.toUrl(ref),
title: u.unescapeHTML(title) || undefined
}, '\n' + alt) : h('img', {
src: this.render.imageUrl(ref),
alt: alt,
title: u.unescapeHTML(title) || undefined
})).outerHTML
}
MdRenderer.prototype.link = function (ref, title, text) {
var href = this.urltransform(ref)
var name = href && /^\/(&|%26)/.test(href) && (title || text)
if (name) {
href += (/\?/.test(href) ? '&' : '?') +
'filename=' + encodeURIComponent(name)
}
if (u.isRef(ref)) {
var myName = this.render.app.getNameSync(ref)
if (myName) title = title ? title + ' (' + myName + ')' : myName
}
var hrefToken = href !== false ? u.token() : undefined
var a = h('a', {
class: href === false ? 'bad' : undefined,
href: href !== false ? hrefToken : undefined,
title: title || undefined
})
text = truncateWords(text)
// text is already html-escaped
a.innerHTML = text
var html = a.outerHTML
// href is already html-escaped
if (hrefToken) html = html.replace(hrefToken, href)
var link = this.render._mentionsByLink[ref]
if (link && link.type === 'text/x-markdown') {
html += h('sup', ' [', h('a', {
href: this.render.toUrl('/markdown/' + ref),
title: 'view rendered markdown'
}, 'md'), ']').outerHTML
}
return html
}
MdRenderer.prototype.mention = function (preceding, id) {
var href = this.urltransform(id)
var myName = this.render.app.getNameSync(id)
var html = (preceding||'') + h('a', {
class: href === false ? 'bad' : undefined,
href: href !== false ? href : undefined,
title: myName || undefined,
}, id.length > 50 ? id.slice(0, 8) + '…' : id).outerHTML
var link = this.render._mentionsByLink[id]
if (link && link.type === 'text/x-markdown') {
html += h('sup', ' [', h('a', {
href: this.render.toUrl('/markdown/' + id),
title: 'view rendered markdown'
}, 'md'), ']').outerHTML
}
return html
}
MdRenderer.prototype.code = function (code, lang, escaped) {
if (this.render.opts.codeInTextareas) {
return h('div', h('textarea', {
cols: 80,
rows: u.rows(code),
style: 'font-family: monospace',
innerHTML: escaped ? code : u.escapeHTML(code)
})).outerHTML
} else {
return marked.Renderer.prototype.code.call(this, code, lang, escaped)
}
}
function lexerRenderEmoji(emoji) {
var el = this.renderer.render.emoji(emoji)
return el && el.outerHTML || el
}
function Render(app, opts) {
this.app = app
this.opts = opts
this.markedOpts = {
gfm: true,
mentions: true,
tables: true,
breaks: true,
pedantic: false,
sanitize: true,
smartLists: true,
smartypants: false,
emoji: lexerRenderEmoji,
renderer: new MdRenderer(this),
highlight: this.highlight.bind(this),
}
}
Render.prototype.emoji = function (emoji) {
var name = ':' + emoji + ':'
var link = this._mentions && this._mentions[name]
if (link && link.link) {
this.app.reverseEmojiNameCache.set(emoji, link.link)
return h('img.ssb-emoji', {
src: this.opts.img_base + link.link,
alt: name
+ (link.size != null ? ' (' + this.formatSize(link.size) + ')' : ''),
height: 17,
title: name,
})
}
return name
}
/* disabled until it can be done safely without breaking html
function fixSymbols(str) {
// Dillo doesn't do fallback fonts, so specifically render fancy characters
// with Symbola
return str.replace(/[^\u0000-\u00ff]+/, function ($0) {
return '' + $0 + ''
})
}
*/
Render.prototype.markdown = function (text, mentions, opts) {
if (!text) return ''
var ssbcMd = opts && opts.ssbcMd
var mentionsObj = this._mentions = {}
var mentionsByLink = this._mentionsByLink = {}
if (Array.isArray(mentions)) mentions.forEach(function (link) {
if (!link) return
else if (link.emoji)
mentionsObj[':' + link.name + ':'] = link
else if (link.name && link.link && link.link[0] === '@')
mentionsObj['@' + link.name] = link.link
else if (link.host === 'http://localhost:7777')
mentionsObj[link.href] = link.link
if (link.link)
mentionsByLink[link.link +
(link.query && typeof link.query.unbox === 'string' ?
'?unbox=' + link.query.unbox.replace(/\s/g, '+') : '') +
(link.key ? '#' + link.key : '')] = link
})
var self = this
var out = ssbcMd ? md.block(String(text), {
toUrl: function (ref) { return self.toUrl(ref) },
imageLink: function (ref) { return self.imageUrl(ref) }
}) : marked(u.toString(text), this.markedOpts)
delete this._mentions
delete this._mentionsByLink
return out //fixSymbols(out)
}
Render.prototype.imageUrl = function (ref) {
var m = /^blobstore:(.*)/.exec(ref)
if (m) ref = m[1]
ref = String(ref).replace(/#/, '%23')
return this.opts.img_base + ref
}
Render.prototype.getImageAlt = function (id, fallback) {
var link = this._mentionsByLink[id]
if (!link) return fallback
var name = String(u.unescapeHTML(link.name || fallback))
.replace(/^(video|audio):/, '')
return name
+ (link.type && !/\.\S+$/.test(name) ? ' [' + link.type + ']' : '')
+ (link.size != null ? ' (' + this.formatSize(link.size) + ')' : '')
}
Render.prototype.formatSize = function (size) {
if (size < 1024) return size + ' B'
size /= 1024
if (size < 1024) return size.toFixed(2) + ' KB'
size /= 1024
return size.toFixed(2) + ' MB'
}
Render.prototype.linkify = function (text, opts) {
var ellipsis = opts && opts.ellipsis
var arr = text.split(u.ssbRefEncRegex)
for (var i = 1; i < arr.length; i += 2) {
var id = arr[i]
var text = ellipsis && id.length > 8 ? id.substr(0, 8) + '…' : id
arr[i] = h('a', {href: this.toUrlEnc(id)}, text)
}
return arr
}
Render.prototype.toUrlEnc = function (href) {
var url = this.toUrl(href)
if (url) return url
try { href = decodeURIComponent(href) }
catch (e) { return false }
return this.toUrl(href)
}
Render.prototype.toUrl = function (href) {
if (!href) return href
var mentions = this._mentions
if (mentions && href in this._mentions) href = this._mentions[href]
if (/^ssb:/.test(href)) href = u.translateFromURI(href) || href
if (/^ssb-blob:\/\//.test(href)) {
return this.opts.base + 'zip/' + href.substr(11)
}
switch (href[0]) {
case '%':
var parts = href.split('?')
var hash = parts.shift()
var query = parts.join('?')
if (!u.isRef(hash)) return false
return this.opts.base +
(this.opts.encode_msgids ? encodeURIComponent(hash) : hash)
+ (query ? '?' + query : '')
case '@':
if (!u.isRef(href.replace(/\?.*/, ''))) return false
return this.opts.base + href
case '&':
var parts = href.split('#')
var hash = parts.shift()
var key = parts.shift()
var fragment = parts.join('#')
parts = hash.split('?')
hash = parts.shift()
query = parts.join('?')
if (!u.isRef(hash)) return false
return this.opts.blob_base + hash
+ (query ? '?' + query : '')
+ (key ? encodeURIComponent('#' + key) : '')
+ (fragment ? '#' + fragment : '')
case '#': return this.opts.base + 'channel/' +
encodeURIComponent(href.substr(1).toLowerCase())
case '/': return this.opts.base + href.substr(1)
case '?': return this.opts.base + 'search?q=' + encodeURIComponent(href)
}
var m = /^blobstore:(.*)/.exec(href)
if (m) return this.opts.blob_base + m[1]
if (/^javascript:/.test(href)) return false
if (!/^[a-z0-9]*:/.test(href)) return 'http://' + href
return href
}
Render.prototype.lockIcon = function () {
return '🔒'
}
Render.prototype.avatarImage = function (link, cb) {
var self = this
if (!link) return cb(), ''
if (typeof link === 'string') link = {link: link}
var img = h('img.ssb-avatar-image', {
width: 72,
alt: ' '
})
if (link.image) gotAbout(null, link)
else self.app.getAbout(link.link, gotAbout)
function gotAbout(err, about) {
if (err) console.trace(err)
img.src = about && about.image ? self.imageUrl(about.image)
: self.toUrl('/static/fallback.png')
cb()
}
return img
}
Render.prototype.prepareLink = function (link, cb) {
if (typeof link === 'string') link = {link: link}
if (link.name || !link.link) cb(null, link)
else this.app.getAbout(link.link, function (err, about) {
if (err) return cb(null, link)
link.name = about.name || about.title || (link.link.substr(0, 8) + '…')
if (link.name && link.name[0] === link.link[0]) {
link.name = link.name.substr(1)
}
cb(null, link)
})
}
Render.prototype.prepareLinks = function (links, cb) {
var self = this
if (!links) return cb()
var done = multicb({pluck: 1})
if (Array.isArray(links)) links.forEach(function (link) {
self.prepareLink(link, done())
})
done(cb)
}
Render.prototype.idLink = function (link, cb) {
var self = this
if (!link) return cb(), ''
var a = h('a', ' ')
self.prepareLink(link, function (err, link) {
if (err) return cb(err)
a.href = self.toUrl(link.link)
var sigil = link.link && link.link[0] || '@'
var name = link.name || String(link.link).substr(1, 8) + '…'
a.childNodes[0].textContent = sigil + name
cb()
})
return a
}
// %NM8tXGBBDKKcpRbbyd/5uN1p/2OtBMFDylLMDPGoq8Q=.sha256
var idRegex = /^[A-Za-z0-9._\-+=/]*[A-Za-z0-9_\-+=/]$/
Render.prototype.idLinkCopyable = function (link, cb) {
var self = this
if (!self.app.copyableIds) return idLink(link, cb)
if (!link) return cb(), ''
var a = h('a', ' ')
self.prepareLink(link, function (err, link) {
if (err) return cb(err)
a.href = self.toUrl(link.link)
var name = link.name || String(link.link).substr(1, 8) + '…'
if (idRegex.test(name)) a.childNodes[0].textContent = '@' + name
else {
a.className = 'id-copyable-link'
a.innerHTML = h('span', [
h('span.id-deemphasize', '['),
h('span.id-name', '@' + link.name),
h('span.id-deemphasize', ']'
+ '(', h('span.id-inner', link.link), ')'),
]).innerHTML
}
cb()
})
return a
}
Render.prototype.privateLine = function (opts, cb) {
var recps = opts.recps
var isAuthorRecp = opts.isAuthorRecp
var done = multicb({pluck: 1, spread: true})
var self = this
var el = h('div.recps',
opts.noLockIcon ? '' : self.lockIcon(),
!isAuthorRecp ? [
h('span', {title: 'Author is not a recipient'}, '[!]'), ' '
] : '',
Array.isArray(recps)
? recps.map(function (recp) {
return [' ', self.idLink(recp, done())]
}) : '')
done(cb)
return el
}
Render.prototype.msgIdLink = function (id, cb) {
var self = this
var el = h('span')
var a = h('a', {href: self.toUrl(id)}, id)
self.app.getMsgDecrypted(id, function (err, msg) {
if (err) return el.appendChild(u.renderError(err)), cb()
var renderMsg = new RenderMsg(self, self.app, msg, {wrap: false, serve: self.serve})
renderMsg.title(function (err, title) {
if (err) return el.appendChild(u.renderError(err)), cb()
a.childNodes[0].textContent = title
cb()
})
})
return a
}
Render.prototype.phMsgLink = function (msg) {
var self = this
return u.readNext(function (cb) {
self.app.unboxMsg(msg, function (err, msg) {
if (err) return cb(err)
var renderMsg = new RenderMsg(self, self.app, msg, {wrap: false, serve: self.serve})
renderMsg.title(function (err, title) {
if (err) return cb(err)
cb(null, ph('a', {href: self.toUrl(msg.key)}, u.escapeHTML(title)))
})
})
})
}
Render.prototype.renderMsg = function (msg, opts, cb) {
var self = this
self.app.filterMsg(msg, opts, function (err, show) {
if (err) return cb(err)
if (show) new RenderMsg(self, self.app, msg, opts).message(cb)
else cb(null, '')
})
}
Render.prototype.renderFeeds = function (opts) {
var self = this
var limit = Number(opts.limit)
return pull(
paramap(function (msg, cb) {
self.renderMsg(msg, opts, cb)
}, 4),
pull.filter(Boolean),
limit && pull.take(limit)
)
}
Render.prototype.gitCommitBody = function (body) {
if (!body) return ''
var isMarkdown = !/^# Conflicts:$/m.test(body)
return isMarkdown
? h('div', {innerHTML: this.markdown('\n' + body)})
: h('pre', this.linkify('\n' + body))
}
Render.prototype.getName = function (id, cb) {
// TODO: consolidate the get name/link functions
var self = this
switch (id && id[0]) {
case '%':
return self.app.getMsgDecrypted(id, function (err, msg) {
if (err && err.name == 'NotFoundError')
return cb(null, String(id).substring(0, 8) + '…(missing)')
if (err) return fallback()
new RenderMsg(self, self.app, msg, {wrap: false}).title(cb)
})
case '@': // fallthrough
case '&':
return self.app.getAbout(id, function (err, about) {
if (err || !about || !about.name) return fallback()
cb(null, about.name)
})
default:
return cb(null, String(id))
}
function fallback() {
cb(null, String(id).substr(0, 8) + '…')
}
}
Render.prototype.getNameLink = function (id, opts, cb) {
if (!cb && typeof opts === 'function') cb = opts, opts = null
if (typeof id === 'object' && id !== null && typeof id.link === 'string') {
var link = id
id = link.link
if (typeof link.name === 'string') {
return cb(null, h('a', {href: self.toUrl(id)},
u.truncate(link.name, length)))
}
}
var length = opts && opts.length || Infinity
var self = this
self.getName(id, function (err, name) {
if (err) return cb(err)
cb(null, h('a', {href: self.toUrl(id)}, u.truncate(name, length)))
})
}
Render.prototype.npmAuthorLink = function (author) {
if (!author) return
var url = u.ifString(author.url)
var email = u.ifString(author.email)
var name = u.ifString(author.name)
var title
if (!url && u.isRef(name)) url = name, name = null
if (!url && !email) return name || JSON.stringify(author)
if (!url && email) url = 'mailto:' + email, email = null
if (!name && email) name = email, email = null
var feed = u.isRef(url) && url[0] === '@' && url
if (feed && name) title = this.app.getNameSync(feed)
if (feed && name && name[0] != '@') name = '@' + name
if (feed && !name) name = this.app.getNameSync(feed) // TODO: async
if (url && !name) name = url
var secondaryLink = email && h('a', {href: this.toUrl('mailto:' + email)}, email)
return [
h('a', {href: this.toUrl(url), title: title}, name),
secondaryLink ? [' (', secondaryLink, ')'] : ''
]
}
// auto-highlight is slow
var useAutoHighlight = false
Render.prototype.highlight = function (code, lang) {
if (code.length > 100000) return u.escapeHTML(code)
if (!lang && /^#!\/bin\/[^\/]*sh$/m.test(code)) lang = 'sh'
try {
return lang
? Highlight.highlight(lang, code).value
: useAutoHighlight
? Highlight.highlightAuto(code).value
: u.escapeHTML(code)
} catch(e) {
if (!/^Unknown language/.test(e.message)) console.trace(e)
return u.escapeHTML(code)
}
}
Render.prototype.npmPackageMentions = function (links, cb) {
var self = this
var pkgLinks = u.toArray(links).filter(function (link) {
return /^npm:/.test(link.name)
})
if (pkgLinks.length === 0) return cb(null, '')
var done = multicb({pluck: 1})
pkgLinks.forEach(function (link) {
self.npmPackageMention(link, {}, done())
})
done(function (err, mentionEls) {
cb(null, h('table',
h('thead', h('tr',
h('th', 'package'),
h('th', 'version'),
h('th', 'tag'),
h('th', 'size'),
h('th', 'tarball'),
h('th', 'readme')
)),
h('tbody', mentionEls)
))
})
}
Render.prototype.npmPrebuildMentions = function (links, cb) {
var self = this
var prebuildLinks = u.toArray(links).filter(function (link) {
return /^prebuild:/.test(link.name)
})
if (prebuildLinks.length === 0) return cb(null, '')
var done = multicb({pluck: 1})
prebuildLinks.forEach(function (link) {
self.npmPrebuildMention(link, {}, done())
})
done(function (err, mentionEls) {
cb(null, h('table',
h('thead', h('tr',
h('th', 'name'),
h('th', 'version'),
h('th', 'runtime'),
h('th', 'abi'),
h('th', 'platform+libc'),
h('th', 'arch'),
h('th', 'size'),
h('th', 'tarball')
)),
h('tbody', mentionEls)
))
})
}
Render.prototype.npmPackageMention = function (link, opts, cb) {
var nameRegex = /'prebuild:{name}-v{version}-{runtime}-v{abi}-{platform}{libc}-{arch}.tar.gz'/
var parts = String(link.name).replace(/\.tgz$/, '').split(':')
var name = parts[1]
var version = parts[2]
var distTag = parts[3]
var self = this
var done = multicb({pluck: 1, spread: true})
var base = '/npm/' + (opts.author ? u.escapeId(link.author) + '/' : '')
var pathWithAuthor = opts.withAuthor ? '/npm/' +
u.escapeId(link.author) +
(opts.name ? '/' + opts.name +
(opts.version ? '/' + opts.version +
(opts.distTag ? '/' + opts.distTag + '/' : '') : '') : '') : ''
self.app.getAbout(link.author, done())
self.app.getBlobState(link.link, done())
done(function (err, about, blobState) {
if (err) return cb(err)
cb(null, h('tr', [
opts.withAuthor ? h('td', h('a', {
href: self.toUrl(pathWithAuthor),
title: 'publisher'
}, about.name || u.truncate(link.author, 8)), ' ') : '',
h('td', h('a', {
href: self.toUrl(base + name),
title: 'package name'
}, name), ' '),
h('td', version ? [h('a', {
href: self.toUrl(base + name + '/' + version),
title: 'package version'
}, version), ' '] : ''),
h('td', distTag ? [h('a', {
href: self.toUrl(base + name + '//' + distTag),
title: 'dist-tag'
}, distTag), ' '] : ''),
h('td', {align: 'right'}, link.size != null ? [h('span', {
title: 'tarball size'
}, self.formatSize(link.size)), ' '] : ''),
h('td', typeof link.link === 'string' ? h('code', h('a', {
href: self.toUrl('/links/' + link.link),
title: 'package tarball'
}, link.link.substr(0, 8) + '…')) : ''),
h('td',
blobState === 'wanted' ?
'fetching...'
: blobState ? h('a', {
href: self.toUrl('/npm-readme/' + encodeURIComponent(link.link)),
title: 'package contents'
}, 'readme')
: self.blobFetchForm(link.link))
]))
})
}
Render.prototype.blobFetchForm = function (id) {
return h('form', {action: '', method: 'post'},
h('input', {type: 'hidden', name: 'action', value: 'want-blobs'}),
h('input', {type: 'hidden', name: 'async_want', value: '1'}),
h('input', {type: 'hidden', name: 'blob_ids', value: id}),
h('input', {type: 'submit', value: 'fetch'})
)
}
Render.prototype.npmPrebuildNameRegex = /^prebuild:(.*?)-v([0-9]+\.[0-9]+.*?)-(.*?)-v(.*?)-(.*?)-(.*?)\.tar\.gz$/
Render.prototype.npmPrebuildMention = function (link, opts, cb) {
var m = this.npmPrebuildNameRegex.exec(link.name)
if (!m) return cb(null, '')
var name = m[1], version = m[2], runtime = m[3],
abi = m[4], platformlibc = m[5], arch = m[6]
var self = this
var done = multicb({pluck: 1, spread: true})
var base = '/npm-prebuilds/' + (opts.author ? u.escapeId(link.author) + '/' : '')
self.app.getAbout(link.author, done())
self.app.getBlobState(link.link, done())
done(function (err, about, blobState) {
if (err) return cb(err)
cb(null, h('tr', [
opts.withAuthor ? h('td', h('a', {
href: self.toUrl(link.author)
}, about.name || u.truncate(link.author, 8)), ' ') : '',
h('td', h('a', {
href: self.toUrl(base + name)
}, name), ' '),
h('td', h('a', {
href: self.toUrl('/npm/' + name + '/' + version)
}, version), ' '),
h('td', runtime, ' '),
h('td', abi, ' '),
h('td', platformlibc, ' '),
h('td', arch, ' '),
h('td', {align: 'right'}, link.size != null ? [
self.formatSize(link.size), ' '
] : ''),
h('td', typeof link.link === 'string' ? h('code', h('a', {
href: self.toUrl('/links/' + link.link)
}, link.link.substr(0, 8) + '…')) : ''),
h('td',
blobState === 'wanted' ?
'fetching...'
: blobState ? ''
: self.blobFetchForm(link.link))
]))
})
}
Render.prototype.friendsList = function (prefix) {
prefix = prefix || '/'
var self = this
return pull(
paramap(function (item, cb) {
if (typeof item === 'string') item = {feed: item}
var id = item.feed
self.app.getAbout(item.feed, function (err, about) {
var name = about && about.name || id.substr(0, 8) + '…'
cb(null, [
h('a', {href: self.toUrl(prefix + id)}, name),
item.msg ? h('a', {href: self.toUrl(item.msg.key)}, '₁') : '',
item.msg2 ? h('a', {href: self.toUrl(item.msg2.key)}, '₂') : ''
])
})
}, 8),
function (read) {
var count = 0
var ended
return function (abort, cb) {
if (ended) return cb(ended)
read(abort, function (end, el) {
if (end === true) {
ended = true
cb(null, '(' + count + ')')
} else {
count++
cb(end, u.toHTML(el) + ' ')
}
})
}
}
)
}
Render.prototype.textEditDiffTable = function (oldMsg, newMsg) {
var oldC = oldMsg && oldMsg.value.content || {}
var newC = newMsg && newMsg.value.content || {}
var oldText = String(oldC.text || oldC.description || '')
var newText = String(newC.text || newC.description || '')
var diff = Diff.structuredPatch('', '', oldText, newText)
var self = this
// note: this structure is duplicated in lib/render.js
return h('table', {class: 'diff-table'}, [
diff.hunks.map(function (hunk) {
var oldLine = hunk.oldStart
var newLine = hunk.newStart
return [
h('tr', [
h('td', {colspan: 2}),
h('td', {colspan: 2}, h('pre',
'@@ -' + oldLine + ',' + hunk.oldLines + ' ' +
'+' + newLine + ',' + hunk.newLines + ' @@'))
]),
hunk.lines.map(function (line) {
var s = line[0]
if (s == '\\') return
var lineNums = [s == '+' ? '' : oldLine++, s == '-' ? '' : newLine++]
return [
h('tr', {
class: s == '+' ? 'diff-new' : s == '-' ? 'diff-old' : ''
}, [
lineNums.map(function (num, i) {
return h('td', String(num))
}),
h('td', {class: 'diff-sigil'}, h('code', s)),
h('td', {class: 'diff-line'}, {innerHTML:
u.unwrapP(self.markdown(line.substr(1),
s == '-' ? oldC.mentions : newC.mentions))
})
])
]
})
]
})
])
}