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('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 = href.substr(6) 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)) }) ]) ] }) ] }) ]) }