render.jsView |
---|
| 1 | +var path = require('path'); |
| 2 | +var pull = require("pull-stream"); |
| 3 | +var marked = require("ssb-marked"); |
| 4 | +var htime = require("human-time"); |
| 5 | +var emojis = require("emoji-named-characters"); |
| 6 | +var cat = require("pull-cat"); |
| 7 | + |
| 8 | +var emojiDir = path.join(require.resolve("emoji-named-characters"), "../pngs"); |
| 9 | + |
| 10 | +exports.wrapPage = wrapPage; |
| 11 | +exports.MdRenderer = MdRenderer; |
| 12 | +exports.renderEmoji = renderEmoji; |
| 13 | +exports.formatMsgs = formatMsgs; |
| 14 | +exports.renderThread = renderThread; |
| 15 | + |
| 16 | +function MdRenderer(opts) { |
| 17 | + marked.Renderer.call(this, {}); |
| 18 | + this.opts = opts; |
| 19 | +} |
| 20 | + |
| 21 | +MdRenderer.prototype = new marked.Renderer(); |
| 22 | + |
| 23 | +MdRenderer.prototype.urltransform = function(href) { |
| 24 | + if (!href) return false; |
| 25 | + switch (href[0]) { |
| 26 | + case "#": |
| 27 | + return this.opts.base + "channel/" + href.slice(1); |
| 28 | + case "%": |
| 29 | + return this.opts.msg_base + encodeURIComponent(href); |
| 30 | + case "@": |
| 31 | + return this.opts.feed_base + encodeURIComponent(href); |
| 32 | + case "&": |
| 33 | + return this.opts.blob_base + encodeURIComponent(href); |
| 34 | + } |
| 35 | + if (href.indexOf("javascript:") === 0) return false; |
| 36 | + return href; |
| 37 | +}; |
| 38 | + |
| 39 | +MdRenderer.prototype.image = function(href, title, text) { |
| 40 | + return ( |
| 41 | + '<img src="' + |
| 42 | + this.opts.img_base + |
| 43 | + escape(href) + |
| 44 | + '"' + |
| 45 | + ' alt="' + |
| 46 | + text + |
| 47 | + '"' + |
| 48 | + (title ? ' title="' + title + '"' : "") + |
| 49 | + (this.options.xhtml ? "/>" : ">") |
| 50 | + ); |
| 51 | +}; |
| 52 | + |
| 53 | +function renderEmoji(emoji) { |
| 54 | + var opts = this.renderer.opts; |
| 55 | + return emoji in emojis |
| 56 | + ? '<img src="' + |
| 57 | + opts.emoji_base + |
| 58 | + escape(emoji) + |
| 59 | + '.png"' + |
| 60 | + ' alt=":' + |
| 61 | + escape(emoji) + |
| 62 | + ':"' + |
| 63 | + ' title=":' + |
| 64 | + escape(emoji) + |
| 65 | + ':"' + |
| 66 | + ' class="ssb-emoji" height="16" width="16">' |
| 67 | + : ":" + emoji + ":"; |
| 68 | +} |
| 69 | + |
| 70 | +function escape(str) { |
| 71 | + return String(str) |
| 72 | + .replace(/&/g, "&") |
| 73 | + .replace(/</g, "<") |
| 74 | + .replace(/>/g, ">") |
| 75 | + .replace(/"/g, """); |
| 76 | +} |
| 77 | + |
| 78 | +function formatMsgs(id, ext, opts) { |
| 79 | + switch (ext || "html") { |
| 80 | + case "html": |
| 81 | + return pull(renderThread(opts), wrapPage(id)); |
| 82 | + case "js": |
| 83 | + return pull(renderThread(opts), wrapJSEmbed(opts)); |
| 84 | + case "json": |
| 85 | + return wrapJSON(); |
| 86 | + default: |
| 87 | + return null; |
| 88 | + } |
| 89 | +} |
| 90 | + |
| 91 | +function wrap(before, after) { |
| 92 | + return function(read) { |
| 93 | + return cat([pull.once(before), read, pull.once(after)]); |
| 94 | + }; |
| 95 | +} |
| 96 | + |
| 97 | +function renderThread(opts) { |
| 98 | + return pull( |
| 99 | + pull.map(renderMsg.bind(this, opts)), |
| 100 | + wrap( |
| 101 | + '<span class="top-tip">You are reading content from ' + |
| 102 | + '<a href="https://www.scuttlebutt.nz">Scuttlebutt</a>' + |
| 103 | + '</span>' + |
| 104 | + '<main>', |
| 105 | + |
| 106 | + '</main>' + |
| 107 | + '<a class="call-to-action" href="https://www.scuttlebutt.nz">' + |
| 108 | + 'Join Scuttlebutt now' + |
| 109 | + '</a>' |
| 110 | + ) |
| 111 | + ); |
| 112 | +} |
| 113 | + |
| 114 | +function wrapPage(id) { |
| 115 | + return wrap( |
| 116 | + "<!doctype html><html><head>" + |
| 117 | + "<meta charset=utf-8>" + |
| 118 | + "<title>" + |
| 119 | + id + " | ssb-viewer" + |
| 120 | + "</title>" + |
| 121 | + '<meta name=viewport content="width=device-width,initial-scale=1">' + |
| 122 | + styles + |
| 123 | + "</head><body>", |
| 124 | + "</body></html>" |
| 125 | + ); |
| 126 | +} |
| 127 | + |
| 128 | +var styles = ` |
| 129 | + <style> |
| 130 | + html { background-color: #f1f3f5; } |
| 131 | + body { |
| 132 | + color: #212529; |
| 133 | + font-family: "Helvetica Neue", "Calibri Light", Roboto, sans-serif; |
| 134 | + -webkit-font-smoothing: antialiased; |
| 135 | + -moz-osx-font-smoothing: grayscale; |
| 136 | + letter-spacing: 0.02em; |
| 137 | + padding-top: 30px; |
| 138 | + padding-bottom: 50px; |
| 139 | + } |
| 140 | + a { color: #364fc7; } |
| 141 | + |
| 142 | + .top-tip, .top-tip a { |
| 143 | + color: #868e96; |
| 144 | + } |
| 145 | + .top-tip { |
| 146 | + text-align: center; |
| 147 | + display: block; |
| 148 | + margin-bottom: 10px; |
| 149 | + font-size: 14px; |
| 150 | + } |
| 151 | + main { margin: 0 auto; max-width: 40rem; } |
| 152 | + main article:first-child { border-radius: 3px 3px 0 0; } |
| 153 | + main article:last-child { border-radius: 0 0 3px 3px; } |
| 154 | + article { |
| 155 | + background-color: white; |
| 156 | + padding: 20px; |
| 157 | + box-shadow: 0 1px 3px #949494; |
| 158 | + position: relative; |
| 159 | + } |
| 160 | + .top-right { position: absolute; top: 20px; right: 20px; } |
| 161 | + article > header { margin-bottom: 20px; } |
| 162 | + article > header > figure { |
| 163 | + margin: 0; display: flex; |
| 164 | + } |
| 165 | + article > header > figure > img { |
| 166 | + border-radius: 2px; margin-right: 10px; |
| 167 | + } |
| 168 | + article > header > figure > figcaption { |
| 169 | + display: flex; flex-direction: column; justify-content: space-around; |
| 170 | + } |
| 171 | + .ssb-avatar-name { font-size: 1.2em; font-weight: bold; } |
| 172 | + time a { color: #868e96; } |
| 173 | + .ssb-avatar-name, time a { |
| 174 | + text-decoration: none; |
| 175 | + } |
| 176 | + .ssb-avatar-name:hover, time:hover a { |
| 177 | + text-decoration: underline; |
| 178 | + } |
| 179 | + section p { line-height: 1.45em; } |
| 180 | + section p img { |
| 181 | + max-width: 100%; |
| 182 | + max-height: 50vh; |
| 183 | + margin: 0 auto; |
| 184 | + } |
| 185 | + .status { |
| 186 | + font-style: italic; |
| 187 | + } |
| 188 | + |
| 189 | + code { |
| 190 | + display: inline; |
| 191 | + padding: 2px 5px; |
| 192 | + font-weight: 600; |
| 193 | + background-color: #e9ecef; |
| 194 | + border-radius: 3px; |
| 195 | + color: #495057; |
| 196 | + } |
| 197 | + blockquote { |
| 198 | + padding-left: 1.2em; |
| 199 | + margin: 0; |
| 200 | + color: #868e96; |
| 201 | + border-left: 5px solid #ced4da; |
| 202 | + } |
| 203 | + pre { |
| 204 | + background-color: #212529; |
| 205 | + color: #ced4da; |
| 206 | + font-weight: bold; |
| 207 | + padding: 5px; |
| 208 | + border-radius: 3px; |
| 209 | + position: relative; |
| 210 | + } |
| 211 | + pre::before { |
| 212 | + content: "METADATA"; |
| 213 | + position: absolute; |
| 214 | + top: -7px; |
| 215 | + left: 0px; |
| 216 | + background-color: #212529; |
| 217 | + padding: 2px 4px 0; |
| 218 | + border-radius: 2px; |
| 219 | + font-family: "Helvetica Neue", "Calibri Light", Roboto, sans-serif; |
| 220 | + font-size: 9px; |
| 221 | + } |
| 222 | + .call-to-action { |
| 223 | + display: block; |
| 224 | + margin: 0 auto; |
| 225 | + width: 13em; |
| 226 | + text-align: center; |
| 227 | + text-decoration: none; |
| 228 | + margin-top: 20px; |
| 229 | + margin-bottom: 60px; |
| 230 | + background-color: #5c7cfa; |
| 231 | + padding: 15px 0; |
| 232 | + color: #edf2ff; |
| 233 | + border-radius: 3px; |
| 234 | + border-bottom: 3px solid #3b5bdb; |
| 235 | + } |
| 236 | + .call-to-action:hover { |
| 237 | + background-color: #748ffc; |
| 238 | + border-bottom: 3px solid #4c6ef5; |
| 239 | + } |
| 240 | + </style> |
| 241 | +`; |
| 242 | + |
| 243 | +function wrapJSON() { |
| 244 | + var first = true; |
| 245 | + return pull(pull.map(JSON.stringify), join(","), wrap("[", "]")); |
| 246 | +} |
| 247 | + |
| 248 | +function wrapJSEmbed(opts) { |
| 249 | + return pull( |
| 250 | + wrap('<link rel=stylesheet href="' + opts.base + 'static/base.css">', ""), |
| 251 | + pull.map(docWrite), |
| 252 | + opts.base_token && rewriteBase(new RegExp(opts.base_token, "g")) |
| 253 | + ); |
| 254 | +} |
| 255 | + |
| 256 | +function rewriteBase(token) { |
| 257 | + |
| 258 | + return pull( |
| 259 | + replace(token, '" + SSB_VIEWER_ORIGIN + "/'), |
| 260 | + wrap( |
| 261 | + "var SSB_VIEWER_ORIGIN = (function () {" + |
| 262 | + 'var scripts = document.getElementsByTagName("script")\n' + |
| 263 | + "var script = scripts[scripts.length-1]\n" + |
| 264 | + "if (!script) return location.origin\n" + |
| 265 | + 'return script.src.replace(/\\/%.*$/, "")\n' + |
| 266 | + "}())\n", |
| 267 | + "" |
| 268 | + ) |
| 269 | + ); |
| 270 | +} |
| 271 | + |
| 272 | +function join(delim) { |
| 273 | + var first = true; |
| 274 | + return pull.map(function(val) { |
| 275 | + if (!first) return delim + String(val); |
| 276 | + first = false; |
| 277 | + return val; |
| 278 | + }); |
| 279 | +} |
| 280 | + |
| 281 | +function replace(re, rep) { |
| 282 | + return pull.map(function(val) { |
| 283 | + return String(val).replace(re, rep); |
| 284 | + }); |
| 285 | +} |
| 286 | + |
| 287 | +function docWrite(str) { |
| 288 | + return "document.write(" + JSON.stringify(str) + ")\n"; |
| 289 | +} |
| 290 | + |
| 291 | +function renderMsg(opts, msg) { |
| 292 | + var c = msg.value.content || {}; |
| 293 | + var name = encodeURIComponent(msg.key); |
| 294 | + return ( |
| 295 | + '<article id="' + |
| 296 | + name + |
| 297 | + '">' + |
| 298 | + '<header>' + |
| 299 | + '<figure>' + |
| 300 | + '<img alt="" ' + |
| 301 | + 'src="' + opts.img_base + escape(msg.author.image) + '" ' + |
| 302 | + 'height="50" width="50">' + |
| 303 | + '<figcaption>' + |
| 304 | + '<a class="ssb-avatar-name"' + |
| 305 | + ' href="' + opts.base + |
| 306 | + escape(msg.value.author) + |
| 307 | + '"' + |
| 308 | + ">" + msg.author.name + "</a>" + |
| 309 | + msgTimestamp(msg, name) + |
| 310 | + '</figcaption>' + |
| 311 | + '</figure>' + |
| 312 | + '</header>' + |
| 313 | + render(opts, c) + |
| 314 | + "</article>" |
| 315 | + ); |
| 316 | +} |
| 317 | + |
| 318 | +function msgTimestamp(msg, name) { |
| 319 | + var date = new Date(msg.value.timestamp); |
| 320 | + var isoStr = date.toISOString(); |
| 321 | + return ( |
| 322 | + '<time class="ssb-timestamp" datetime="' + isoStr + '">' + |
| 323 | + '<a ' + |
| 324 | + 'href="#' + name + '" ' + |
| 325 | + 'title="' + isoStr + '" ' + |
| 326 | + '>' + formatDate(date) + '</a>' + |
| 327 | + '</time>' |
| 328 | + ); |
| 329 | +} |
| 330 | + |
| 331 | +function formatDate(date) { |
| 332 | + |
| 333 | + return htime(date); |
| 334 | +} |
| 335 | + |
| 336 | +function render(opts, c) { |
| 337 | + var base = opts.base; |
| 338 | + if (c.type === "post") { |
| 339 | + var channel = c.channel |
| 340 | + ? '<div class="top-right"><a href="' + base + 'channel/' + c.channel + '">#' + c.channel + "</a></div>" |
| 341 | + : ""; |
| 342 | + return channel + renderPost(opts, c); |
| 343 | + } else if (c.type == "vote" && c.vote.expression == "Dig") { |
| 344 | + var channel = c.channel |
| 345 | + ? ' in <a href="' + base + 'channel/' + c.channel + '">#' + c.channel + "</a>" |
| 346 | + : ""; |
| 347 | + var linkedText = "this"; |
| 348 | + if (typeof c.vote.linkedText != "undefined") |
| 349 | + linkedText = c.vote.linkedText.substring(0, 75); |
| 350 | + return ('<span class="status">' + |
| 351 | + 'Liked ' + |
| 352 | + '<a href="' + base + |
| 353 | + c.vote.link + |
| 354 | + '">' + |
| 355 | + linkedText + |
| 356 | + "</a>" + |
| 357 | + channel + |
| 358 | + '</span>' |
| 359 | + ); |
| 360 | + } else if (c.type == "vote") { |
| 361 | + var linkedText = "this"; |
| 362 | + if (typeof c.vote.linkedText != "undefined") |
| 363 | + linkedText = c.vote.linkedText.substring(0, 75); |
| 364 | + return '<span class="status">' + |
| 365 | + 'Voted <a href="' + base + c.vote.link + '">' + linkedText + "</a>" + |
| 366 | + '</span>'; |
| 367 | + } else if (c.type == "contact" && c.following) { |
| 368 | + var name = c.contact; |
| 369 | + if (typeof c.contactAbout != "undefined") name = c.contactAbout.name; |
| 370 | + return '<span class="status">' + |
| 371 | + 'Followed <a href="' + base + c.contact + '">' + name + "</a>" + |
| 372 | + '</span>'; |
| 373 | + } else if (c.type == "contact" && !c.following) { |
| 374 | + var name = c.contact; |
| 375 | + if (typeof c.contactAbout != "undefined") name = c.contactAbout.name; |
| 376 | + return '<span class="status">' + |
| 377 | + 'Unfollowed <a href="' + base + c.contact + '">' + name + "</a>" + |
| 378 | + '</span>'; |
| 379 | + } else if (typeof c == "string") { |
| 380 | + return '<span class="status">' + |
| 381 | + "Wrote something private" + |
| 382 | + '</span>'; |
| 383 | + } |
| 384 | + else if (c.type == "about") { |
| 385 | + return '<span class="status">' + |
| 386 | + "Changed something in about" + |
| 387 | + '</span>' + |
| 388 | + renderDefault(c); |
| 389 | + } |
| 390 | + else if (c.type == "issue") { |
| 391 | + return '<span class="status">' + |
| 392 | + "Created a git issue" + |
| 393 | + '</span>' + |
| 394 | + renderDefault(c); |
| 395 | + } |
| 396 | + else if (c.type == "git-update") { |
| 397 | + return '<span class="status">' + |
| 398 | + "Did a git update" + |
| 399 | + '</span>' + |
| 400 | + renderDefault(c); |
| 401 | + } |
| 402 | + else if (c.type == "ssb-dns") { |
| 403 | + return '<span class="status">' + |
| 404 | + "Updated DNS" + |
| 405 | + '</span>' + |
| 406 | + renderDefault(c); |
| 407 | + } |
| 408 | + else if (c.type == "pub") { |
| 409 | + return '<span class="status">' + |
| 410 | + "Connected to a pub" + |
| 411 | + '</span>' + |
| 412 | + renderDefault(c); |
| 413 | + } |
| 414 | + else if (c.type == "channel" && c.subscribed) |
| 415 | + return '<span class="status">' + |
| 416 | + 'Subscribed to channel <a href="' + base + 'channel/' + |
| 417 | + c.channel + |
| 418 | + '">#' + |
| 419 | + c.channel + |
| 420 | + "</a>" + |
| 421 | + '</span>'; |
| 422 | + else if (c.type == "channel" && !c.subscribed) |
| 423 | + return '<span class="status">' + |
| 424 | + 'Unsubscribed from channel <a href="' + base + 'channel/' + |
| 425 | + c.channel + |
| 426 | + '">#' + |
| 427 | + c.channel + |
| 428 | + "</a>" + |
| 429 | + '</span>'; |
| 430 | + else return renderDefault(c); |
| 431 | +} |
| 432 | + |
| 433 | +function renderPost(opts, c) { |
| 434 | + return '<section>' + marked(c.text, opts.marked) + "</section>"; |
| 435 | +} |
| 436 | + |
| 437 | +function renderDefault(c) { |
| 438 | + return "<pre>" + JSON.stringify(c, 0, 2) + "</pre>"; |
| 439 | +} |