diff --git a/.babelrc b/.babelrc index 3c732dd1..94521147 100644 --- a/.babelrc +++ b/.babelrc @@ -1,5 +1,5 @@ { - "presets": ["@babel/preset-env"], - "plugins": ["@babel/plugin-transform-runtime", "lodash", "@vue/babel-plugin-transform-vue-jsx"], + "presets": ["@babel/preset-env", "@vue/babel-preset-jsx"], + "plugins": ["@babel/plugin-transform-runtime", "lodash"], "comments": false } diff --git a/package.json b/package.json index 99301266..5134a8b1 100644 --- a/package.json +++ b/package.json @@ -47,8 +47,8 @@ "@babel/preset-env": "^7.7.6", "@babel/register": "^7.7.4", "@ungap/event-target": "^0.1.0", - "@vue/babel-helper-vue-jsx-merge-props": "^1.0.0", - "@vue/babel-plugin-transform-vue-jsx": "^1.1.2", + "@vue/babel-helper-vue-jsx-merge-props": "^1.2.1", + "@vue/babel-preset-jsx": "^1.2.4", "@vue/test-utils": "^1.0.0-beta.26", "autoprefixer": "^6.4.0", "babel-eslint": "^7.0.0", diff --git a/src/App.scss b/src/App.scss index 45071ba2..bc027f4f 100644 --- a/src/App.scss +++ b/src/App.scss @@ -88,6 +88,10 @@ a { font-family: sans-serif; font-family: var(--interfaceFont, sans-serif); + &.-sublime { + background: transparent; + } + i[class*=icon-], .svg-inline--fa { color: $fallback--text; diff --git a/src/components/basic_user_card/basic_user_card.js b/src/components/basic_user_card/basic_user_card.js index 87085a28..8f41e2fb 100644 --- a/src/components/basic_user_card/basic_user_card.js +++ b/src/components/basic_user_card/basic_user_card.js @@ -1,5 +1,6 @@ import UserCard from '../user_card/user_card.vue' import UserAvatar from '../user_avatar/user_avatar.vue' +import RichContent from 'src/components/rich_content/rich_content.jsx' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' const BasicUserCard = { @@ -13,7 +14,8 @@ const BasicUserCard = { }, components: { UserCard, - UserAvatar + UserAvatar, + RichContent }, methods: { toggleUserExpanded () { diff --git a/src/components/basic_user_card/basic_user_card.vue b/src/components/basic_user_card/basic_user_card.vue index c53f6a9c..53deb1df 100644 --- a/src/components/basic_user_card/basic_user_card.vue +++ b/src/components/basic_user_card/basic_user_card.vue @@ -25,17 +25,11 @@ :title="user.name" class="basic-user-card-user-name" > - - - - {{ user.name }}
${this.$t('chats.you')} ${content}` : content return { summary: '', - statusnet_html: messagePreview, + emojis: messageEmojis, + raw_html: messagePreview, text: messagePreview, attachments: [] } diff --git a/src/components/chat_list_item/chat_list_item.scss b/src/components/chat_list_item/chat_list_item.scss index 9e97b28e..57332bed 100644 --- a/src/components/chat_list_item/chat_list_item.scss +++ b/src/components/chat_list_item/chat_list_item.scss @@ -77,18 +77,15 @@ border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); } - .StatusContent { - img.emoji { - width: 1.4em; - height: 1.4em; - } + .chat-preview-body { + --emoji-size: 1.4em; } .time-wrapper { line-height: 1.4em; } - .single-line { + .chat-preview-body { padding-right: 1em; } } diff --git a/src/components/chat_list_item/chat_list_item.vue b/src/components/chat_list_item/chat_list_item.vue index cd3f436e..c7c0e878 100644 --- a/src/components/chat_list_item/chat_list_item.vue +++ b/src/components/chat_list_item/chat_list_item.vue @@ -29,7 +29,8 @@
- diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js index bb380f87..eb195bc1 100644 --- a/src/components/chat_message/chat_message.js +++ b/src/components/chat_message/chat_message.js @@ -57,8 +57,9 @@ const ChatMessage = { messageForStatusContent () { return { summary: '', - statusnet_html: this.message.content, - text: this.message.content, + emojis: this.message.emojis, + raw_html: this.message.content || '', + text: this.message.content || '', attachments: this.message.attachments } }, diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss index e4351d3b..fcfa7c8a 100644 --- a/src/components/chat_message/chat_message.scss +++ b/src/components/chat_message/chat_message.scss @@ -89,8 +89,9 @@ } .without-attachment { - .status-content { - &::after { + .message-content { + // TODO figure out how to do it properly + .RichContent::after { margin-right: 5.4em; content: " "; display: inline-block; @@ -162,6 +163,7 @@ .visible { opacity: 1; } + } .chat-message-date-separator { diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue index 0f3fc97d..d62b831d 100644 --- a/src/components/chat_message/chat_message.vue +++ b/src/components/chat_message/chat_message.vue @@ -71,6 +71,7 @@
diff --git a/src/components/hashtag_link/hashtag_link.js b/src/components/hashtag_link/hashtag_link.js new file mode 100644 index 00000000..a2433c2a --- /dev/null +++ b/src/components/hashtag_link/hashtag_link.js @@ -0,0 +1,36 @@ +import { extractTagFromUrl } from 'src/services/matcher/matcher.service.js' + +const HashtagLink = { + name: 'HashtagLink', + props: { + url: { + required: true, + type: String + }, + content: { + required: true, + type: String + }, + tag: { + required: false, + type: String, + default: '' + } + }, + methods: { + onClick () { + const tag = this.tag || extractTagFromUrl(this.url) + if (tag) { + const link = this.generateTagLink(tag) + this.$router.push(link) + } else { + window.open(this.url, '_blank') + } + }, + generateTagLink (tag) { + return `/tag/${tag}` + } + } +} + +export default HashtagLink diff --git a/src/components/hashtag_link/hashtag_link.scss b/src/components/hashtag_link/hashtag_link.scss new file mode 100644 index 00000000..78e8fb99 --- /dev/null +++ b/src/components/hashtag_link/hashtag_link.scss @@ -0,0 +1,6 @@ +.HashtagLink { + position: relative; + white-space: normal; + display: inline-block; + color: var(--link); +} diff --git a/src/components/hashtag_link/hashtag_link.vue b/src/components/hashtag_link/hashtag_link.vue new file mode 100644 index 00000000..918ed26b --- /dev/null +++ b/src/components/hashtag_link/hashtag_link.vue @@ -0,0 +1,19 @@ + + + + diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue index d3eb5925..0623b42e 100644 --- a/src/components/still-image/still-image.vue +++ b/src/components/still-image/still-image.vue @@ -30,7 +30,7 @@ position: relative; line-height: 0; overflow: hidden; - display: flex; + display: inline-flex; align-items: center; canvas { @@ -47,12 +47,13 @@ img { width: 100%; - min-height: 100%; + height: 100%; object-fit: contain; } &.animated { &::before { + zoom: var(--_still_image-label-scale, 1); content: 'gif'; position: absolute; line-height: 10px; diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js index 367fbc6c..cd8ca420 100644 --- a/src/components/user_card/user_card.js +++ b/src/components/user_card/user_card.js @@ -5,6 +5,7 @@ import FollowButton from '../follow_button/follow_button.vue' import ModerationTools from '../moderation_tools/moderation_tools.vue' import AccountActions from '../account_actions/account_actions.vue' import Select from '../select/select.vue' +import RichContent from 'src/components/rich_content/rich_content.jsx' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import { mapGetters } from 'vuex' import { library } from '@fortawesome/fontawesome-svg-core' @@ -120,7 +121,8 @@ export default { AccountActions, ProgressButton, FollowButton, - Select + Select, + RichContent }, methods: { muteUser () { diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue index 72c151b9..6b69d15a 100644 --- a/src/components/user_card/user_card.vue +++ b/src/components/user_card/user_card.vue @@ -38,21 +38,12 @@
- -
- -
- {{ user.name }} -
- -

- -

- {{ user.description }} -

@@ -293,9 +276,10 @@ .user-card { position: relative; - &:hover .Avatar { + &:hover { --_still-image-img-visibility: visible; --_still-image-canvas-visibility: hidden; + --_still-image-label-visibility: hidden; } .panel-heading { @@ -339,12 +323,12 @@ } } - p { - margin-bottom: 0; - } - &-bio { text-align: center; + display: block; + line-height: 18px; + padding: 1em; + margin: 0; a { color: $fallback--link; @@ -356,11 +340,6 @@ vertical-align: middle; max-width: 100%; max-height: 400px; - - &.emoji { - width: 32px; - height: 32px; - } } } @@ -462,13 +441,6 @@ // big one z-index: 1; - img { - width: 26px; - height: 26px; - vertical-align: middle; - object-fit: contain - } - .top-line { display: flex; } @@ -481,12 +453,7 @@ margin-right: 1em; font-size: 15px; - img { - object-fit: contain; - height: 16px; - width: 16px; - vertical-align: middle; - } + --emoji-size: 14px; } .bottom-line { diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js index c0b55a6c..7a475609 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -4,6 +4,7 @@ import FollowCard from '../follow_card/follow_card.vue' import Timeline from '../timeline/timeline.vue' import Conversation from '../conversation/conversation.vue' import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js' +import RichContent from 'src/components/rich_content/rich_content.jsx' import List from '../list/list.vue' import withLoadMore from '../../hocs/with_load_more/with_load_more' import { library } from '@fortawesome/fontawesome-svg-core' @@ -164,7 +165,8 @@ const UserProfile = { FriendList, FollowCard, TabSwitcher, - Conversation + Conversation, + RichContent } } diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index aef897ae..726216ff 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -20,20 +20,24 @@ :key="index" class="user-profile-field" > - query => { + return state.users + .find(u => u.statusnet_profile_url && + u.statusnet_profile_url.toLowerCase() === query.toLowerCase()) + }, relationship: state => id => { const rel = id && state.relationships[id] return rel || { id, loading: true } diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index a4ddf927..04bb45a4 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -54,17 +54,19 @@ export const parseUser = (data) => { return output } + output.emoji = data.emojis output.name = data.display_name - output.name_html = addEmojis(escape(data.display_name), data.emojis) + output.name_html = escape(data.display_name) output.description = data.note - output.description_html = addEmojis(data.note, data.emojis) + // TODO cleanup this shit, output.description is overriden with source data + output.description_html = data.note output.fields = data.fields output.fields_html = data.fields.map(field => { return { - name: addEmojis(escape(field.name), data.emojis), - value: addEmojis(field.value, data.emojis) + name: escape(field.name), + value: field.value } }) output.fields_text = data.fields.map(field => { @@ -239,16 +241,6 @@ export const parseAttachment = (data) => { return output } -export const addEmojis = (string, emojis) => { - const matchOperatorsRegex = /[|\\{}()[\]^$+*?.-]/g - return emojis.reduce((acc, emoji) => { - const regexSafeShortCode = emoji.shortcode.replace(matchOperatorsRegex, '\\$&') - return acc.replace( - new RegExp(`:${regexSafeShortCode}:`, 'g'), - `:${emoji.shortcode}:` - ) - }, string) -} export const parseStatus = (data) => { const output = {} @@ -266,7 +258,8 @@ export const parseStatus = (data) => { output.type = data.reblog ? 'retweet' : 'status' output.nsfw = data.sensitive - output.statusnet_html = addEmojis(data.content, data.emojis) + output.raw_html = data.content + output.emojis = data.emojis output.tags = data.tags @@ -293,13 +286,13 @@ export const parseStatus = (data) => { output.retweeted_status = parseStatus(data.reblog) } - output.summary_html = addEmojis(escape(data.spoiler_text), data.emojis) + output.summary_raw_html = escape(data.spoiler_text) output.external_url = data.url output.poll = data.poll if (output.poll) { output.poll.options = (output.poll.options || []).map(field => ({ ...field, - title_html: addEmojis(escape(field.title), data.emojis) + title_html: escape(field.title) })) } output.pinned = data.pinned @@ -325,7 +318,7 @@ export const parseStatus = (data) => { output.nsfw = data.nsfw } - output.statusnet_html = data.statusnet_html + output.raw_html = data.statusnet_html output.text = data.text output.in_reply_to_status_id = data.in_reply_to_status_id @@ -444,11 +437,8 @@ export const parseChatMessage = (message) => { output.id = message.id output.created_at = new Date(message.created_at) output.chat_id = message.chat_id - if (message.content) { - output.content = addEmojis(message.content, message.emojis) - } else { - output.content = '' - } + output.emojis = message.emojis + output.content = message.content if (message.attachment) { output.attachments = [parseAttachment(message.attachment)] } else { diff --git a/src/services/html_converter/html_line_converter.service.js b/src/services/html_converter/html_line_converter.service.js new file mode 100644 index 00000000..5eeaa7cb --- /dev/null +++ b/src/services/html_converter/html_line_converter.service.js @@ -0,0 +1,136 @@ +import { getTagName } from './utility.service.js' + +/** + * This is a tiny purpose-built HTML parser/processor. This basically detects + * any type of visual newline and converts entire HTML into a array structure. + * + * Text nodes are represented as object with single property - text - containing + * the visual line. Intended usage is to process the array with .map() in which + * map function returns a string and resulting array can be converted back to html + * with a .join(''). + * + * Generally this isn't very useful except for when you really need to either + * modify visual lines (greentext i.e. simple quoting) or do something with + * first/last line. + * + * known issue: doesn't handle CDATA so nested CDATA might not work well + * + * @param {Object} input - input data + * @return {(string|{ text: string })[]} processed html in form of a list. + */ +export const convertHtmlToLines = (html = '') => { + // Elements that are implicitly self-closing + // https://developer.mozilla.org/en-US/docs/Glossary/empty_element + const emptyElements = new Set([ + 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', + 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr' + ]) + // Block-level element (they make a visual line) + // https://developer.mozilla.org/en-US/docs/Web/HTML/Block-level_elements + const blockElements = new Set([ + 'address', 'article', 'aside', 'blockquote', 'details', 'dialog', 'dd', + 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'li', 'main', + 'nav', 'ol', 'p', 'pre', 'section', 'table', 'ul' + ]) + // br is very weird in a way that it's technically not block-level, it's + // essentially converted to a \n (or \r\n). There's also wbr but it doesn't + // guarantee linebreak, only suggest it. + const linebreakElements = new Set(['br']) + + const visualLineElements = new Set([ + ...blockElements.values(), + ...linebreakElements.values() + ]) + + // All block-level elements that aren't empty elements, i.e. not
+ const nonEmptyElements = new Set(visualLineElements) + // Difference + for (let elem of emptyElements) { + nonEmptyElements.delete(elem) + } + + // All elements that we are recognizing + const allElements = new Set([ + ...nonEmptyElements.values(), + ...emptyElements.values() + ]) + + let buffer = [] // Current output buffer + const level = [] // How deep we are in tags and which tags were there + let textBuffer = '' // Current line content + let tagBuffer = null // Current tag buffer, if null = we are not currently reading a tag + + const flush = () => { // Processes current line buffer, adds it to output buffer and clears line buffer + if (textBuffer.trim().length > 0) { + buffer.push({ level: [...level], text: textBuffer }) + } else { + buffer.push(textBuffer) + } + textBuffer = '' + } + + const handleBr = (tag) => { // handles single newlines/linebreaks/selfclosing + flush() + buffer.push(tag) + } + + const handleOpen = (tag) => { // handles opening tags + flush() + buffer.push(tag) + level.unshift(getTagName(tag)) + } + + const handleClose = (tag) => { // handles closing tags + if (level[0] === getTagName(tag)) { + flush() + buffer.push(tag) + level.shift() + } else { // Broken case + textBuffer += tag + } + } + + for (let i = 0; i < html.length; i++) { + const char = html[i] + if (char === '<' && tagBuffer === null) { + tagBuffer = char + } else if (char !== '>' && tagBuffer !== null) { + tagBuffer += char + } else if (char === '>' && tagBuffer !== null) { + tagBuffer += char + const tagFull = tagBuffer + tagBuffer = null + const tagName = getTagName(tagFull) + if (allElements.has(tagName)) { + if (linebreakElements.has(tagName)) { + handleBr(tagFull) + } else if (nonEmptyElements.has(tagName)) { + if (tagFull[1] === '/') { + handleClose(tagFull) + } else if (tagFull[tagFull.length - 2] === '/') { + // self-closing + handleBr(tagFull) + } else { + handleOpen(tagFull) + } + } else { + textBuffer += tagFull + } + } else { + textBuffer += tagFull + } + } else if (char === '\n') { + handleBr(char) + } else { + textBuffer += char + } + } + if (tagBuffer) { + textBuffer += tagBuffer + } + + flush() + + return buffer +} diff --git a/src/services/html_converter/html_tree_converter.service.js b/src/services/html_converter/html_tree_converter.service.js new file mode 100644 index 00000000..6a8796c4 --- /dev/null +++ b/src/services/html_converter/html_tree_converter.service.js @@ -0,0 +1,97 @@ +import { getTagName } from './utility.service.js' + +/** + * This is a not-so-tiny purpose-built HTML parser/processor. This parses html + * and converts it into a tree structure representing tag openers/closers and + * children. + * + * Structure follows this pattern: [opener, [...children], closer] except root + * node which is just [...children]. Text nodes can only be within children and + * are represented as strings. + * + * Intended use is to convert HTML structure and then recursively iterate over it + * most likely using a map. Very useful for dynamically rendering html replacing + * tags with JSX elements in a render function. + * + * known issue: doesn't handle CDATA so CDATA might not work well + * known issue: doesn't handle HTML comments + * + * @param {Object} input - input data + * @return {string} processed html + */ +export const convertHtmlToTree = (html = '') => { + // Elements that are implicitly self-closing + // https://developer.mozilla.org/en-US/docs/Glossary/empty_element + const emptyElements = new Set([ + 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', + 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr' + ]) + // TODO For future - also parse HTML5 multi-source components? + + const buffer = [] // Current output buffer + const levels = [['', buffer]] // How deep we are in tags and which tags were there + let textBuffer = '' // Current line content + let tagBuffer = null // Current tag buffer, if null = we are not currently reading a tag + + const getCurrentBuffer = () => { + return levels[levels.length - 1][1] + } + + const flushText = () => { // Processes current line buffer, adds it to output buffer and clears line buffer + if (textBuffer === '') return + getCurrentBuffer().push(textBuffer) + textBuffer = '' + } + + const handleSelfClosing = (tag) => { + getCurrentBuffer().push([tag]) + } + + const handleOpen = (tag) => { + const curBuf = getCurrentBuffer() + const newLevel = [tag, []] + levels.push(newLevel) + curBuf.push(newLevel) + } + + const handleClose = (tag) => { + const currentTag = levels[levels.length - 1] + if (getTagName(levels[levels.length - 1][0]) === getTagName(tag)) { + currentTag.push(tag) + levels.pop() + } else { + getCurrentBuffer().push(tag) + } + } + + for (let i = 0; i < html.length; i++) { + const char = html[i] + if (char === '<' && tagBuffer === null) { + flushText() + tagBuffer = char + } else if (char !== '>' && tagBuffer !== null) { + tagBuffer += char + } else if (char === '>' && tagBuffer !== null) { + tagBuffer += char + const tagFull = tagBuffer + tagBuffer = null + const tagName = getTagName(tagFull) + if (tagFull[1] === '/') { + handleClose(tagFull) + } else if (emptyElements.has(tagName) || tagFull[tagFull.length - 2] === '/') { + // self-closing + handleSelfClosing(tagFull) + } else { + handleOpen(tagFull) + } + } else { + textBuffer += char + } + } + if (tagBuffer) { + textBuffer += tagBuffer + } + + flushText() + return buffer +} diff --git a/src/services/html_converter/utility.service.js b/src/services/html_converter/utility.service.js new file mode 100644 index 00000000..4d0c36c2 --- /dev/null +++ b/src/services/html_converter/utility.service.js @@ -0,0 +1,73 @@ +/** + * Extract tag name from tag opener/closer. + * + * @param {String} tag - tag string, i.e. '' + * @return {String} - tagname, i.e. "div" + */ +export const getTagName = (tag) => { + const result = /(?:<\/(\w+)>|<(\w+)\s?.*?\/?>)/gi.exec(tag) + return result && (result[1] || result[2]) +} + +/** + * Extract attributes from tag opener. + * + * @param {String} tag - tag string, i.e. '' + * @return {Object} - map of attributes key = attribute name, value = attribute value + * attributes without values represented as boolean true + */ +export const getAttrs = tag => { + const innertag = tag + .substring(1, tag.length - 1) + .replace(new RegExp('^' + getTagName(tag)), '') + .replace(/\/?$/, '') + .trim() + const attrs = Array.from(innertag.matchAll(/([a-z0-9-]+)(?:=("[^"]+?"|'[^']+?'))?/gi)) + .map(([trash, key, value]) => [key, value]) + .map(([k, v]) => { + if (!v) return [k, true] + return [k, v.substring(1, v.length - 1)] + }) + return Object.fromEntries(attrs) +} + +/** + * Finds shortcodes in text + * + * @param {String} text - original text to find emojis in + * @param {{ url: String, shortcode: Sring }[]} emoji - list of shortcodes to find + * @param {Function} processor - function to call on each encountered emoji, + * function is passed single object containing matching emoji ({ url, shortcode }) + * return value will be inserted into resulting array instead of :shortcode: + * @return {Array} resulting array with non-emoji parts of text and whatever {processor} + * returned for emoji + */ +export const processTextForEmoji = (text, emojis, processor) => { + const buffer = [] + let textBuffer = '' + for (let i = 0; i < text.length; i++) { + const char = text[i] + if (char === ':') { + const next = text.slice(i + 1) + let found = false + for (let emoji of emojis) { + if (next.slice(0, emoji.shortcode.length + 1) === (emoji.shortcode + ':')) { + found = emoji + break + } + } + if (found) { + buffer.push(textBuffer) + textBuffer = '' + buffer.push(processor(found)) + i += found.shortcode.length + 1 + } else { + textBuffer += char + } + } else { + textBuffer += char + } + } + if (textBuffer) buffer.push(textBuffer) + return buffer +} diff --git a/src/services/theme_data/pleromafe.js b/src/services/theme_data/pleromafe.js index 14aac975..c2983be7 100644 --- a/src/services/theme_data/pleromafe.js +++ b/src/services/theme_data/pleromafe.js @@ -369,6 +369,12 @@ export const SLOT_INHERITANCE = { textColor: 'preserve' }, + postCyantext: { + depends: ['cBlue'], + layer: 'bg', + textColor: 'preserve' + }, + border: { depends: ['fg'], opacity: 'border', diff --git a/src/services/tiny_post_html_processor/tiny_post_html_processor.service.js b/src/services/tiny_post_html_processor/tiny_post_html_processor.service.js deleted file mode 100644 index de6f20ef..00000000 --- a/src/services/tiny_post_html_processor/tiny_post_html_processor.service.js +++ /dev/null @@ -1,94 +0,0 @@ -/** - * This is a tiny purpose-built HTML parser/processor. This basically detects any type of visual newline and - * allows it to be processed, useful for greentexting, mostly - * - * known issue: doesn't handle CDATA so nested CDATA might not work well - * - * @param {Object} input - input data - * @param {(string) => string} processor - function that will be called on every line - * @return {string} processed html - */ -export const processHtml = (html, processor) => { - const handledTags = new Set(['p', 'br', 'div']) - const openCloseTags = new Set(['p', 'div']) - - let buffer = '' // Current output buffer - const level = [] // How deep we are in tags and which tags were there - let textBuffer = '' // Current line content - let tagBuffer = null // Current tag buffer, if null = we are not currently reading a tag - - // Extracts tag name from tag, i.e. => span - const getTagName = (tag) => { - const result = /(?:<\/(\w+)>|<(\w+)\s?[^/]*?\/?>)/gi.exec(tag) - return result && (result[1] || result[2]) - } - - const flush = () => { // Processes current line buffer, adds it to output buffer and clears line buffer - if (textBuffer.trim().length > 0) { - buffer += processor(textBuffer) - } else { - buffer += textBuffer - } - textBuffer = '' - } - - const handleBr = (tag) => { // handles single newlines/linebreaks/selfclosing - flush() - buffer += tag - } - - const handleOpen = (tag) => { // handles opening tags - flush() - buffer += tag - level.push(tag) - } - - const handleClose = (tag) => { // handles closing tags - flush() - buffer += tag - if (level[level.length - 1] === tag) { - level.pop() - } - } - - for (let i = 0; i < html.length; i++) { - const char = html[i] - if (char === '<' && tagBuffer === null) { - tagBuffer = char - } else if (char !== '>' && tagBuffer !== null) { - tagBuffer += char - } else if (char === '>' && tagBuffer !== null) { - tagBuffer += char - const tagFull = tagBuffer - tagBuffer = null - const tagName = getTagName(tagFull) - if (handledTags.has(tagName)) { - if (tagName === 'br') { - handleBr(tagFull) - } else if (openCloseTags.has(tagName)) { - if (tagFull[1] === '/') { - handleClose(tagFull) - } else if (tagFull[tagFull.length - 2] === '/') { - // self-closing - handleBr(tagFull) - } else { - handleOpen(tagFull) - } - } - } else { - textBuffer += tagFull - } - } else if (char === '\n') { - handleBr(char) - } else { - textBuffer += char - } - } - if (tagBuffer) { - textBuffer += tagBuffer - } - - flush() - - return buffer -} diff --git a/src/services/user_highlighter/user_highlighter.js b/src/services/user_highlighter/user_highlighter.js index b91c0f78..3b07592e 100644 --- a/src/services/user_highlighter/user_highlighter.js +++ b/src/services/user_highlighter/user_highlighter.js @@ -8,6 +8,11 @@ const highlightStyle = (prefs) => { const solidColor = `rgb(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)})` const tintColor = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .1)` const tintColor2 = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .2)` + const customProps = { + '--____highlight-solidColor': solidColor, + '--____highlight-tintColor': tintColor, + '--____highlight-tintColor2': tintColor2 + } if (type === 'striped') { return { backgroundImage: [ @@ -17,11 +22,13 @@ const highlightStyle = (prefs) => { `${tintColor2} 20px,`, `${tintColor2} 40px` ].join(' '), - backgroundPosition: '0 0' + backgroundPosition: '0 0', + ...customProps } } else if (type === 'solid') { return { - backgroundColor: tintColor2 + backgroundColor: tintColor2, + ...customProps } } else if (type === 'side') { return { @@ -31,7 +38,8 @@ const highlightStyle = (prefs) => { `${solidColor} 2px,`, `transparent 6px` ].join(' '), - backgroundPosition: '0 0' + backgroundPosition: '0 0', + ...customProps } } } diff --git a/test/unit/specs/components/rich_content.spec.js b/test/unit/specs/components/rich_content.spec.js new file mode 100644 index 00000000..f6c478a9 --- /dev/null +++ b/test/unit/specs/components/rich_content.spec.js @@ -0,0 +1,480 @@ +import { mount, shallowMount, createLocalVue } from '@vue/test-utils' +import RichContent from 'src/components/rich_content/rich_content.jsx' + +const localVue = createLocalVue() +const attentions = [] + +const makeMention = (who) => { + attentions.push({ statusnet_profile_url: `https://fake.tld/@${who}` }) + return `@${who}
` +} +const p = (...data) => `

