import { unescape, flattenDeep } from 'lodash' import { getTagName, processTextForEmoji, getAttrs } from 'src/services/html_converter/utility.service.js' import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js' import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js' import { marked } from 'marked' import markedMfm from 'marked-mfm' import StillImage from 'src/components/still-image/still-image.vue' import MentionsLine, { MENTIONS_LIMIT } from 'src/components/mentions_line/mentions_line.vue' import HashtagLink from 'src/components/hashtag_link/hashtag_link.vue' import './rich_content.scss' /** * RichContent, The Über-powered component for rendering Post HTML. * * This takes post HTML and does multiple things to it: * - Groups all mentions into , this affects all mentions regardles * of where they are (beginning/middle/end), even single mentions are converted * to a containing single . * - Replaces emoji shortcodes with 'd images. * * There are two problems with this component's architecture: * 1. Parsing HTML and rendering are inseparable. Attempts to separate the two * proven to be a massive overcomplication due to amount of things done here. * 2. We need to output both render and some extra data, which seems to be imp- * possible in vue. Current solution is to emit 'parseReady' event when parsing * is done within render() function. * * Apart from that one small hiccup with emit in render this _should_ be vue3-ready */ export default { name: 'RichContent', components: { MentionsLine, HashtagLink }, props: { // Original html content html: { required: true, type: String }, attentions: { required: false, default: () => [] }, // Emoji object, as in status.emojis, note the "s" at the end... emoji: { required: true, type: Array }, // Whether to handle links or not (posts: yes, everything else: no) handleLinks: { required: false, type: Boolean, default: false }, // Meme arrows greentext: { required: false, type: Boolean, default: false }, // Render Misskey Markdown mfm: { required: false, type: Boolean, default: false } }, // NEVER EVER TOUCH DATA INSIDE RENDER render () { // Don't greentext MFM const greentext = this.mfm ? false : this.greentext // Pre-process HTML const { newHtml: html } = preProcessPerLine(this.html, greentext) let currentMentions = null // Current chain of mentions, we group all mentions together // This is used to recover spacing removed when parsing mentions let lastSpacing = '' const lastTags = [] // Tags that appear at the end of post body const writtenMentions = [] // All mentions that appear in post body const invisibleMentions = [] // All mentions that go beyond the limiter (see MentionsLine) // to collapse too many mentions in a row const writtenTags = [] // All tags that appear in post body // unique index for vue "tag" property let mentionIndex = 0 let tagsIndex = 0 const renderImage = (tag) => { return } const renderHashtag = (attrs, children, encounteredTextReverse) => { const { index, ...linkData } = getLinkData(attrs, children, tagsIndex++) writtenTags.push(linkData) if (!encounteredTextReverse) { lastTags.push(linkData) } const { url, tag, content } = linkData return } const renderMention = (attrs, children) => { const linkData = getLinkData(attrs, children, mentionIndex++) linkData.notifying = this.attentions.some(a => a.statusnet_profile_url === linkData.url) writtenMentions.push(linkData) if (currentMentions === null) { currentMentions = [] } currentMentions.push(linkData) if (currentMentions.length > MENTIONS_LIMIT) { invisibleMentions.push(linkData) } if (currentMentions.length === 1) { return } else { return '' } } const renderMisskeyMarkdown = (content) => { // Untangle code blocks from
tags and other html encodings const codeblocks = content.match(/()?(~~~|```)\w*.+?\2\1?/g) if (codeblocks) { codeblocks.forEach((pre) => { content = content.replace(pre, pre.replaceAll('
', '\n') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll(''', "'") ) }) } marked.use(markedMfm, { mangle: false, gfm: false, breaks: true }) const mfmHtml = document.createElement('template') mfmHtml.innerHTML = marked.parse(content) // Add options with set values to CSS if (mfmHtml.content.firstChild) { Array.from(mfmHtml.content.firstChild.getElementsByClassName('mfm')).map((el) => { if (el.dataset.speed) { el.style.animationDuration = el.dataset.speed } if (el.dataset.deg) { el.style.transform = `rotate(${el.dataset.deg}deg)` } if (Array.from(el.classList).includes('_mfm_font_')) { const font = Object.keys(el.dataset)[0] if (['serif', 'monospace', 'cursive', 'fantasy', 'emoji', 'math'].includes(font)) { el.style.fontFamily = font } } }) } return mfmHtml.innerHTML } // Processor to use with html_tree_converter const processItem = (item, index, array, what) => { // Handle text nodes - just add emoji if (typeof item === 'string') { const emptyText = item.trim() === '' if (item.includes('\n')) { currentMentions = null } if (emptyText) { // don't include spaces when processing mentions - we'll include them // in MentionsLine lastSpacing = item // Don't remove last space in a container (fixes poast mentions) return (index !== array.length - 1) && (currentMentions !== null) ? item.trim() : item } currentMentions = null if (item.includes(':')) { item = ['', processTextForEmoji( item, this.emoji, ({ shortcode, url }) => { return } )] } return item } // Handle tag nodes if (Array.isArray(item)) { const [opener, children, closer] = item const Tag = getTagName(opener) const attrs = getAttrs(opener) const previouslyMentions = currentMentions !== null /* During grouping of mentions we trim all the empty text elements * This padding is added to recover last space removed in case * we have a tag right next to mentions */ const mentionsLinePadding = // Padding is only needed if we just finished parsing mentions previouslyMentions && // Don't add padding if content is string and has padding already !(children && typeof children[0] === 'string' && children[0].match(/^\s/)) ? lastSpacing : '' switch (Tag) { case 'br': currentMentions = null break case 'img': // replace images with StillImage return ['', [mentionsLinePadding, renderImage(opener)], ''] case 'a': // replace mentions with MentionLink if (!this.handleLinks) break if (attrs['class'] && attrs['class'].includes('mention')) { // Handling mentions here return renderMention(attrs, children) } else { currentMentions = null break } case 'span': if (this.handleLinks && attrs['class'] && attrs['class'].includes('h-card')) { return ['', children.map(processItem), ''] } } if (children !== undefined) { return [ '', [ mentionsLinePadding, [opener, children.map(processItem), closer] ], '' ] } else { return ['', [mentionsLinePadding, item], ''] } } } // Processor for back direction (for finding "last" stuff, just easier this way) let encounteredTextReverse = false const processItemReverse = (item, index, array, what) => { // Handle text nodes - just add emoji if (typeof item === 'string') { const emptyText = item.trim() === '' if (emptyText) return item if (!encounteredTextReverse) encounteredTextReverse = true return unescape(item) } else if (Array.isArray(item)) { // Handle tag nodes const [opener, children] = item const Tag = opener === '' ? '' : getTagName(opener) switch (Tag) { case 'a': // replace mentions with MentionLink if (!this.handleLinks) break const attrs = getAttrs(opener) // should only be this if ( (attrs['class'] && attrs['class'].includes('hashtag')) || // Pleroma style (attrs['rel'] === 'tag') // Mastodon style ) { return renderHashtag(attrs, children, encounteredTextReverse) } else { attrs.target = '_blank' const newChildren = [...children].reverse().map(processItemReverse).reverse() return { newChildren } } case '': return [...children].reverse().map(processItemReverse).reverse() } // Render tag as is if (children !== undefined) { const newChildren = Array.isArray(children) ? [...children].reverse().map(processItemReverse).reverse() : children return { newChildren } } else { return } } return item } const pass1 = convertHtmlToTree(this.mfm ? renderMisskeyMarkdown(html) : html).map(processItem) const pass2 = [...pass1].reverse().map(processItemReverse).reverse() // DO NOT USE SLOTS they cause a re-render feedback loop here. // slots updated -> rerender -> emit -> update up the tree -> rerender -> ... // at least until vue3? const result = { pass2 } const event = { lastTags, writtenMentions, writtenTags, invisibleMentions } // DO NOT MOVE TO UPDATE. BAD IDEA. this.$emit('parseReady', event) return result } } const getLinkData = (attrs, children, index) => { const stripTags = (item) => { if (typeof item === 'string') { return item } else { return item[1].map(stripTags).join('') } } const textContent = children.map(stripTags).join('') return { index, url: attrs.href, tag: attrs['data-tag'], content: flattenDeep(children).join(''), textContent } } /** Pre-processing HTML * * Currently this does one thing: * - add green/cyantexting * * @param {String} html - raw HTML to process * @param {Boolean} greentext - whether to enable greentexting or not */ export const preProcessPerLine = (html, greentext) => { const greentextHandle = new Set(['p', 'div']) const lines = convertHtmlToLines(html) const newHtml = lines.reverse().map((item, index, array) => { if (!item.text) return item const string = item.text // Greentext stuff if ( // Only if greentext is engaged greentext && // Only handle p's and divs. Don't want to affect blockquotes, code etc item.level.every(l => greentextHandle.has(l)) && // Only if line begins with '>' or '<' (string.includes('>') || string.includes('<')) ) { const cleanedString = string.replace(/<[^>]+?>/gi, '') // remove all tags .replace(/@\w+/gi, '') // remove mentions (even failed ones) .trim() if (cleanedString.startsWith('>')) { return `${string}` } else if (cleanedString.startsWith('<')) { return `${string}` } } return string }).reverse().join('') return { newHtml } }