Files: a448b261d6bffa420d1f0c4d57ebe852146001bf / lib / render.js
18169 bytesRaw
1 | var fs = require('fs') |
2 | var path = require('path') |
3 | var pull = require('pull-stream') |
4 | var cat = require('pull-cat') |
5 | var paramap = require('pull-paramap') |
6 | var h = require('hyperscript') |
7 | var marked = require('ssb-marked') |
8 | var emojis = require('emoji-named-characters') |
9 | var qs = require('querystring') |
10 | var u = require('./util') |
11 | var multicb = require('multicb') |
12 | var RenderMsg = require('./render-msg') |
13 | var Highlight = require('highlight.js') |
14 | |
15 | module.exports = Render |
16 | |
17 | function MdRenderer(render) { |
18 | marked.Renderer.call(this, {}) |
19 | this.render = render |
20 | } |
21 | MdRenderer.prototype = new marked.Renderer() |
22 | |
23 | MdRenderer.prototype.urltransform = function (href) { |
24 | return this.render.toUrl(href) |
25 | } |
26 | |
27 | MdRenderer.prototype.image = function (ref, title, text) { |
28 | var href = this.render.imageUrl(ref) |
29 | return h('img', { |
30 | src: href, |
31 | alt: this.render.getImageAlt(ref, text), |
32 | title: title || undefined |
33 | }).outerHTML |
34 | } |
35 | |
36 | MdRenderer.prototype.link = function (ref, title, text) { |
37 | var href = this.urltransform(ref) |
38 | var name = href && /^\/(&|%26)/.test(href) && (title || text) |
39 | if (u.isRef(ref)) { |
40 | var myName = this.render.app.getNameSync(ref) |
41 | if (myName) title = title ? title + ' (' + myName + ')' : myName |
42 | } |
43 | var hrefToken = href !== false ? u.token() : undefined |
44 | var a = h('a', { |
45 | class: href === false ? 'bad' : undefined, |
46 | href: href !== false ? hrefToken : undefined, |
47 | title: title || undefined, |
48 | download: name ? encodeURIComponent(name) : undefined |
49 | }) |
50 | // text is already html-escaped |
51 | a.innerHTML = text |
52 | var html = a.outerHTML |
53 | // href is already html-escaped |
54 | if (hrefToken) html = html.replace(hrefToken, href) |
55 | return html |
56 | } |
57 | |
58 | MdRenderer.prototype.mention = function (preceding, id) { |
59 | var href = this.urltransform(id) |
60 | var myName = this.render.app.getNameSync(id) |
61 | if (id.length > 50) id = id.slice(0, 8) + '…' |
62 | return (preceding||'') + h('a', { |
63 | class: href === false ? 'bad' : undefined, |
64 | href: href !== false ? href : undefined, |
65 | title: myName || undefined, |
66 | }, id).outerHTML |
67 | } |
68 | |
69 | MdRenderer.prototype.code = function (code, lang, escaped) { |
70 | if (this.render.opts.codeInTextareas) { |
71 | return h('div', h('textarea', { |
72 | cols: 80, |
73 | rows: u.rows(code), |
74 | innerHTML: escaped ? code : u.escapeHTML(code) |
75 | })).outerHTML |
76 | } else { |
77 | return marked.Renderer.prototype.code.call(this, code, lang, escaped) |
78 | } |
79 | } |
80 | |
81 | function lexerRenderEmoji(emoji) { |
82 | var el = this.renderer.render.emoji(emoji) |
83 | return el && el.outerHTML || el |
84 | } |
85 | |
86 | function Render(app, opts) { |
87 | this.app = app |
88 | this.opts = opts |
89 | |
90 | this.markedOpts = { |
91 | gfm: true, |
92 | mentions: true, |
93 | tables: true, |
94 | breaks: true, |
95 | pedantic: false, |
96 | sanitize: true, |
97 | smartLists: true, |
98 | smartypants: false, |
99 | emoji: lexerRenderEmoji, |
100 | renderer: new MdRenderer(this), |
101 | highlight: this.highlight.bind(this), |
102 | } |
103 | } |
104 | |
105 | Render.prototype.emoji = function (emoji) { |
106 | var name = ':' + emoji + ':' |
107 | var link = this._mentions && this._mentions[name] |
108 | if (link && link.link) { |
109 | this.app.reverseEmojiNameCache.set(emoji, link.link) |
110 | return h('img.ssb-emoji', { |
111 | src: this.opts.img_base + link.link, |
112 | alt: name |
113 | + (link.size != null ? ' (' + this.formatSize(link.size) + ')' : ''), |
114 | height: 17, |
115 | title: name, |
116 | }) |
117 | } |
118 | if (emoji in emojis) { |
119 | return h('img.ssb-emoji', { |
120 | src: this.opts.emoji_base + emoji + '.png', |
121 | alt: name, |
122 | height: 17, |
123 | align: 'absmiddle', |
124 | title: name, |
125 | }) |
126 | } |
127 | return name |
128 | } |
129 | |
130 | /* disabled until it can be done safely without breaking html |
131 | function fixSymbols(str) { |
132 | // Dillo doesn't do fallback fonts, so specifically render fancy characters |
133 | // with Symbola |
134 | return str.replace(/[^\u0000-\u00ff]+/, function ($0) { |
135 | return '<span class="symbol">' + $0 + '</span>' |
136 | }) |
137 | } |
138 | */ |
139 | |
140 | Render.prototype.markdown = function (text, mentions) { |
141 | if (!text) return '' |
142 | var mentionsObj = this._mentions = {} |
143 | var mentionsByLink = this._mentionsByLink = {} |
144 | if (Array.isArray(mentions)) mentions.forEach(function (link) { |
145 | if (!link) return |
146 | else if (link.emoji) |
147 | mentionsObj[':' + link.name + ':'] = link |
148 | else if (link.name) |
149 | mentionsObj['@' + link.name] = link.link |
150 | else if (link.host === 'http://localhost:7777') |
151 | mentionsObj[link.href] = link.link |
152 | if (link.link) |
153 | mentionsByLink[link.link + (link.key ? '#' + link.key : '')] = link |
154 | }) |
155 | var out = marked(String(text), this.markedOpts) |
156 | delete this._mentions |
157 | delete this._mentionsByLink |
158 | return out //fixSymbols(out) |
159 | } |
160 | |
161 | Render.prototype.imageUrl = function (ref) { |
162 | var m = /^blobstore:(.*)/.exec(ref) |
163 | if (m) ref = m[1] |
164 | ref = ref.replace(/#/, '%23') |
165 | return this.opts.img_base + ref |
166 | } |
167 | |
168 | Render.prototype.getImageAlt = function (id, fallback) { |
169 | var link = this._mentionsByLink[id] |
170 | if (!link) return fallback |
171 | var name = link.name || fallback |
172 | return name |
173 | + (link.size != null ? ' (' + this.formatSize(link.size) + ')' : '') |
174 | } |
175 | |
176 | Render.prototype.formatSize = function (size) { |
177 | if (size < 1024) return size + ' B' |
178 | size /= 1024 |
179 | if (size < 1024) return size.toFixed(2) + ' KB' |
180 | size /= 1024 |
181 | return size.toFixed(2) + ' MB' |
182 | } |
183 | |
184 | Render.prototype.linkify = function (text) { |
185 | var arr = text.split(u.ssbRefEncRegex) |
186 | for (var i = 1; i < arr.length; i += 2) { |
187 | arr[i] = h('a', {href: this.toUrlEnc(arr[i])}, arr[i]) |
188 | } |
189 | return arr |
190 | } |
191 | |
192 | Render.prototype.toUrlEnc = function (href) { |
193 | var url = this.toUrl(href) |
194 | if (url) return url |
195 | try { href = decodeURIComponent(href) } |
196 | catch (e) { return false } |
197 | return this.toUrl(href) |
198 | } |
199 | |
200 | Render.prototype.toUrl = function (href) { |
201 | if (!href) return href |
202 | var mentions = this._mentions |
203 | if (mentions && href in this._mentions) href = this._mentions[href] |
204 | if (/^ssb:\/\//.test(href)) href = href.substr(6) |
205 | if (/^ssb-blob:\/\//.test(href)) { |
206 | return this.opts.base + 'zip/' + href.substr(11) |
207 | } |
208 | switch (href[0]) { |
209 | case '%': |
210 | if (!u.isRef(href)) return false |
211 | return this.opts.base + |
212 | (this.opts.encode_msgids ? encodeURIComponent(href) : href) |
213 | case '@': |
214 | if (!u.isRef(href)) return false |
215 | return this.opts.base + href |
216 | case '&': |
217 | var parts = href.split('#') |
218 | var hash = parts.shift() |
219 | var key = parts.shift() |
220 | var fragment = parts.join('#') |
221 | if (!u.isRef(hash)) return false |
222 | return this.opts.blob_base + hash |
223 | + (key ? encodeURIComponent('#' + key) : '') |
224 | + (fragment ? '#' + fragment : '') |
225 | case '#': return this.opts.base + 'channel/' + |
226 | encodeURIComponent(href.substr(1)) |
227 | case '/': return this.opts.base + href.substr(1) |
228 | case '?': return this.opts.base + 'search?q=' + encodeURIComponent(href) |
229 | } |
230 | var m = /^blobstore:(.*)/.exec(href) |
231 | if (m) return this.opts.blob_base + m[1] |
232 | if (/^javascript:/.test(href)) return false |
233 | return href |
234 | } |
235 | |
236 | Render.prototype.lockIcon = function () { |
237 | return this.emoji('lock') |
238 | } |
239 | |
240 | Render.prototype.avatarImage = function (link, cb) { |
241 | var self = this |
242 | if (!link) return cb(), '' |
243 | if (typeof link === 'string') link = {link: link} |
244 | var img = h('img.ssb-avatar-image', { |
245 | width: 72, |
246 | alt: ' ' |
247 | }) |
248 | if (link.image) gotAbout(null, link) |
249 | else self.app.getAbout(link.link, gotAbout) |
250 | function gotAbout(err, about) { |
251 | if (err) return cb(err) |
252 | if (!about.image) img.src = self.toUrl('/static/fallback.png') |
253 | else img.src = self.imageUrl(about.image) |
254 | cb() |
255 | } |
256 | return img |
257 | } |
258 | |
259 | Render.prototype.prepareLink = function (link, cb) { |
260 | if (typeof link === 'string') link = {link: link} |
261 | if (link.name || !link.link) cb(null, link) |
262 | else this.app.getAbout(link.link, function (err, about) { |
263 | if (err) return cb(null, link) |
264 | link.name = about.name || about.title || (link.link.substr(0, 8) + '…') |
265 | if (link.name && link.name[0] === link.link[0]) { |
266 | link.name = link.name.substr(1) |
267 | } |
268 | cb(null, link) |
269 | }) |
270 | } |
271 | |
272 | Render.prototype.prepareLinks = function (links, cb) { |
273 | var self = this |
274 | if (!links) return cb() |
275 | var done = multicb({pluck: 1}) |
276 | if (Array.isArray(links)) links.forEach(function (link) { |
277 | self.prepareLink(link, done()) |
278 | }) |
279 | done(cb) |
280 | } |
281 | |
282 | Render.prototype.idLink = function (link, cb) { |
283 | var self = this |
284 | if (!link) return cb(), '' |
285 | var a = h('a', ' ') |
286 | self.prepareLink(link, function (err, link) { |
287 | if (err) return cb(err) |
288 | a.href = self.toUrl(link.link) |
289 | var sigil = link.link && link.link[0] || '@' |
290 | a.childNodes[0].textContent = sigil + link.name |
291 | cb() |
292 | }) |
293 | return a |
294 | } |
295 | |
296 | Render.prototype.privateLine = function (recps, cb) { |
297 | var done = multicb({pluck: 1, spread: true}) |
298 | var self = this |
299 | var el = h('div.recps', |
300 | self.lockIcon(), |
301 | Array.isArray(recps) |
302 | ? recps.map(function (recp) { |
303 | return [' ', self.idLink(recp, done())] |
304 | }) : '') |
305 | done(cb) |
306 | return el |
307 | } |
308 | |
309 | Render.prototype.msgLink = function (msg, cb) { |
310 | var self = this |
311 | var el = h('span') |
312 | var a = h('a', {href: self.toUrl(msg.key)}, msg.key) |
313 | self.app.unboxMsg(msg, function (err, msg) { |
314 | if (err) return el.appendChild(u.renderError(err)), cb() |
315 | var renderMsg = new RenderMsg(self, self.app, msg, {wrap: false}) |
316 | renderMsg.title(function (err, title) { |
317 | if (err) return el.appendChild(u.renderError(err)), cb() |
318 | a.childNodes[0].textContent = title |
319 | cb() |
320 | }) |
321 | }) |
322 | return a |
323 | } |
324 | |
325 | Render.prototype.renderMsg = function (msg, opts, cb) { |
326 | var self = this |
327 | self.app.filterMsg(msg, opts, function (err, show) { |
328 | if (err) return cb(err) |
329 | if (show) new RenderMsg(self, self.app, msg, opts).message(cb) |
330 | else cb(null, '') |
331 | }) |
332 | } |
333 | |
334 | Render.prototype.renderFeeds = function (opts) { |
335 | var self = this |
336 | var limit = Number(opts.limit) |
337 | return pull( |
338 | paramap(function (msg, cb) { |
339 | self.renderMsg(msg, opts, cb) |
340 | }, 4), |
341 | pull.filter(Boolean), |
342 | limit && pull.take(limit) |
343 | ) |
344 | } |
345 | |
346 | Render.prototype.gitCommitBody = function (body) { |
347 | if (!body) return '' |
348 | var isMarkdown = !/^# Conflicts:$/m.test(body) |
349 | return isMarkdown |
350 | ? h('div', {innerHTML: this.markdown('\n' + body)}) |
351 | : h('pre', this.linkify('\n' + body)) |
352 | } |
353 | |
354 | Render.prototype.getName = function (id, cb) { |
355 | // TODO: consolidate the get name/link functions |
356 | var self = this |
357 | switch (id && id[0]) { |
358 | case '%': |
359 | return self.app.getMsgDecrypted(id, function (err, msg) { |
360 | if (err && err.name == 'NotFoundError') |
361 | return cb(null, String(id).substring(0, 8) + '…(missing)') |
362 | if (err) return fallback() |
363 | new RenderMsg(self, self.app, msg, {wrap: false}).title(cb) |
364 | }) |
365 | case '@': // fallthrough |
366 | case '&': |
367 | return self.app.getAbout(id, function (err, about) { |
368 | if (err || !about || !about.name) return fallback() |
369 | cb(null, about.name) |
370 | }) |
371 | default: |
372 | return cb(null, String(id)) |
373 | } |
374 | function fallback() { |
375 | cb(null, String(id).substr(0, 8) + '…') |
376 | } |
377 | } |
378 | |
379 | Render.prototype.getNameLink = function (id, cb) { |
380 | var self = this |
381 | self.getName(id, function (err, name) { |
382 | if (err) return cb(err) |
383 | cb(null, h('a', {href: self.toUrl(id)}, name)) |
384 | }) |
385 | } |
386 | |
387 | Render.prototype.npmAuthorLink = function (author) { |
388 | if (!author) return |
389 | var url = u.ifString(author.url) |
390 | var email = u.ifString(author.email) |
391 | var name = u.ifString(author.name) |
392 | var title |
393 | if (!url && u.isRef(name)) url = name, name = null |
394 | if (!url && !email) return name || JSON.stringify(author) |
395 | if (!url && email) url = 'mailto:' + email, email = null |
396 | if (!name && email) name = email, email = null |
397 | var feed = u.isRef(url) && url[0] === '@' && url |
398 | if (feed && name) title = this.app.getNameSync(feed) |
399 | if (feed && name && name[0] != '@') name = '@' + name |
400 | if (feed && !name) name = this.app.getNameSync(feed) // TODO: async |
401 | if (url && !name) name = url |
402 | var secondaryLink = email && h('a', {href: this.toUrl('mailto:' + email)}, email) |
403 | return [ |
404 | h('a', {href: this.toUrl(url), title: title}, name), |
405 | secondaryLink ? [' (', secondaryLink, ')'] : '' |
406 | ] |
407 | } |
408 | |
409 | // auto-highlight is slow |
410 | var useAutoHighlight = false |
411 | |
412 | Render.prototype.highlight = function (code, lang) { |
413 | if (code.length > 100000) return u.escapeHTML(code) |
414 | if (!lang && /^#!\/bin\/[^\/]*sh$/m.test(code)) lang = 'sh' |
415 | try { |
416 | return lang |
417 | ? Highlight.highlight(lang, code).value |
418 | : useAutoHighlight |
419 | ? Highlight.highlightAuto(code).value |
420 | : u.escapeHTML(code) |
421 | } catch(e) { |
422 | if (!/^Unknown language/.test(e.message)) console.trace(e) |
423 | return u.escapeHTML(code) |
424 | } |
425 | } |
426 | |
427 | Render.prototype.npmPackageMentions = function (links, cb) { |
428 | var self = this |
429 | var pkgLinks = u.toArray(links).filter(function (link) { |
430 | return /^npm:/.test(link.name) |
431 | }) |
432 | if (pkgLinks.length === 0) return cb(null, '') |
433 | var done = multicb({pluck: 1}) |
434 | pkgLinks.forEach(function (link) { |
435 | self.npmPackageMention(link, {}, done()) |
436 | }) |
437 | done(function (err, mentionEls) { |
438 | cb(null, h('table', |
439 | h('thead', h('tr', |
440 | h('th', 'package'), |
441 | h('th', 'version'), |
442 | h('th', 'tag'), |
443 | h('th', 'size'), |
444 | h('th', 'tarball'), |
445 | h('th', 'readme') |
446 | )), |
447 | h('tbody', mentionEls) |
448 | )) |
449 | }) |
450 | } |
451 | |
452 | Render.prototype.npmPrebuildMentions = function (links, cb) { |
453 | var self = this |
454 | var prebuildLinks = u.toArray(links).filter(function (link) { |
455 | return /^prebuild:/.test(link.name) |
456 | }) |
457 | if (prebuildLinks.length === 0) return cb(null, '') |
458 | var done = multicb({pluck: 1}) |
459 | prebuildLinks.forEach(function (link) { |
460 | self.npmPrebuildMention(link, {}, done()) |
461 | }) |
462 | done(function (err, mentionEls) { |
463 | cb(null, h('table', |
464 | h('thead', h('tr', |
465 | h('th', 'name'), |
466 | h('th', 'version'), |
467 | h('th', 'runtime'), |
468 | h('th', 'abi'), |
469 | h('th', 'platform+libc'), |
470 | h('th', 'arch'), |
471 | h('th', 'size'), |
472 | h('th', 'tarball') |
473 | )), |
474 | h('tbody', mentionEls) |
475 | )) |
476 | }) |
477 | } |
478 | |
479 | Render.prototype.npmPackageMention = function (link, opts, cb) { |
480 | var nameRegex = /'prebuild:{name}-v{version}-{runtime}-v{abi}-{platform}{libc}-{arch}.tar.gz'/ |
481 | var parts = String(link.name).replace(/\.tgz$/, '').split(':') |
482 | var name = parts[1] |
483 | var version = parts[2] |
484 | var distTag = parts[3] |
485 | var self = this |
486 | var done = multicb({pluck: 1, spread: true}) |
487 | var base = '/npm/' + (opts.author ? u.escapeId(link.author) + '/' : '') |
488 | var pathWithAuthor = opts.withAuthor ? '/npm/' + |
489 | u.escapeId(link.author) + |
490 | (opts.name ? '/' + opts.name + |
491 | (opts.version ? '/' + opts.version + |
492 | (opts.distTag ? '/' + opts.distTag + '/' : '') : '') : '') : '' |
493 | self.app.getAbout(link.author, done()) |
494 | self.app.getBlobState(link.link, done()) |
495 | done(function (err, about, blobState) { |
496 | if (err) return cb(err) |
497 | cb(null, h('tr', [ |
498 | opts.withAuthor ? h('td', h('a', { |
499 | href: self.toUrl(pathWithAuthor), |
500 | title: 'publisher' |
501 | }, about.name), ' ') : '', |
502 | h('td', h('a', { |
503 | href: self.toUrl(base + name), |
504 | title: 'package name' |
505 | }, name), ' '), |
506 | h('td', version ? [h('a', { |
507 | href: self.toUrl(base + name + '/' + version), |
508 | title: 'package version' |
509 | }, version), ' '] : ''), |
510 | h('td', distTag ? [h('a', { |
511 | href: self.toUrl(base + name + '//' + distTag), |
512 | title: 'dist-tag' |
513 | }, distTag), ' '] : ''), |
514 | h('td', {align: 'right'}, link.size != null ? [h('span', { |
515 | title: 'tarball size' |
516 | }, self.formatSize(link.size)), ' '] : ''), |
517 | h('td', typeof link.link === 'string' ? h('code', h('a', { |
518 | href: self.toUrl('/links/' + link.link), |
519 | title: 'package tarball' |
520 | }, link.link.substr(0, 8) + '…')) : ''), |
521 | h('td', |
522 | blobState === 'wanted' ? |
523 | 'fetching...' |
524 | : blobState ? h('a', { |
525 | href: self.toUrl('/npm-readme/' + encodeURIComponent(link.link)), |
526 | title: 'package contents' |
527 | }, 'readme') |
528 | : self.blobFetchForm(link.link)) |
529 | ])) |
530 | }) |
531 | } |
532 | |
533 | Render.prototype.blobFetchForm = function (id) { |
534 | return h('form', {action: '', method: 'post'}, |
535 | h('input', {type: 'hidden', name: 'action', value: 'want-blobs'}), |
536 | h('input', {type: 'hidden', name: 'async_want', value: '1'}), |
537 | h('input', {type: 'hidden', name: 'blob_ids', value: id}), |
538 | h('input', {type: 'submit', value: 'fetch'}) |
539 | ) |
540 | } |
541 | |
542 | Render.prototype.npmPrebuildNameRegex = /^prebuild:(.*?)-v([0-9]+\.[0-9]+.*?)-(.*?)-v(.*?)-(.*?)-(.*?)\.tar\.gz$/ |
543 | |
544 | Render.prototype.npmPrebuildMention = function (link, opts, cb) { |
545 | var m = this.npmPrebuildNameRegex.exec(link.name) |
546 | if (!m) return cb(null, '') |
547 | var name = m[1], version = m[2], runtime = m[3], |
548 | abi = m[4], platformlibc = m[5], arch = m[6] |
549 | var self = this |
550 | var done = multicb({pluck: 1, spread: true}) |
551 | var base = '/npm-prebuilds/' + (opts.author ? u.escapeId(link.author) + '/' : '') |
552 | self.app.getAbout(link.author, done()) |
553 | self.app.getBlobState(link.link, done()) |
554 | done(function (err, about, blobState) { |
555 | if (err) return cb(err) |
556 | cb(null, h('tr', [ |
557 | opts.withAuthor ? h('td', h('a', { |
558 | href: self.toUrl(link.author) |
559 | }, about.name), ' ') : '', |
560 | h('td', h('a', { |
561 | href: self.toUrl(base + name) |
562 | }, name), ' '), |
563 | h('td', h('a', { |
564 | href: self.toUrl('/npm/' + name + '/' + version) |
565 | }, version), ' '), |
566 | h('td', runtime, ' '), |
567 | h('td', abi, ' '), |
568 | h('td', platformlibc, ' '), |
569 | h('td', arch, ' '), |
570 | h('td', {align: 'right'}, link.size != null ? [ |
571 | self.formatSize(link.size), ' ' |
572 | ] : ''), |
573 | h('td', typeof link.link === 'string' ? h('code', h('a', { |
574 | href: self.toUrl('/links/' + link.link) |
575 | }, link.link.substr(0, 8) + '…')) : ''), |
576 | h('td', |
577 | blobState === 'wanted' ? |
578 | 'fetching...' |
579 | : blobState ? '' |
580 | : self.blobFetchForm(link.link)) |
581 | ])) |
582 | }) |
583 | } |
584 | |
585 | Render.prototype.friendsList = function (prefix) { |
586 | prefix = prefix || '/' |
587 | var self = this |
588 | return pull( |
589 | paramap(function (id, cb) { |
590 | self.app.getAbout(id, function (err, about) { |
591 | var name = about && about.name || id.substr(0, 8) + '…' |
592 | cb(null, h('a', {href: self.toUrl(prefix + id)}, name)) |
593 | }) |
594 | }, 8), |
595 | pull.map(function (el) { |
596 | return [el, ' '] |
597 | }), |
598 | pull.flatten(), |
599 | pull.map(u.toHTML) |
600 | ) |
601 | } |
602 |
Built with git-ssb-web