${data.join('')}

` +const compwrap = (...data) => `${data.join('')}` +const mentionsLine = (times) => [ + '' +].join('') + +describe('RichContent', () => { + it('renders simple post without exploding', () => { + const html = p('Hello world!') + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + attentions, + handleLinks: true, + greentext: true, + emoji: [], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(html)) + }) + + it('unescapes everything as needed', () => { + const html = [ + p('Testing 'em all'), + 'Testing 'em all' + ].join('') + const expected = [ + p('Testing \'em all'), + 'Testing \'em all' + ].join('') + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + attentions, + handleLinks: true, + greentext: true, + emoji: [], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(expected)) + }) + + it('replaces mention with mentionsline', () => { + const html = p( + makeMention('John'), + ' how are you doing today?' + ) + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + attentions, + handleLinks: true, + greentext: true, + emoji: [], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(p( + mentionsLine(1), + ' how are you doing today?' + ))) + }) + + it('replaces mentions at the end of the hellpost', () => { + const html = [ + p('How are you doing today, fine gentlemen?'), + p( + makeMention('John'), + makeMention('Josh'), + makeMention('Jeremy') + ) + ].join('') + const expected = [ + p( + 'How are you doing today, fine gentlemen?' + ), + // TODO fix this extra line somehow? + p( + '' + ) + ].join('') + + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + attentions, + handleLinks: true, + greentext: true, + emoji: [], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(expected)) + }) + + it('Does not touch links if link handling is disabled', () => { + const html = [ + [ + makeMention('Jack'), + 'let\'s meet up with ', + makeMention('Janet') + ].join(''), + [ + makeMention('John'), + makeMention('Josh'), + makeMention('Jeremy') + ].join('') + ].join('\n') + + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + attentions, + handleLinks: false, + greentext: true, + emoji: [], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(html)) + }) + + it('Adds greentext and cyantext to the post', () => { + const html = [ + '>preordering videogames', + '>any year' + ].join('\n') + const expected = [ + '>preordering videogames', + '>any year' + ].join('\n') + + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + attentions, + handleLinks: false, + greentext: true, + emoji: [], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(expected)) + }) + + it('Does not add greentext and cyantext if setting is set to false', () => { + const html = [ + '>preordering videogames', + '>any year' + ].join('\n') + + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + attentions, + handleLinks: false, + greentext: false, + emoji: [], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(html)) + }) + + it('Adds emoji to post', () => { + const html = p('Ebin :DDDD :spurdo:') + const expected = p( + 'Ebin :DDDD ', + '' + ) + + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + attentions, + handleLinks: false, + greentext: false, + emoji: [{ url: 'about:blank', shortcode: 'spurdo' }], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(expected)) + }) + + it('Doesn\'t add nonexistent emoji to post', () => { + const html = p('Lol :lol:') + + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + attentions, + handleLinks: false, + greentext: false, + emoji: [], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(html)) + }) + + it('Greentext + last mentions', () => { + const html = [ + '>quote', + makeMention('lol'), + '>quote', + '>quote' + ].join('\n') + const expected = [ + '>quote', + mentionsLine(1), + '>quote', + '>quote' + ].join('\n') + + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + attentions, + handleLinks: true, + greentext: true, + emoji: [], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(expected)) + }) + + it('One buggy example', () => { + const html = [ + 'Bruh', + 'Bruh', + [ + makeMention('foo'), + makeMention('bar'), + makeMention('baz') + ].join(''), + 'Bruh' + ].join('
') + const expected = [ + 'Bruh', + 'Bruh', + mentionsLine(3), + 'Bruh' + ].join('
') + + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + attentions, + handleLinks: true, + greentext: true, + emoji: [], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(expected)) + }) + + it('buggy example/hashtags', () => { + const html = [ + '

', + '', + 'NHCMDUXJPPZ6M3Z2CQ6D2EBRSWGE7MZY.jpg', + ' ', + '#nou', + ' ', + '#screencap', + '

' + ].join('') + const expected = [ + '

', + '', + 'NHCMDUXJPPZ6M3Z2CQ6D2EBRSWGE7MZY.jpg', + ' ', + '', + ' ', + '', + '

' + ].join('') + + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + attentions, + handleLinks: true, + greentext: true, + emoji: [], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(expected)) + }) + + it('rich contents of a mention are handled properly', () => { + attentions.push({ statusnet_profile_url: 'lol' }) + const html = [ + p( + '', + '', + 'https://', + '', + 'lol.tld/', + '', + '', + '' + ), + p( + 'Testing' + ) + ].join('') + const expected = [ + p( + '', + '', + '', + '', + 'https://', + '', + 'lol.tld/', + '', + '', + '', + ' ', + '', // v-if placeholder, mentionlink's "new" (i.e. rich) display + '', + '', // v-if placeholder, mentionsline's extra mentions and stuff + '' + ), + p( + 'Testing' + ) + ].join('') + + const wrapper = mount(RichContent, { + localVue, + propsData: { + attentions, + handleLinks: true, + greentext: true, + emoji: [], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(expected)) + }) + + it('rich contents of a link are handled properly', () => { + const html = [ + '

', + 'Freenode is dead.

', + '

', + '', + '', + 'https://', + '', + 'isfreenodedeadyet.com/', + '', + '', + '', + '

' + ].join('') + const expected = [ + '

', + 'Freenode is dead.

', + '

', + '', + '', + 'https://', + '', + 'isfreenodedeadyet.com/', + '', + '', + '', + '

' + ].join('') + + const wrapper = shallowMount(RichContent, { + localVue, + propsData: { + attentions, + handleLinks: true, + greentext: true, + emoji: [], + html + } + }) + + expect(wrapper.html()).to.eql(compwrap(expected)) + }) + + it.skip('[INFORMATIVE] Performance testing, 10 000 simple posts', () => { + const amount = 20 + + const onePost = p( + makeMention('Lain'), + makeMention('Lain'), + makeMention('Lain'), + makeMention('Lain'), + makeMention('Lain'), + makeMention('Lain'), + makeMention('Lain'), + makeMention('Lain'), + makeMention('Lain'), + makeMention('Lain'), + ' i just landed in l a where are you' + ) + + const TestComponent = { + template: ` +
+ ${new Array(amount).fill(``)} +
+
+ ${new Array(amount).fill(`
`)} +
+ `, + props: ['handleLinks', 'attentions', 'vhtml'] + } + console.log(1) + + const ptest = (handleLinks, vhtml) => { + const t0 = performance.now() + + const wrapper = mount(TestComponent, { + localVue, + propsData: { + attentions, + handleLinks, + vhtml + } + }) + + const t1 = performance.now() + + wrapper.destroy() + + const t2 = performance.now() + + return `Mount: ${t1 - t0}ms, destroy: ${t2 - t1}ms, avg ${(t1 - t0) / amount}ms - ${(t2 - t1) / amount}ms per item` + } + + console.log(`${amount} items with links handling:`) + console.log(ptest(true)) + console.log(`${amount} items without links handling:`) + console.log(ptest(false)) + console.log(`${amount} items plain v-html:`) + console.log(ptest(false, true)) + }) +}) diff --git a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js index 759539e0..03fb32c9 100644 --- a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js +++ b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js @@ -1,4 +1,4 @@ -import { parseStatus, parseUser, parseNotification, addEmojis, parseLinkHeaderPagination } from '../../../../../src/services/entity_normalizer/entity_normalizer.service.js' +import { parseStatus, parseUser, parseNotification, parseLinkHeaderPagination } from '../../../../../src/services/entity_normalizer/entity_normalizer.service.js' import mastoapidata from '../../../../fixtures/mastoapi.json' import qvitterapidata from '../../../../fixtures/statuses.json' @@ -23,7 +23,6 @@ const makeMockStatusQvitter = (overrides = {}) => { repeat_num: 0, repeated: false, statusnet_conversation_id: '16300488', - statusnet_html: '

haha benis

', summary: null, tags: [], text: 'haha benis', @@ -232,22 +231,6 @@ describe('API Entities normalizer', () => { expect(parsedRepeat).to.have.property('retweeted_status') expect(parsedRepeat).to.have.deep.property('retweeted_status.id', 'deadbeef') }) - - it('adds emojis to post content', () => { - const post = makeMockStatusMasto({ emojis: makeMockEmojiMasto(), content: 'Makes you think :thinking:' }) - - const parsedPost = parseStatus(post) - - expect(parsedPost).to.have.property('statusnet_html').that.contains(' { - const post = makeMockStatusMasto({ emojis: makeMockEmojiMasto(), spoiler_text: 'CW: 300 IQ :thinking:' }) - - const parsedPost = parseStatus(post) - - expect(parsedPost).to.have.property('summary_html').that.contains(' { expect(parseUser(remote)).to.have.property('is_local', false) }) - it('adds emojis to user name', () => { - const user = makeMockUserMasto({ emojis: makeMockEmojiMasto(), display_name: 'The :thinking: thinker' }) - - const parsedUser = parseUser(user) - - expect(parsedUser).to.have.property('name_html').that.contains(' { - const user = makeMockUserMasto({ emojis: makeMockEmojiMasto(), note: 'Hello i like to :thinking: a lot' }) - - const parsedUser = parseUser(user) - - expect(parsedUser).to.have.property('description_html').that.contains(' { - const user = makeMockUserMasto({ emojis: makeMockEmojiMasto(), fields: [{ name: ':thinking:', value: ':image:' }] }) - - const parsedUser = parseUser(user) - - expect(parsedUser).to.have.property('fields_html').to.be.an('array') - - const field = parsedUser.fields_html[0] - - expect(field).to.have.property('name').that.contains(' { const user = makeMockUserMasto({ emojis: makeMockEmojiMasto(), fields: [{ name: 'user', value: '@user' }] }) @@ -355,41 +309,6 @@ describe('API Entities normalizer', () => { }) }) - describe('MastoAPI emoji adder', () => { - const emojis = makeMockEmojiMasto() - const imageHtml = ':image:' - .replace(/"/g, '\'') - const thinkHtml = ':thinking:' - .replace(/"/g, '\'') - - it('correctly replaces shortcodes in supplied string', () => { - const result = addEmojis('This post has :image: emoji and :thinking: emoji', emojis) - expect(result).to.include(thinkHtml) - expect(result).to.include(imageHtml) - }) - - it('handles consecutive emojis correctly', () => { - const result = addEmojis('Lelel emoji spam :thinking::thinking::thinking::thinking:', emojis) - expect(result).to.include(thinkHtml + thinkHtml + thinkHtml + thinkHtml) - }) - - it('Doesn\'t replace nonexistent emojis', () => { - const result = addEmojis('Admin add the :tenshi: emoji', emojis) - expect(result).to.equal('Admin add the :tenshi: emoji') - }) - - it('Doesn\'t blow up on regex special characters', () => { - const emojis = makeMockEmojiMasto([{ - shortcode: 'c++' - }, { - shortcode: '[a-z] {|}*' - }]) - const result = addEmojis('This post has :c++: emoji and :[a-z] {|}*: emoji', emojis) - expect(result).to.include('title=\':c++:\'') - expect(result).to.include('title=\':[a-z] {|}*:\'') - }) - }) - describe('Link header pagination', () => { it('Parses min and max ids as integers', () => { const linkHeader = '; rel="next", ; rel="prev"' diff --git a/test/unit/specs/services/html_converter/html_line_converter.spec.js b/test/unit/specs/services/html_converter/html_line_converter.spec.js new file mode 100644 index 00000000..86bd7e8b --- /dev/null +++ b/test/unit/specs/services/html_converter/html_line_converter.spec.js @@ -0,0 +1,171 @@ +import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js' + +const greentextHandle = new Set(['p', 'div']) +const mapOnlyText = (processor) => (input) => { + if (input.text && input.level.every(l => greentextHandle.has(l))) { + return processor(input.text) + } else if (input.text) { + return input.text + } else { + return input + } +} + +describe('html_line_converter', () => { + describe('with processor that keeps original line should not make any changes to HTML when', () => { + const processorKeep = (line) => line + it('fed with regular HTML with newlines', () => { + const inputOutput = '1
2

3 4

5 \n 6

7
8


\n
' + const result = convertHtmlToLines(inputOutput) + const comparableResult = result.map(mapOnlyText(processorKeep)).join('') + expect(comparableResult).to.eql(inputOutput) + }) + + it('fed with possibly broken HTML with invalid tags/composition', () => { + const inputOutput = ' ayylmao ' + const result = convertHtmlToLines(inputOutput) + const comparableResult = result.map(mapOnlyText(processorKeep)).join('') + expect(comparableResult).to.eql(inputOutput) + }) + + it('fed with very broken HTML with broken composition', () => { + const inputOutput = '

lmao what
whats going on
wha

' + const result = convertHtmlToLines(inputOutput) + const comparableResult = result.map(mapOnlyText(processorKeep)).join('') + expect(comparableResult).to.eql(inputOutput) + }) + + it('fed with sorta valid HTML but tags aren\'t closed', () => { + const inputOutput = 'just leaving a

hanging' + const result = convertHtmlToLines(inputOutput) + const comparableResult = result.map(mapOnlyText(processorKeep)).join('') + expect(comparableResult).to.eql(inputOutput) + }) + + it('fed with not really HTML at this point... tags that aren\'t finished', () => { + const inputOutput = 'do you expect me to finish this
{ + const inputOutput = 'look ma

p \nwithin

p!

and a
div!

' + const result = convertHtmlToLines(inputOutput) + const comparableResult = result.map(mapOnlyText(processorKeep)).join('') + expect(comparableResult).to.eql(inputOutput) + }) + + it('fed with maybe valid HTML? self-closing divs and ps', () => { + const inputOutput = 'a
what now

?' + const result = convertHtmlToLines(inputOutput) + const comparableResult = result.map(mapOnlyText(processorKeep)).join('') + expect(comparableResult).to.eql(inputOutput) + }) + + it('fed with valid XHTML containing a CDATA', () => { + const inputOutput = 'Yes, it is me, ' + const result = convertHtmlToLines(inputOutput) + const comparableResult = result.map(mapOnlyText(processorKeep)).join('') + expect(comparableResult).to.eql(inputOutput) + }) + + it('fed with some recognized but not handled elements', () => { + const inputOutput = 'testing images\n\n' + const result = convertHtmlToLines(inputOutput) + const comparableResult = result.map(mapOnlyText(processorKeep)).join('') + expect(comparableResult).to.eql(inputOutput) + }) + }) + describe('with processor that replaces lines with word "_" should match expected line when', () => { + const processorReplace = (line) => '_' + it('fed with regular HTML with newlines', () => { + const input = '1
2

3 4

5 \n 6

7
8


\n
' + const output = '_
_

_

_\n_

_
_


\n
' + const result = convertHtmlToLines(input) + const comparableResult = result.map(mapOnlyText(processorReplace)).join('') + expect(comparableResult).to.eql(output) + }) + + it('fed with possibly broken HTML with invalid tags/composition', () => { + const input = ' ayylmao ' + const output = '_' + const result = convertHtmlToLines(input) + const comparableResult = result.map(mapOnlyText(processorReplace)).join('') + expect(comparableResult).to.eql(output) + }) + + it('fed with very broken HTML with broken composition', () => { + const input = '

lmao what
whats going on
wha

' + const output = '_

_

' + const result = convertHtmlToLines(input) + const comparableResult = result.map(mapOnlyText(processorReplace)).join('') + expect(comparableResult).to.eql(output) + }) + + it('fed with sorta valid HTML but tags aren\'t closed', () => { + const input = 'just leaving a

hanging' + const output = '_
_' + const result = convertHtmlToLines(input) + const comparableResult = result.map(mapOnlyText(processorReplace)).join('') + expect(comparableResult).to.eql(output) + }) + + it('fed with not really HTML at this point... tags that aren\'t finished', () => { + const input = 'do you expect me to finish this
{ + const input = 'look ma

p \nwithin

p!

and a
div!

' + const output = '_

_\n_

_

_
_

' + const result = convertHtmlToLines(input) + const comparableResult = result.map(mapOnlyText(processorReplace)).join('') + expect(comparableResult).to.eql(output) + }) + + it('fed with maybe valid HTML? (XHTML) self-closing divs and ps', () => { + const input = 'a
what now

?' + const output = '_

_

_' + const result = convertHtmlToLines(input) + const comparableResult = result.map(mapOnlyText(processorReplace)).join('') + expect(comparableResult).to.eql(output) + }) + + it('fed with valid XHTML containing a CDATA', () => { + const input = 'Yes, it is me, ' + const output = '_' + const result = convertHtmlToLines(input) + const comparableResult = result.map(mapOnlyText(processorReplace)).join('') + expect(comparableResult).to.eql(output) + }) + + it('Testing handling ignored blocks', () => { + const input = ` +

