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