function htmlEscape(html) {
return html.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(//g, '>')
}
function insertAfter(newNode, referenceNode) {
referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling)
}
function setContent(containerNode, ...newNodes) {
removeAllFrom(containerNode)
newNodes.forEach(n => containerNode.appendChild(n))
return containerNode
}
function removeAllFrom(fromNode) {
fromNode.innerHTML = ''
}
// rules :: [{pr: number, re: RegExp, with: string}]
// rules :: [{pr: number, re: RegExp, with: Function}]
// rules :: [{pr: number, re: RegExp, brackets: true, with: [string, string]}]
// rules :: [{pr: number, re: RegExp, brackets: true, with: [string, string, Function]}]
function formatText(txt, rules) {
let idCounter = 0
function nextId() { return idCounter++ }
function ft(txt, rules) {
let matches = rules.map(r => { r.re.lastIndex = 0; return [r, r.re.exec(txt)] })
.filter(([, m]) => m !== null)
.sort(([r1, m1], [r2, m2]) => (r1.pr - r2.pr) || (m1.index - m2.index))
if (matches && matches.length > 0) {
let [rule, match] = matches[0]
let subsequentRules = rules.filter(r => r.pr >= rule.pr)
let idStr = `<>(${nextId()})<>`
let outerStr = txt.substring(0, match.index) + idStr + txt.substring(rule.re.lastIndex)
let innerStr = (rule.brackets)
? (() => { let [l, r, f] = rule.with; return l + ft((f ? f(match[1]) : match[1]), subsequentRules) + r })()
: match[0].replace(rule.re, rule.with)
return ft(outerStr, subsequentRules).replace(idStr, innerStr)
}
return txt
}
return ft(htmlEscape(txt), rules) // idStr above relies on the fact the text is escaped
}
function fixWwwLink(url) {
return url.replace(/^(?!([a-z]+:)?\/\/)/i, '//')
}
function makeNewNode(embedType, aNode, reResult) {
const withClasses = el => {
if (embedType.className) {
el.classList.add(...embedType.className.split(' '))
}
return el
}
return embedType.makeNode(aNode, reResult, withClasses(document.createElement('div')))
}
function makeIframe(src, w, h, scrolling = 'no') {
let iframe = document.createElement('iframe')
iframe.style.width = w
iframe.style.height = h
iframe.frameBorder = '0'
iframe.scrolling = scrolling
iframe.setAttribute('allowFullScreen', '')
iframe.src = src
iframe.innerHTML = 'Cannot show iframes.'
return iframe
}
function makeResizableToRatio(element, ratio) {
element.setAttribute('data-ratio', ratio)
makeResizable(element, w => w * element.getAttribute('data-ratio'))
}
// calcHeight :: Number -> Number -- calculate element height for a given width
function makeResizable(element, calcHeight) {
const setHeight = el => {
if (document.body.contains(el) && (el.offsetWidth > 0)) {
el.style.height = (calcHeight(el.offsetWidth)).toFixed(2) + 'px'
}
}
window.addEventListener('resize', () => setHeight(element))
setHeight(element)
}
function extractDomain(url) {
const domainRe = /^(?:https?:\/\/)?(?:[^@/\n]+@)?(?:www\.)?([^:/\n]+)/i
let result = domainRe.exec(url) || []
if (result.length > 0) {
return result[1]
}
}
function urlReplace(match, p1, p2, p3) {
let isBrackets = (p1 !== undefined)
return (isBrackets)
? `${p1}`
: `${extractDomain(match)}`
}
function urlReplaceInCode(match, p1, p2, p3) {
let isBrackets = (p1 !== undefined)
return (isBrackets)
? `${match}`
: `${match}`
}
function messageReplyReplace(messageId) {
return function(match, mid, rid) {
let replyPart = (rid && rid != '0') ? '#' + rid : ''
return `${match}`
}
}
/**
* Given "txt" message in unescaped plaintext with Juick markup, this function
* returns escaped formatted HTML string.
* @param {string} txt text message
* @param {string} messageId current message id
* @param {boolean} isCode set when message contains *code tag
* @returns {string} formatted message
*/
function juickFormat(txt, messageId, isCode) {
const urlRe = /(?:\[([^\][]+)\](?:\[([^\]]+)\]|\(((?:[a-z]+:\/\/|www\.|ftp\.)(?:\([-\S+*&@#/%=~|$?!:;,.]*\)|[-\S+*&@#/%=~|$?!:;,.])*(?:\([-\S+*&@#/%=~|$?!:;,.]*\)|[\S+*&@#/%=~|$]))\))|\b(?:[a-z]+:\/\/|www\.|ftp\.)(?:\([-\S+*&@#/%=~|$?!:;,.]*\)|[-\S+*&@#/%=~|$?!:;,.])*(?:\([-\S+*&@#/%=~|$?!:;,.]*\)|[\S+*&@#/%=~|$]))/gi
const bqReplace = m => m.replace(/^(?:>|>)\s?/gmi, '')
return (isCode)
? formatText(txt, [
{ pr: 1, re: urlRe, with: urlReplaceInCode },
{ pr: 1, re: /\B(?:#(\d+))?(?:\/(\d+))?\b/g, with: messageReplyReplace(messageId) },
{ pr: 1, re: /\B@([\w-]+)\b/gi, with: '@$1' },
])
: formatText(txt, [
{ pr: 0, re: /((?:^(?:>|>)\s?[\s\S]+?$\n?)+)/gmi, brackets: true, with: ['', '
', bqReplace] },
{ pr: 1, re: urlRe, with: urlReplace },
{ pr: 1, re: /\B(?:#(\d+))?(?:\/(\d+))?\b/g, with: messageReplyReplace(messageId) },
{ pr: 1, re: /\B@([\w-]+)\b/gi, with: '@$1' },
{ pr: 2, re: /\B\*([^\n]+?)\*((?=\s)|(?=$)|(?=[!"#$%&'*+,\-./:;<=>?@[\]^_`{|}~()]+))/g, brackets: true, with: ['', ''] },
{ pr: 2, re: /\B\/([^\n]+?)\/((?=\s)|(?=$)|(?=[!"#$%&'*+,\-./:;<=>?@[\]^_`{|}~()]+))/g, brackets: true, with: ['', ''] },
{ pr: 2, re: /\b_([^\n]+?)_((?=\s)|(?=$)|(?=[!"#$%&'*+,\-./:;<=>?@[\]^_`{|}~()]+))/g, brackets: true, with: ['', ''] },
{ pr: 3, re: /\n/g, with: '
' },
])
}
/**
* @external RegExpExecArray
*/
/**
* @callback MakeNodeCallback
* @param { HTMLAnchorElement } aNode a DOM node of the link
* @param { RegExpExecArray } reResult Result of RegExp execution
* @param { HTMLDivElement} div target DOM element which can be updated by callback function
* @returns { HTMLDivElement } updated DOM element
*/
/**
* @typedef { object } LinkFormatData
* @property { string } id Format identifier
* @property { string } name Format description
* @property { RegExp } re Regular expression to match expected hyperlinks
* @property { string } className list of CSS classes which
* will be added to the target DOM element
* @property { MakeNodeCallback } makeNode callback function called when a target link is matched
*/
/**
* Get supported embeddable formats
* @returns {LinkFormatData[]} list of supported formats
*/
function getEmbeddableLinkTypes() {
return [
{
name: 'Jpeg and png images',
id: 'embed_jpeg_and_png_images',
className: 'picture compact',
re: /\.(jpe?g|png|svg)(:[a-zA-Z]+)?(?:\?[\w&;?=]*)?$/i,
makeNode: function(aNode, reResult, div) {
// dirty fix for dropbox urls
let url = aNode.href.endsWith('dl=0') ? aNode.href.replace('dl=0', 'raw=1') : aNode.href
div.innerHTML = ``
return div
}
},
{
name: 'Gif images',
id: 'embed_gif_images',
className: 'picture compact',
re: /\.gif(:[a-zA-Z]+)?(?:\?[\w&;?=]*)?$/i,
makeNode: function(aNode, reResult, div) {
div.innerHTML = ``
return div
}
},
{
name: 'Video (webm, mp4, ogv)',
id: 'embed_webm_and_mp4_videos',
className: 'video compact',
re: /\.(webm|mp4|m4v|ogv)(?:\?[\w&;?=]*)?$/i,
makeNode: function(aNode, reResult, div) {
div.innerHTML = ``
return div
}
},
{
name: 'Audio (mp3, ogg, weba, opus, m4a, oga, wav)',
id: 'embed_sound_files',
className: 'audio singleColumn',
re: /\.(mp3|ogg|weba|opus|m4a|oga|wav)(?:\?[\w&;?=]*)?$/i,
makeNode: function(aNode, reResult, div) {
div.innerHTML = ``
return div
}
},
{
name: 'YouTube videos (and playlists)',
id: 'embed_youtube_videos',
className: 'youtube resizableV singleColumn',
re: /^(?:https?:)?\/\/(?:www\.|m\.|gaming\.)?(?:youtu(?:(?:\.be\/|be\.com\/(?:v|embed|shorts)\/)([-\w]+)|be\.com\/watch)((?:(?:\?|&(?:amp;)?)(?:\w+=[-.\w]*[-\w]))*)|youtube\.com\/playlist\?list=([-\w]*)(&(amp;)?[-\w?=]*)?)/i,
makeNode: function(aNode, reResult, div) {
let [, v, args, plist] = reResult
let iframeUrl
if (plist) {
iframeUrl = 'https://www.youtube-nocookie.com/embed/videoseries?list=' + plist
} else {
let pp = {}; args.replace(/^\?/, '')
.split('&')
.map(s => s.split('='))
.forEach(z => pp[z[0]] = z[1])
let embedArgs = {
rel: '0',
enablejsapi: '1',
origin: `${window.location.protocol}//${window.location.host}`
}
if (pp.t) {
const tre = /^(?:(\d+)|(?:(\d+)h)?(?:(\d+)m)?(\d+)s|(?:(\d+)h)?(\d+)m|(\d+)h)$/i
let [, t, h, m, s, h1, m1, h2] = tre.exec(pp.t)
embedArgs['start'] = (+t) || ((+(h || h1 || h2 || 0)) * 60 * 60 + (+(m || m1 || 0)) * 60 + (+(s || 0)))
}
if (pp.list) {
embedArgs['list'] = pp.list
}
v = v || pp.v
let argsStr = Object.keys(embedArgs)
.map(k => `${k}=${embedArgs[k]}`)
.join('&')
iframeUrl = `https://www.youtube-nocookie.com/embed/${v}?${argsStr}`
}
let iframe = makeIframe(iframeUrl, '100%', '360px')
iframe.onload = () => makeResizableToRatio(iframe, 9.0 / 16.0)
return setContent(div, iframe)
}
},
{
name: 'Vimeo videos',
id: 'embed_vimeo_videos',
className: 'vimeo resizableV',
re: /^(?:https?:)?\/\/(?:www\.)?(?:player\.)?vimeo\.com\/(?:video\/|album\/[\d]+\/video\/)?([\d]+)/i,
makeNode: function(aNode, reResult, div) {
let iframe = makeIframe('//player.vimeo.com/video/' + reResult[1], '100%', '360px')
iframe.onload = () => makeResizableToRatio(iframe, 9.0 / 16.0)
return setContent(div, iframe)
}
},
{
name: 'Twitter',
id: 'embed_twitter_status',
className: 'twi compact',
re: /^(?:https?:)?\/\/(?:www\.)?(?:mobile\.)?(twitter|x)\.com\/([\w-]+)\/status(?:es)?\/([\d]+)/i,
makeNode: function(aNode, reResult, div) {
const wrong_prefix = 'https://x.com'
const correct_prefix = 'https://twitter.com'
const twitter_url = reResult[0].startsWith(wrong_prefix) ?
reResult[0].replace(wrong_prefix, correct_prefix)
: reResult[0]
fetch('https://x.juick.com/oembed?url=' + twitter_url)
.then(response => response.json())
.then(json => {
div.innerHTML = json.html
}).catch(console.log)
return div
}
},
{
name: 'Instagram media',
id: 'embed_instagram_images',
className: 'picture compact',
re: /https?:\/\/www\.?instagram\.com(\/p\/\w+)\/?/i,
makeNode: function(aNode, reResult, div) {
let [, postId] = reResult
let mediaUrl = `https://instagr.am${postId}/media`
div.innerHTML = ``
return div
}
},
{
name: 'Telegram posts',
id: 'embed_telegram_posts',
className: 'tg compact',
re: /https?:\/\/t\.me\/(\S+)/i,
makeNode: function(aNode, reResult, div) {
let [, post] = reResult
// innerHTML cannot insert scripts, so...
let script = document.createElement('script')
script.src = 'https://telegram.org/js/telegram-widget.js?18'
script.setAttribute('data-telegram-post', post)
script.setAttribute('data-tme-mode', 'data-tme-mode')
script.setAttribute('data-width', '100%')
script.charset = 'utf-8'
div.appendChild(script)
return div
}
},
]
}
/**
* Embed a link
* @param { HTMLAnchorElement } aNode a DOM node of the link
* @param { LinkFormatData[] } linkTypes supported link types
* @param { HTMLElement } container a target DOM element with the link content
* @param { boolean } afterNode where to insert new DOM node
* @returns { boolean } `true` when some link was embedded
*/
function embedLink(aNode, linkTypes, container, afterNode = false) {
let anyEmbed = false
let linkId = (aNode.href.replace(/^https?:/i, '').replace(/'/gi, ''))
let sameEmbed = container.querySelector(`*[data-linkid='${linkId}']`) // do not embed the same thing twice
if (!sameEmbed) {
anyEmbed = linkTypes.some((linkType) => {
let reResult = linkType.re.exec(aNode.href)
if (reResult) {
if (linkType.match && (linkType.match(aNode, reResult) === false)) { return false }
let newNode = makeNewNode(linkType, aNode, reResult)
if (!newNode) { return false }
newNode.setAttribute('data-linkid', linkId)
if (afterNode) {
insertAfter(newNode, afterNode)
} else {
container.appendChild(newNode)
}
aNode.classList.add('embedLink')
return true
}
})
}
return anyEmbed
}
function embedLinks(aNodes, container) {
let anyEmbed = false
let embeddableLinkTypes = getEmbeddableLinkTypes()
Array.from(aNodes).every(aNode => {
let isEmbedded = embedLink(aNode, embeddableLinkTypes, container)
// stop on first embedded link
anyEmbed = anyEmbed || isEmbedded
return !(anyEmbed)
})
return anyEmbed
}
/**
* Embed first link from supplied links inside element "x" that match to "allLinksSelector".
* All the embedded media is placed inside "div.embedContainer".
* "div.embedContainer" is inserted before an element matched by "beforeNodeSelector"
* if not present. Existing container is used otherwise.
* @param {Element} x
* @param {string} beforeNodeSelector
* @param {string} allLinksSelector
*/
export function embedLinksToX(x, beforeNodeSelector, allLinksSelector) {
let allLinks = x.querySelectorAll(allLinksSelector)
let existingContainer = x.querySelector('div.embedContainer')
if (existingContainer) {
embedLinks(allLinks, existingContainer)
} else {
let embedContainer = document.createElement('div')
embedContainer.className = 'embedContainer'
let anyEmbed = embedLinks(allLinks, embedContainer)
if (anyEmbed) {
let beforeNode = x.querySelector(beforeNodeSelector)
x.insertBefore(embedContainer, beforeNode)
}
}
}
/**
* Embed all the links in all messages/replies on the page.
*/
export function embedAll() {
let beforeNodeSelector = '.msg-txt + *'
let allLinksSelector = '.msg-txt a'
Array.from(document.querySelectorAll('#content .msg-cont')).forEach(msg => {
let hasMedia = msg.querySelector('.msg-media') || msg.querySelector('.ir')
if (!hasMedia) {
embedLinksToX(msg, beforeNodeSelector, allLinksSelector)
}
})
}
/**
* Embed URLs to container
* @param {Element[]} urls
* @param {HTMLDivElement} embedContainer
*/
export function embedUrls(urls, embedContainer) {
embedLinks(urls, embedContainer)
}
export const format = juickFormat