> rei = "0"
+      '0'
+      > rei == 0
+      true
+      > rei == null
+      false
That, christian-like JS diagram but it’s evangelion instead.
+ ` + const result = convertHtmlToLines(input) + const comparableResult = result.map(mapOnlyText(processorReplace)).join('') + expect(comparableResult).to.eql(input) + }) + it('Testing handling ignored blocks 2', () => { + const input = ` +
An SSL error has happened.

Shakespeare

+ ` + const output = ` +
An SSL error has happened.

_

+ ` + const result = convertHtmlToLines(input) + const comparableResult = result.map(mapOnlyText(processorReplace)).join('') + expect(comparableResult).to.eql(output) + }) + }) +}) diff --git a/test/unit/specs/services/html_converter/html_tree_converter.spec.js b/test/unit/specs/services/html_converter/html_tree_converter.spec.js new file mode 100644 index 00000000..7283021b --- /dev/null +++ b/test/unit/specs/services/html_converter/html_tree_converter.spec.js @@ -0,0 +1,132 @@ +import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js' + +describe('html_tree_converter', () => { + describe('convertHtmlToTree', () => { + it('converts html into a tree structure', () => { + const input = '1

2

345' + expect(convertHtmlToTree(input)).to.eql([ + '1 ', + [ + '

', + ['2'], + '

' + ], + ' ', + [ + '', + [ + '3', + [''], + '4' + ], + '' + ], + '5' + ]) + }) + it('converts html to tree while preserving tag formatting', () => { + const input = '1

2

345' + expect(convertHtmlToTree(input)).to.eql([ + '1 ', + [ + '

', + ['2'], + '

' + ], + [ + '', + [ + '3', + [''], + '4' + ], + '' + ], + '5' + ]) + }) + it('converts semi-broken html', () => { + const input = '1
2

42' + expect(convertHtmlToTree(input)).to.eql([ + '1 ', + ['
'], + ' 2 ', + [ + '

', + [' 42'] + ] + ]) + }) + it('realistic case 1', () => { + const input = '

@benis @hj nice

' + expect(convertHtmlToTree(input)).to.eql([ + [ + '

', + [ + [ + '', + [ + [ + '', + [ + '@', + [ + '', + [ + 'benis' + ], + '' + ] + ], + '' + ] + ], + '' + ], + ' ', + [ + '', + [ + [ + '', + [ + '@', + [ + '', + [ + 'hj' + ], + '' + ] + ], + '' + ] + ], + '' + ], + ' nice' + ], + '

' + ] + ]) + }) + it('realistic case 2', () => { + const inputOutput = 'Country improv: give me a city
Audience: Memphis
Improv troupe: come on, a better one
Audience: el paso' + expect(convertHtmlToTree(inputOutput)).to.eql([ + 'Country improv: give me a city', + [ + '
' + ], + 'Audience: Memphis', + [ + '
' + ], + 'Improv troupe: come on, a better one', + [ + '
' + ], + 'Audience: el paso' + ]) + }) + }) +}) diff --git a/test/unit/specs/services/html_converter/utility.spec.js b/test/unit/specs/services/html_converter/utility.spec.js new file mode 100644 index 00000000..cf6fd99b --- /dev/null +++ b/test/unit/specs/services/html_converter/utility.spec.js @@ -0,0 +1,37 @@ +import { processTextForEmoji, getAttrs } from 'src/services/html_converter/utility.service.js' + +describe('html_converter utility', () => { + describe('processTextForEmoji', () => { + it('processes all emoji in text', () => { + const input = 'Hello from finland! :lol: We have best water! :lmao:' + const emojis = [ + { shortcode: 'lol', src: 'LOL' }, + { shortcode: 'lmao', src: 'LMAO' } + ] + const processor = ({ shortcode, src }) => ({ shortcode, src }) + expect(processTextForEmoji(input, emojis, processor)).to.eql([ + 'Hello from finland! ', + { shortcode: 'lol', src: 'LOL' }, + ' We have best water! ', + { shortcode: 'lmao', src: 'LMAO' } + ]) + }) + it('leaves text as is', () => { + const input = 'Number one: that\'s terror' + const emojis = [] + const processor = ({ shortcode, src }) => ({ shortcode, src }) + expect(processTextForEmoji(input, emojis, processor)).to.eql([ + 'Number one: that\'s terror' + ]) + }) + }) + + describe('getAttrs', () => { + it('extracts arguments from tag', () => { + const input = '' + const output = { src: 'boop', cool: true, ebin: 'true' } + + expect(getAttrs(input)).to.eql(output) + }) + }) +}) diff --git a/test/unit/specs/services/tiny_post_html_processor/tiny_post_html_processor.spec.js b/test/unit/specs/services/tiny_post_html_processor/tiny_post_html_processor.spec.js deleted file mode 100644 index f301429d..00000000 --- a/test/unit/specs/services/tiny_post_html_processor/tiny_post_html_processor.spec.js +++ /dev/null @@ -1,96 +0,0 @@ -import { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js' - -describe('TinyPostHTMLProcessor', () => { - describe('with processor that keeps original line should not make any changes to HTML when', () => { - const processorKeep = (line) => line - it('fed with regular HTML with newlines', () => { - const inputOutput = '1
2

3 4

5 \n 6

7
8


\n
' - expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput) - }) - - it('fed with possibly broken HTML with invalid tags/composition', () => { - const inputOutput = ' ayylmao ' - expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput) - }) - - it('fed with very broken HTML with broken composition', () => { - const inputOutput = '

lmao what
whats going on
wha

' - expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput) - }) - - it('fed with sorta valid HTML but tags aren\'t closed', () => { - const inputOutput = 'just leaving a

hanging' - expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput) - }) - - it('fed with not really HTML at this point... tags that aren\'t finished', () => { - const inputOutput = 'do you expect me to finish this
{ - const inputOutput = 'look ma

p \nwithin

p!

and a
div!

' - expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput) - }) - - it('fed with maybe valid HTML? self-closing divs and ps', () => { - const inputOutput = 'a
what now

?' - expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput) - }) - - it('fed with valid XHTML containing a CDATA', () => { - const inputOutput = 'Yes, it is me, ' - expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput) - }) - }) - describe('with processor that replaces lines with word "_" should match expected line when', () => { - const processorReplace = (line) => '_' - it('fed with regular HTML with newlines', () => { - const input = '1
2

3 4

5 \n 6

7
8


\n
' - const output = '_
_

_

_\n_

_
_


\n
' - expect(processHtml(input, processorReplace)).to.eql(output) - }) - - it('fed with possibly broken HTML with invalid tags/composition', () => { - const input = ' ayylmao ' - const output = '_' - expect(processHtml(input, processorReplace)).to.eql(output) - }) - - it('fed with very broken HTML with broken composition', () => { - const input = '

lmao what
whats going on
wha

' - const output = '

_
_
_

' - expect(processHtml(input, processorReplace)).to.eql(output) - }) - - it('fed with sorta valid HTML but tags aren\'t closed', () => { - const input = 'just leaving a

hanging' - const output = '_
_' - expect(processHtml(input, processorReplace)).to.eql(output) - }) - - it('fed with not really HTML at this point... tags that aren\'t finished', () => { - const input = 'do you expect me to finish this
{ - const input = 'look ma

p \nwithin

p!

and a
div!

' - const output = '_

_\n_

_

_
_

' - expect(processHtml(input, processorReplace)).to.eql(output) - }) - - it('fed with maybe valid HTML? self-closing divs and ps', () => { - const input = 'a
what now

?' - const output = '_

_

_' - expect(processHtml(input, processorReplace)).to.eql(output) - }) - - it('fed with valid XHTML containing a CDATA', () => { - const input = 'Yes, it is me, ' - const output = '_' - expect(processHtml(input, processorReplace)).to.eql(output) - }) - }) -}) diff --git a/yarn.lock b/yarn.lock index 23cc895b..9329cc3a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1011,23 +1011,86 @@ resolved "https://registry.yarnpkg.com/@ungap/event-target/-/event-target-0.1.0.tgz#88d527d40de86c4b0c99a060ca241d755999915b" integrity sha512-W2oyj0Fe1w/XhPZjkI3oUcDUAmu5P4qsdT2/2S8aMhtAWM/CE/jYWtji0pKNPDfxLI75fa5gWSEmnynKMNP/oA== -"@vue/babel-helper-vue-jsx-merge-props@^1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@vue/babel-helper-vue-jsx-merge-props/-/babel-helper-vue-jsx-merge-props-1.0.0.tgz#048fe579958da408fb7a8b2a3ec050b50a661040" - integrity sha512-6tyf5Cqm4m6v7buITuwS+jHzPlIPxbFzEhXR5JGZpbrvOcp1hiQKckd305/3C7C36wFekNTQSxAtgeM0j0yoUw== +"@vue/babel-helper-vue-jsx-merge-props@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@vue/babel-helper-vue-jsx-merge-props/-/babel-helper-vue-jsx-merge-props-1.2.1.tgz#31624a7a505fb14da1d58023725a4c5f270e6a81" + integrity sha512-QOi5OW45e2R20VygMSNhyQHvpdUwQZqGPc748JLGCYEy+yp8fNFNdbNIGAgZmi9e+2JHPd6i6idRuqivyicIkA== -"@vue/babel-plugin-transform-vue-jsx@^1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@vue/babel-plugin-transform-vue-jsx/-/babel-plugin-transform-vue-jsx-1.1.2.tgz#c0a3e6efc022e75e4247b448a8fc6b86f03e91c0" - integrity sha512-YfdaoSMvD1nj7+DsrwfTvTnhDXI7bsuh+Y5qWwvQXlD24uLgnsoww3qbiZvWf/EoviZMrvqkqN4CBw0W3BWUTQ== +"@vue/babel-plugin-transform-vue-jsx@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@vue/babel-plugin-transform-vue-jsx/-/babel-plugin-transform-vue-jsx-1.2.1.tgz#646046c652c2f0242727f34519d917b064041ed7" + integrity sha512-HJuqwACYehQwh1fNT8f4kyzqlNMpBuUK4rSiSES5D4QsYncv5fxFsLyrxFPG2ksO7t5WP+Vgix6tt6yKClwPzA== dependencies: "@babel/helper-module-imports" "^7.0.0" "@babel/plugin-syntax-jsx" "^7.2.0" - "@vue/babel-helper-vue-jsx-merge-props" "^1.0.0" + "@vue/babel-helper-vue-jsx-merge-props" "^1.2.1" html-tags "^2.0.0" lodash.kebabcase "^4.1.1" svg-tags "^1.0.0" +"@vue/babel-preset-jsx@^1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@vue/babel-preset-jsx/-/babel-preset-jsx-1.2.4.tgz#92fea79db6f13b01e80d3a0099e2924bdcbe4e87" + integrity sha512-oRVnmN2a77bYDJzeGSt92AuHXbkIxbf/XXSE3klINnh9AXBmVS1DGa1f0d+dDYpLfsAKElMnqKTQfKn7obcL4w== + dependencies: + "@vue/babel-helper-vue-jsx-merge-props" "^1.2.1" + "@vue/babel-plugin-transform-vue-jsx" "^1.2.1" + "@vue/babel-sugar-composition-api-inject-h" "^1.2.1" + "@vue/babel-sugar-composition-api-render-instance" "^1.2.4" + "@vue/babel-sugar-functional-vue" "^1.2.2" + "@vue/babel-sugar-inject-h" "^1.2.2" + "@vue/babel-sugar-v-model" "^1.2.3" + "@vue/babel-sugar-v-on" "^1.2.3" + +"@vue/babel-sugar-composition-api-inject-h@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@vue/babel-sugar-composition-api-inject-h/-/babel-sugar-composition-api-inject-h-1.2.1.tgz#05d6e0c432710e37582b2be9a6049b689b6f03eb" + integrity sha512-4B3L5Z2G+7s+9Bwbf+zPIifkFNcKth7fQwekVbnOA3cr3Pq71q71goWr97sk4/yyzH8phfe5ODVzEjX7HU7ItQ== + dependencies: + "@babel/plugin-syntax-jsx" "^7.2.0" + +"@vue/babel-sugar-composition-api-render-instance@^1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@vue/babel-sugar-composition-api-render-instance/-/babel-sugar-composition-api-render-instance-1.2.4.tgz#e4cbc6997c344fac271785ad7a29325c51d68d19" + integrity sha512-joha4PZznQMsxQYXtR3MnTgCASC9u3zt9KfBxIeuI5g2gscpTsSKRDzWQt4aqNIpx6cv8On7/m6zmmovlNsG7Q== + dependencies: + "@babel/plugin-syntax-jsx" "^7.2.0" + +"@vue/babel-sugar-functional-vue@^1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@vue/babel-sugar-functional-vue/-/babel-sugar-functional-vue-1.2.2.tgz#267a9ac8d787c96edbf03ce3f392c49da9bd2658" + integrity sha512-JvbgGn1bjCLByIAU1VOoepHQ1vFsroSA/QkzdiSs657V79q6OwEWLCQtQnEXD/rLTA8rRit4rMOhFpbjRFm82w== + dependencies: + "@babel/plugin-syntax-jsx" "^7.2.0" + +"@vue/babel-sugar-inject-h@^1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@vue/babel-sugar-inject-h/-/babel-sugar-inject-h-1.2.2.tgz#d738d3c893367ec8491dcbb669b000919293e3aa" + integrity sha512-y8vTo00oRkzQTgufeotjCLPAvlhnpSkcHFEp60+LJUwygGcd5Chrpn5480AQp/thrxVm8m2ifAk0LyFel9oCnw== + dependencies: + "@babel/plugin-syntax-jsx" "^7.2.0" + +"@vue/babel-sugar-v-model@^1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@vue/babel-sugar-v-model/-/babel-sugar-v-model-1.2.3.tgz#fa1f29ba51ebf0aa1a6c35fa66d539bc459a18f2" + integrity sha512-A2jxx87mySr/ulAsSSyYE8un6SIH0NWHiLaCWpodPCVOlQVODCaSpiR4+IMsmBr73haG+oeCuSvMOM+ttWUqRQ== + dependencies: + "@babel/plugin-syntax-jsx" "^7.2.0" + "@vue/babel-helper-vue-jsx-merge-props" "^1.2.1" + "@vue/babel-plugin-transform-vue-jsx" "^1.2.1" + camelcase "^5.0.0" + html-tags "^2.0.0" + svg-tags "^1.0.0" + +"@vue/babel-sugar-v-on@^1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@vue/babel-sugar-v-on/-/babel-sugar-v-on-1.2.3.tgz#342367178586a69f392f04bfba32021d02913ada" + integrity sha512-kt12VJdz/37D3N3eglBywV8GStKNUhNrsxChXIV+o0MwVXORYuhDTHJRKPgLJRb/EY3vM2aRFQdxJBp9CLikjw== + dependencies: + "@babel/plugin-syntax-jsx" "^7.2.0" + "@vue/babel-plugin-transform-vue-jsx" "^1.2.1" + camelcase "^5.0.0" + "@vue/test-utils@^1.0.0-beta.26": version "1.0.0-beta.28" resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-1.0.0-beta.28.tgz#767c43413df8cde86128735e58923803e444b9a5"