'use strict'; var nodeHtmlParser = require('node-html-parser'); /* * Copyright (c) 2022. * Author Peter Placzek (tada5hi) * For the full copyright and license information, * view the LICENSE file that was distributed with this source code. */ exports.QuoteType = void 0; (function(QuoteType) { /** * Always quote the attribute value */ QuoteType["always"] = "always"; /** * Never quote the attributes value */ QuoteType["never"] = "never"; /** * Only quote the attributes value when it contains spaces to equals */ QuoteType["auto"] = "auto"; })(exports.QuoteType || (exports.QuoteType = {})); /* * Copyright (c) 2022. * Author Peter Placzek (tada5hi) * For the full copyright and license information, * view the LICENSE file that was distributed with this source code. */ /** * Convert 10base to 16base. * * @param input */ function toHex(input) { let number = parseInt(`${input}`, 10); if (Number.isNaN(number)) { return '00'; } number = Math.max(0, Math.min(number, 255)).toString(16); return number.length < 2 ? `0${number}` : number; } /** * Convert rgb, rgba to hex code. * * @param input */ function normaliseColor(input) { input = input || '#000'; let match = input.match(/rgb\((\d{1,3}),\s*?(\d{1,3}),\s*?(\d{1,3})\)/i); // rgb(n,n,n); if (match) { return `#${toHex(match[1])}${toHex(match[2])}${toHex(match[3])}`; } match = input.match(/#([0-f])([0-f])([0-f])\s*?$/i); // expand shorthand if (match) { return `#${match[1]}${match[1]}${match[2]}${match[2]}${match[3]}${match[3]}`; } return input; } /* * Copyright (c) 2022. * Author Peter Placzek (tada5hi) * For the full copyright and license information, * view the LICENSE file that was distributed with this source code. */ function stripQuotes(str) { return str ? str.replace(/\\(.)/g, '$1').replace(/^(["'])(.*?)\1$/, '$2') : str; } /* * Copyright (c) 2022-2022. * Author Peter Placzek (tada5hi) * For the full copyright and license information, * view the LICENSE file that was distributed with this source code. */ function extendHandler(handler) { return { ...{ isSelfClosing: false, isInline: true, allowsEmpty: false, excludeClosing: false, skipLastLineBreak: false, strictMatch: false, breakBefore: false, breakStart: false, breakEnd: false, breakAfter: false }, ...handler }; } /* * Copyright (c) 2022. * Author Peter Placzek (tada5hi) * For the full copyright and license information, * view the LICENSE file that was distributed with this source code. */ function lastArrayElement(arr) { if (arr.length) { return arr[arr.length - 1]; } return undefined; } /* * Copyright (c) 2022-2023. * Author Peter Placzek (tada5hi) * For the full copyright and license information, * view the LICENSE file that was distributed with this source code. */ function escapeCharacters(str, noQuotes) { if (!str) { return str; } const replacements = { '&': '&', '<': '<', '>': '>', ' ': '  ', '\r\n': '
', '\r': '
', '\n': '
' }; if (noQuotes !== false) { replacements['"'] = '"'; replacements['\''] = '''; replacements['`'] = '`'; } str = str.replace(/ {2}|\r\n|[&<>\r\n'"`]/g, (match)=>replacements[match] || match); return str; } const VALID_SCHEME_REGEX = /^(https?|s?ftp|mailto|spotify|skype|ssh|teamspeak|tel):|(\/\/)|data:image\/(png|bmp|gif|p?jpe?g);/i; function escapeUriScheme(url) { let path = []; // If there is a : before a / then it has a scheme const hasScheme = /^[^/]*:/i; // Has no scheme or a valid scheme if (!url || !hasScheme.test(url) || VALID_SCHEME_REGEX.test(url)) { return url; } if (typeof window !== 'undefined' && window.location) { const { location } = window; path = location.pathname.split('/'); path.pop(); return `${location.protocol}//${location.host}${path.join('/')}/${url}`; } return url; } /* * Copyright (c) 2021. * Author Peter Placzek (tada5hi) * For the full copyright and license information, * view the LICENSE file that was distributed with this source code. */ // eslint-disable-next-line @typescript-eslint/ban-types function hasOwnProperty(obj, prop) { return Object.prototype.hasOwnProperty.call(obj, prop); } /* * Copyright (c) 2022-2022. * Author Peter Placzek (tada5hi) * For the full copyright and license information, * view the LICENSE file that was distributed with this source code. */ function getObjectPathValue(data, key) { const index = key.indexOf('.'); const currentKey = index === -1 ? key : key.substring(0, index); if (index === -1) { return data[currentKey]; } if (!isObject(data[currentKey])) { return undefined; } const nextKey = key.substring(currentKey.length + 1); return getObjectPathValue(data[currentKey], nextKey); } function isObject(item) { return !!item && typeof item === 'object' && !Array.isArray(item); } function formatString(str, obj) { return str.replace(/\{([^}]+)\}/g, (match, group)=>{ let escape = true; if (group.charAt(0) === '!') { escape = false; group = group.substring(1); } if (group === '0') { escape = false; } if (typeof obj[group] === 'undefined') { return match; } return escape ? escapeCharacters(obj[group], true) : obj[group]; }); } /** * * @param one * @param two */ function isMatch(one, two) { if (!one) { return true; } if (Array.isArray(one)) { if (typeof two !== 'string' && typeof two !== 'number') { return false; } return one.some((item)=>typeof item === 'string' && item.trim() === `${two}`.trim()); } if (!isObject(one) || !isObject(two)) { return false; } const keys = Object.keys(one); for(let i = 0; i < keys.length; i++){ if (!hasOwnProperty(one, keys[i]) || !hasOwnProperty(two, keys[i])) { return false; } if (!one[keys[i]]) { continue; } if (!isMatch(one[keys[i]], two[keys[i]])) { return false; } } return true; } function isHTMLConditionMatch(condition, token) { if (!condition.tag && !condition.attribute) { return false; } if (condition.tag) { if (condition.tag !== token.name.toLowerCase()) { return false; } } if (condition.attribute) { const keys = Object.keys(condition.attribute); for(let i = 0; i < keys.length; i++){ if (!isMatch(condition.attribute, token.attrs)) { return false; } } } return true; } function isHandlerMatch(handler, token) { if (typeof handler.conditions === 'undefined') { return false; } for(let i = 0; i < handler.conditions.length; i++){ if (isHTMLConditionMatch(handler.conditions[i], token)) { return true; } } return false; } function findHandlerForHTMLToken(handlers, token) { if (typeof token === 'undefined') { return undefined; } const items = handlers.get(); const keys = Object.keys(items); for(let i = 0; i < keys.length; i++){ if (isHandlerMatch(items[keys[i]], token)) { return items[keys[i]]; } } return undefined; } /* * Copyright (c) 2022. * Author Peter Placzek (tada5hi) * For the full copyright and license information, * view the LICENSE file that was distributed with this source code. */ exports.TokenType = void 0; (function(TokenType) { TokenType["OPEN"] = "open"; TokenType["CONTENT"] = "content"; TokenType["NEWLINE"] = "newline"; TokenType["CLOSE"] = "close"; })(exports.TokenType || (exports.TokenType = {})); /* * Copyright (c) 2022. * Author Peter Placzek (tada5hi) * For the full copyright and license information, * view the LICENSE file that was distributed with this source code. */ class Token { clone() { return new Token(this.type, this.name, this.value, { ...this.attrs }, [], this.closing ? this.closing.clone() : undefined); } splitAt(splitAt) { let offsetLength; const clone = this.clone(); const offset = this.children.indexOf(splitAt); if (offset > -1) { // Work out how many items are on the right side of the split // to pass to splice() offsetLength = this.children.length - offset; clone.children = this.children.splice(offset, offsetLength); } return clone; } constructor(type, name, value, attrs, children, closing){ this.type = type; this.name = name; this.value = value; this.attrs = attrs || []; this.children = children || []; this.closing = closing || null; } } function tokenizeAttrs(attrs) { let matches = []; /** * ([^\s=]+) Anything that's not a space or equals * = Equals sign = * (?: * (?: * (["']) The opening quote * ( * (?:\\\2|[^\2])*? Anything that isn't the * unescaped opening quote * ) * \2 The opening quote again which * will close the string * ) * | If not a quoted string then match * ( * (?:.(?!\s\S+=))*.? Anything that isn't part of * [space][non-space][=] which * would be a new attribute * ) * ) */ const attrRegex = /([^\s=]+)=(?:(?:(["'])((?:\\\2|[^\2])*?)\2)|((?:.(?!\s\S+=))*.))/g; const ret = {}; // if only one attribute then remove the = from the start and // strip any quotes if (attrs.charAt(0) === '=' && attrs.indexOf('=', 1) < 0) { ret.default = stripQuotes(attrs.substring(1)); } else { if (attrs.charAt(0) === '=') { attrs = `default${attrs}`; } // No need to strip quotes here, the regex will do that. // eslint-disable-next-line no-cond-assign while(matches = attrRegex.exec(attrs)){ ret[matches[1].toLowerCase()] = stripQuotes(matches[3]) || matches[4]; } } return ret; } function tokenizeTag(type, input, handlerManager) { let matches = []; let attrs; let name; // Extract the name and attributes from opening tags and // just the name from closing tags. matches = input.match(/\[([^\]\s=]+)(?:([^\]]+))?\]/); if (type === exports.TokenType.OPEN && matches) { name = matches[1].toLowerCase(); if (matches[2]) { matches[2] = matches[2].trim(); attrs = tokenizeAttrs(matches[2]); } } matches = input.match(/\[\/([^[\]]+)\]/); if (type === exports.TokenType.CLOSE && matches) { name = matches[1].toLowerCase(); } if (type === exports.TokenType.NEWLINE) { name = '#newline'; } // Treat all tokens without a name and // all unknown BBCodes as content if (!name || (type === exports.TokenType.OPEN || type === exports.TokenType.CLOSE) && !handlerManager.get(name)) { type = exports.TokenType.CONTENT; name = '#'; } return new Token(type, name, input, attrs); } function tokenizeBBCode(input, handlerManager) { // The token types in reverse order of precedence const tokenTypes = [ { type: exports.TokenType.CONTENT, regex: /^([^[\r\n]+|\[)/ }, { type: exports.TokenType.NEWLINE, regex: /^(\r\n|\r|\n)/ }, { type: exports.TokenType.OPEN, regex: /^\[[^[\]]+\]/ }, { type: exports.TokenType.CLOSE, regex: /^\[\/[^[\]]+\]/ } ]; let matches = []; let type; let i; const tokens = []; // eslint-disable-next-line no-labels,no-restricted-syntax inputLoop: while(input.length){ i = tokenTypes.length; while(i--){ type = tokenTypes[i].type; // Check if the string matches any of the tokens matches = input.match(tokenTypes[i].regex); if (!matches || !matches[0]) { continue; } // Add the match to the tokens list tokens.push(tokenizeTag(type, matches[0], handlerManager)); // Remove the match from the string input = input.substring(matches[0].length); continue inputLoop; } // If there is anything left in the string which doesn't match // any of the tokens then just assume it's content and add it. if (input.length) { tokens.push(tokenizeTag(exports.TokenType.CONTENT, input, handlerManager)); } input = ''; } return tokens; } function parseStyles(input) { return input.split(';').filter((style)=>style.split(':')[0] && style.split(':')[1]).map((style)=>[ style.split(':')[0].trim().replace(/-./g, (c)=>c.substring(1).toUpperCase()), style.split(':')[1].trim() ]).reduce((styleObj, style)=>({ ...styleObj, [style[0]]: style[1] }), {}); } const selfClosingTags = [ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr' ]; function parseNode(node) { const tokens = []; switch(node.nodeType){ case nodeHtmlParser.NodeType.ELEMENT_NODE: { const element = node; if (element.rawTagName) { const children = []; for(let i = 0; i < element.childNodes.length; i++){ children.push(...parseNode(element.childNodes[i])); } let closingToken; if (selfClosingTags.indexOf(element.tagName.toLowerCase()) === -1) { closingToken = new Token(exports.TokenType.CLOSE, element.tagName.toLowerCase(), ``); } const token = new Token(exports.TokenType.OPEN, element.tagName.toLowerCase(), `<${element.tagName.toLowerCase()}>`, { ...element.attrs, ...element.attrs.style ? { style: parseStyles(element.attrs.style) } : {}, class: Array.from(element.classList.values()) }, children, closingToken); tokens.push(token); } else { for(let i = 0; i < element.childNodes.length; i++){ tokens.push(...parseNode(element.childNodes[i])); } } break; } case nodeHtmlParser.NodeType.TEXT_NODE: { const text = node; tokens.push(new Token(exports.TokenType.CONTENT, '#', text.text)); break; } case nodeHtmlParser.NodeType.COMMENT_NODE: break; } return tokens; } function tokenizeHTML(input) { const htmlElement = nodeHtmlParser.parse(input); return parseNode(htmlElement); } function removeEmptyTokens(tokens, handlerManager) { let token; let bbcode; /** * Checks if all children are whitespace or not * @private */ const isTokenWhiteSpace = (children)=>{ let j = children.length; while(j--){ const { type } = children[j]; if (type === exports.TokenType.OPEN || type === exports.TokenType.CLOSE) { return false; } if (type === exports.TokenType.CONTENT && /\S|\u00A0/.test(children[j].value)) { return false; } } return true; }; let i = tokens.length; while(i--){ // So skip anything that isn't a tag since only tags can be // empty, content can't token = tokens[i]; if (!token || token.type !== exports.TokenType.OPEN) { continue; } bbcode = handlerManager.get(token.name); // Remove any empty children of this tag first so that if they // are all removed this one doesn't think it's not empty. removeEmptyTokens(token.children, handlerManager); if (isTokenWhiteSpace(token.children) && bbcode && !bbcode.isSelfClosing && !bbcode.allowsEmpty) { tokens.splice(i, 1); } } } /* * Copyright (c) 2022. * Author Peter Placzek (tada5hi) * For the full copyright and license information, * view the LICENSE file that was distributed with this source code. */ function hasToken(arr, name, type) { let i = arr.length; while(i--){ if (arr[i].type === type && arr[i].name === name) { return true; } } return false; } function isChildAllowed(context) { const parentBBCode = context.parent ? context.handlers.get(context.parent.name) : {}; if (context.fixInvalidChildren && parentBBCode && parentBBCode.allowedChildren) { return parentBBCode.allowedChildren.indexOf(context.child.name || '#') > -1; } return true; } function parseTokens(context) { let token; let handler; let curTok; let clone; let i; let next; const cloned = []; const output = []; const openTags = []; const currentTag = ()=>lastArrayElement(openTags); const addTag = (token)=>{ const curr = currentTag(); if (curr) { curr.children.push(token); } else { output.push(token); } }; const closesCurrentTag = (name)=>{ const tag = currentTag(); if (!tag) { return false; } const bbcode = context.handlers.get(tag.name); if (!bbcode) { return false; } return bbcode.closedBy && bbcode.closedBy.indexOf(name) > -1; }; // eslint-disable-next-line no-cond-assign while(token = context.items.shift()){ // eslint-disable-next-line prefer-destructuring next = context.items[0]; /* * Fixes any invalid children. * * If it is an element which isn't allowed as a child of it's * parent then it will be converted to content of the parent * element. i.e. * [code]Code [b]only[/b] allows text.[/code] * Will become: * Code [b]only[/b] allows text. * Instead of: * Code only allows text. */ // Ignore tags that can't be children if (!isChildAllowed({ handlers: context.handlers, parent: currentTag(), child: token, fixInvalidChildren: context.fixInvalidChildren })) { const curr = currentTag(); // exclude closing tags of current tag if (token.type !== exports.TokenType.CLOSE || !curr || token.name !== curr.name) { token.name = '#'; token.type = exports.TokenType.CONTENT; } } switch(token.type){ case exports.TokenType.OPEN: // Check it this closes a parent, // e.g. for lists [*]one [*]two if (closesCurrentTag(token.name)) { openTags.pop(); } addTag(token); handler = context.handlers.get(token.name); // If this tag is not self-closing and it has a closing // tag then it is open and has children so add it to the // list of open tags. If has the closedBy property then // it is closed by other tags so include everything as // it's children until one of those tags is reached. if (handler && !handler.isSelfClosing && (handler.closedBy || hasToken(context.items, token.name, exports.TokenType.CLOSE))) { openTags.push(token); } else if (!handler || !handler.isSelfClosing) { token.type = exports.TokenType.CONTENT; } break; case exports.TokenType.CLOSE: { const curr = currentTag(); // check if this closes the current tag, // e.g. [/list] would close an open [*] if (curr && token.name !== curr.name && closesCurrentTag(`/${token.name}`)) { openTags.pop(); } // If this is closing the currently open tag just pop // the close tag off the open tags array if (curr && token.name === curr.name) { curr.closing = token; openTags.pop(); // If this is closing an open tag that is the parent of // the current tag then clone all the tags including the // current one until reaching the parent that is being // closed. Close the parent and then add the clones back // in. } else if (hasToken(openTags, token.name, exports.TokenType.OPEN)) { // Remove the tag from the open tags // eslint-disable-next-line no-cond-assign while(curTok = openTags.pop()){ // If it's the tag that is being closed then // discard it and break the loop. if (curTok.name === token.name) { curTok.closing = token; break; } // Otherwise clone this tag and then add any // previously cloned tags as it's children clone = curTok.clone(); if (cloned.length) { const lastElement = lastArrayElement(cloned); if (lastElement) { clone.children.push(lastElement); } } cloned.push(clone); } // Place block linebreak before cloned tags if (next && next.type === exports.TokenType.NEWLINE) { handler = context.handlers.get(token.name); if (handler && handler.isInline === false) { addTag(next); context.items.shift(); } } // Add the last cloned child to the now current tag // (the parent of the tag which was being closed) const lastElement = lastArrayElement(cloned); if (lastElement) { addTag(lastElement); } // Add all the cloned tags to the open tags list i = cloned.length; while(i--){ openTags.push(cloned[i]); } cloned.length = 0; // This tag is closing nothing so treat it as content } else { token.type = exports.TokenType.CONTENT; addTag(token); } break; } case exports.TokenType.NEWLINE: { const curr = currentTag(); // handle things like // [*]list\nitem\n[*]list1 // where it should come out as // [*]list\nitem[/*]\n[*]list1[/*] // instead of // [*]list\nitem\n[/*][*]list1[/*] if (curr && next && closesCurrentTag((next.type === exports.TokenType.CLOSE ? '/' : '') + next.name)) { // skip if the next tag is the closing tag for // the option tag, i.e. [/*] if (!(next.type === exports.TokenType.CLOSE && next.name === curr.name)) { handler = context.handlers.get(curr.name); if (handler && handler.breakAfter) { openTags.pop(); } else if (handler && handler.isInline === false && handler.breakAfter !== false) { openTags.pop(); } } } addTag(token); break; } default: addTag(token); break; } } return output; } /** * Fixes any invalid nesting. * * If it is a block level element inside 1 or more inline elements * than those inline elements will be split at the point where the * block level is and the block level element placed between the split * parts. i.e. * [inline]A[blocklevel]B[/blocklevel]C[/inline] * Will become: * [inline]A[/inline][blocklevel]B[/blocklevel][inline]C[/inline] * */ function fixNestingTokens(context) { const isInline = (token)=>{ const handler = context.handlers.get(token.name); return !handler || handler.isInline !== false; }; context.parents = context.parents || []; context.rootArr = context.rootArr || context.children; let token; let parent; let parentIndex; let parentParentChildren; let right; // This must check the length each time as it can change when // tokens are moved to fix the nesting. for(let i = 0; i < context.children.length; i++){ // eslint-disable-next-line no-cond-assign if (!(token = context.children[i]) || token.type !== exports.TokenType.OPEN) { continue; } if (context.insideInlineElement && !isInline(token)) { // if this is a blocklevel element inside an inline one then // split the parent at the block level element parent = lastArrayElement(context.parents); if (!parent) { continue; } right = parent.splitAt(token); parentParentChildren = context.parents.length > 1 ? context.parents[context.parents.length - 2].children : context.rootArr; // If parent inline is allowed inside this tag, clone it and // wrap this tags children in it. if (isChildAllowed({ handlers: context.handlers, parent: token, child: parent, fixInvalidChildren: context.fixInvalidChildren })) { const clone = parent.clone(); clone.children = token.children; token.children = [ clone ]; } parentIndex = parentParentChildren.indexOf(parent); if (parentIndex > -1) { // remove the block level token from the right side of // the split inline element right.children.splice(0, 1); // insert the block level token and the right side after // the left side of the inline token parentParentChildren.splice(parentIndex + 1, 0, token, right); // If token is a block and is followed by a newline, // then move the newline along with it to the new parent const next = right.children[0]; if (next && next.type === exports.TokenType.NEWLINE) { if (!isInline(token)) { right.children.splice(0, 1); parentParentChildren.splice(parentIndex + 2, 0, next); } } // return to parents loop as the // children have now increased return; } } context.parents.push(token); fixNestingTokens({ handlers: context.handlers, children: token.children, parents: context.parents, insideInlineElement: context.insideInlineElement || isInline(token), rootArr: context.rootArr, fixInvalidChildren: context.fixInvalidChildren }); context.parents.pop(); } } /* istanbul ignore next */ function normalizeTokenNewLines(context) { const { children, parent, options, onlyRemoveBreakAfter } = context; const childrenLength = children.length; let token; let left; let right; let parentHandler; let handler; let removedBreakEnd; let removedBreakBefore; let remove; if (parent) { parentHandler = context.handlers.get(parent.name); } let i = childrenLength; while(i--){ // eslint-disable-next-line no-cond-assign if (!(token = children[i])) { continue; } if (token.type === exports.TokenType.NEWLINE) { left = i > 0 ? children[i - 1] : undefined; right = i < childrenLength - 1 ? children[i + 1] : undefined; remove = false; // Handle the start and end new lines // e.g. [tag]\n and \n[/tag] if (!onlyRemoveBreakAfter && parentHandler && parentHandler.isSelfClosing !== true) { // First child of parent so must be opening line break // (breakStartBlock, breakStart) e.g. [tag]\n if (!left) { if (parentHandler.isInline === false && options.breakStartBlock && parentHandler.breakStart !== false) { remove = true; } if (parentHandler.breakStart) { remove = true; } // Last child of parent so must be end line break // (breakEndBlock, breakEnd) // e.g. \n[/tag] // remove last line break (breakEndBlock, breakEnd) } else if (!removedBreakEnd && !right) { if (parentHandler.isInline === false && options.breakEndBlock && parentHandler.breakEnd !== false) { remove = true; } if (parentHandler.breakEnd) { remove = true; } removedBreakEnd = remove; } } if (left && left.type === exports.TokenType.OPEN) { handler = context.handlers.get(left.name); if (handler) { if (!onlyRemoveBreakAfter) { if (handler.isInline === false && options.breakAfterBlock && handler.breakAfter !== false) { remove = true; } if (handler.breakAfter) { remove = true; } } else if (handler.isInline === false) { remove = true; } } } if (!onlyRemoveBreakAfter && !removedBreakBefore && right && right.type === exports.TokenType.OPEN) { handler = context.handlers.get(right.name); if (handler) { if (handler.isInline === false && options.breakBeforeBlock && handler.breakBefore !== false) { remove = true; } if (handler.breakBefore) { remove = true; } removedBreakBefore = remove; if (remove) { children.splice(i, 1); continue; } } } if (remove) { children.splice(i, 1); } // reset double removedBreakBefore removal protection. // This is needed for cases like \n\n[\tag] where // only 1 \n should be removed but without this they both // would be. removedBreakBefore = false; } else if (token.type === exports.TokenType.OPEN) { normalizeTokenNewLines({ handlers: context.handlers, children: token.children, parent: token, options, onlyRemoveBreakAfter }); } } } function convertBBCodeToHTML(context) { let bbcode; let content; let html; let ret = ''; const isInline = (handler)=>typeof handler === 'undefined' || (typeof handler.isHtmlInline !== 'undefined' ? handler.isHtmlInline : handler.isInline) !== false; while(context.tokens.length > 0){ const token = context.tokens.shift(); if (!token) { continue; } html = ''; switch(token.type){ case exports.TokenType.OPEN: { const lastChild = token.children[token.children.length - 1] || {}; bbcode = context.handlers.get(token.name); content = convertBBCodeToHTML({ tokens: [ ...token.children ], options: { ...context.options, isRoot: false }, handlers: context.handlers }); if (bbcode && bbcode.html) { const lastChildHandler = context.handlers.get(lastChild.name); // Only add a line break to the end if this is // blocklevel and the last child wasn't block-level if (lastChildHandler && !isInline(bbcode) && isInline(lastChildHandler) && !bbcode.skipLastLineBreak) { // Add placeholder br to end of block level // elements content += '
'; } if (typeof bbcode.html !== 'function') { token.attrs['0'] = content; html = formatString(bbcode.html, token.attrs); } else { html = bbcode.html({ handlers: context.handlers, token, attributes: token.attrs, content, options: context.options }); } } else if (!context.options.lazy) { html = token.value + content + (token.closing ? token.closing.value : ''); } break; } /* istanbul ignore next */ case exports.TokenType.NEWLINE: { if (!context.options.isRoot) { ret += '
'; continue; } ret += '
'; // Normally the div acts as a line-break with by moving // whatever comes after onto a new line. // If this is the last token, add an extra line-break so it // shows as there will be nothing after it. if (!context.tokens.length) { ret += '
'; } ret += '\n'; continue; } default: { html = escapeCharacters(token.value, true); break; } } ret += html; } return ret; } function convertHTMLToBBCode(context) { context.options = context.options || {}; let output = ''; const isInline = (handler)=>typeof handler === 'undefined' || (typeof handler.isHtmlInline !== 'undefined' ? handler.isHtmlInline : handler.isInline) !== false; while(context.tokens.length > 0){ const token = context.tokens.shift(); if (!token) { continue; } switch(token.type){ case exports.TokenType.OPEN: { const lastChild = token.children[token.children.length - 1]; const handler = findHandlerForHTMLToken(context.handlers, token); let content = convertHTMLToBBCode({ tokens: [ ...token.children ], options: { ...context.options, isRoot: false }, handlers: context.handlers }); if (handler && handler.bbcode) { const lastChildHandler = findHandlerForHTMLToken(context.handlers, lastChild); if (lastChildHandler && !isInline(handler) && isInline(lastChildHandler) && !handler.skipLastLineBreak) { // Add placeholder br to end of block level // elements content += '\n'; } if (typeof handler.bbcode !== 'function') { token.attrs['0'] = content; output += formatString(handler.bbcode, token.attrs); } else { output += handler.bbcode({ handlers: context.handlers, token, attributes: token.attrs, content, options: context.options }); } } else if (!context.options.lazy) { output += token.value + content + (token.closing ? token.closing.value : ''); } break; } /* istanbul ignore next */ case exports.TokenType.NEWLINE: { if (!context.options.isRoot) { output += '\n'; continue; } output += '\n'; if (!context.tokens.length) { output += '\n'; } break; } default: { output += escapeCharacters(token.value, true); break; } } } return output; } /* istanbul ignore next */ const HandlerPreset = { h1: { conditions: [ { tag: 'h1' } ], bbcode: '[h1]{0}[/h1]', html: '

{0}

' }, h2: { conditions: [ { tag: 'h2' } ], bbcode: '[h2]{0}[/h2]', html: '

{0}

' }, h3: { conditions: [ { tag: 'h3' } ], bbcode: '[h3]{0}[/h3]', html: '

{0}

' }, h4: { conditions: [ { tag: 'h4' } ], bbcode: '[h4]{0}[/h4]', html: '

{0}

' }, h5: { conditions: [ { tag: 'h5' } ], bbcode: '[h5]{0}[/h5]', html: '
{0}
' }, h6: { conditions: [ { tag: 'h6' } ], bbcode: '[h6]{0}[/h6]', html: '
{0}
' }, // START_COMMAND: Bold b: { conditions: [ { tag: 'b' }, { tag: 'strong' }, { attribute: { style: { fontWeight: [ 'bold', 'bolder', '401', '700', '800', '900' ] } } } ], bbcode: '[b]{0}[/b]', html: '{0}' }, // END_COMMAND // START_COMMAND: Italic i: { conditions: [ { tag: 'i' }, { tag: 'em' }, { attribute: { style: { textDecoration: [ 'italic', 'oblique' ] } } } ], bbcode: '[i]{0}[/i]', html: '{0}' }, // END_COMMAND // START_COMMAND: Underline u: { conditions: [ { tag: 'u' }, { attribute: { style: { textDecoration: [ 'underline' ] } } } ], bbcode: '[u]{0}[/u]', html: '{0}' }, // END_COMMAND // START_COMMAND: Strikethrough s: { conditions: [ { tag: 's' }, { tag: 'strike' }, { attribute: { style: { textDecoration: [ 'line-through' ] } } } ], bbcode: '[s]{0}[/s]', html: '{0}' }, // END_COMMAND // START_COMMAND: Subscript sub: { conditions: [ { tag: 'sub' } ], bbcode: '[sub]{0}[/sub]', html: '{0}' }, // END_COMMAND // START_COMMAND: Superscript sup: { conditions: [ { tag: 'sup' } ], bbcode: '[sup]{0}[/sup]', html: '{0}' }, // END_COMMAND // START_COMMAND: Font font: { conditions: [ { attribute: { style: { fontFamily: null } } } ], quoteType: exports.QuoteType.never, bbcode (context) { const font = getObjectPathValue(context.attributes, 'style.fontFamily'); if (!font) { return ''; } return `[font=${stripQuotes(font)}]${context.content}[/font]`; }, html: '{0}' }, // END_COMMAND // START_COMMAND: Size size: { conditions: [ { attribute: { style: { fontSize: null } } } ], bbcode (context) { let fontSize = getObjectPathValue(context.attributes, 'size'); if (!fontSize) { fontSize = getObjectPathValue(context.attributes, 'style.fontSize'); } return `[size=${fontSize}]${context.content}[/size]`; }, html: '{!0}' }, // END_COMMAND // START_COMMAND: Color color: { conditions: [ { attribute: { style: { color: null } } } ], quoteType: exports.QuoteType.never, bbcode (context) { let color = getObjectPathValue(context.attributes, 'color'); if (!color) { color = getObjectPathValue(context.attributes, 'style.color'); } if (!color) { return ''; } return `[color=${normaliseColor(color)}]${context.content}[/color]`; }, html (context) { if (!context.attributes.default) { return ''; } return `${context.content}`; } }, // END_COMMAND // START_COMMAND: Lists ul: { conditions: [ { tag: 'ul' } ], breakStart: true, isInline: false, skipLastLineBreak: true, bbcode: '[ul]{0}[/ul]', html: '' }, list: { breakStart: true, isInline: false, skipLastLineBreak: true, html: '' }, ol: { conditions: [ { tag: 'ol' } ], breakStart: true, isInline: false, skipLastLineBreak: true, bbcode: '[ol]{0}[/ol]', html: '
    {0}
' }, li: { conditions: [ { tag: 'li' } ], isInline: true, closedBy: [ '/ul', '/ol', '/list', '*', 'li' ], bbcode: '[li]{0}[/li]', html: '
  • {0}
  • ' }, '*': { isInline: false, closedBy: [ '/ul', '/ol', '/list', '*', 'li' ], html: '
  • {0}
  • ' }, // END_COMMAND // START_COMMAND: Table table: { conditions: [ { tag: 'table' } ], isInline: false, isHtmlInline: true, skipLastLineBreak: true, bbcode: '[table]{0}[/table]', html: '{0}
    ' }, tr: { conditions: [ { tag: 'tr' } ], isInline: false, skipLastLineBreak: true, bbcode: '[tr]{0}[/tr]', html: '{0}' }, th: { conditions: [ { tag: 'th' } ], allowsEmpty: true, isInline: false, bbcode: '[th]{0}[/th]', html: '{0}' }, td: { conditions: [ { tag: 'td' } ], allowsEmpty: true, isInline: false, bbcode: '[td]{0}[/td]', html: '{0}' }, // END_COMMAND // START_COMMAND: Emoticons // END_COMMAND // START_COMMAND: Horizontal Rule hr: { conditions: [ { tag: 'hr' } ], allowsEmpty: true, isSelfClosing: true, isInline: false, bbcode: '[hr]{0}', html: '
    ' }, // END_COMMAND // START_COMMAND: Image img: { allowsEmpty: true, conditions: [ { tag: 'img', attribute: { src: null } } ], allowedChildren: [ '#' ], quoteType: exports.QuoteType.never, bbcode (context) { let attribs = ''; const width = getObjectPathValue(context.attributes, 'width') || getObjectPathValue(context.attributes, 'style.width'); const height = getObjectPathValue(context.attributes, 'height') || getObjectPathValue(context.attributes, 'style.height'); // only add width and height if one is specified if (width && height) { attribs = `=${width}x${height}`; } return `[img${attribs}]${getObjectPathValue(context.attributes, 'src')}[/img]`; }, html (context) { let width; let height; let match; let attribs = ''; // handle [img width=340 height=240]url[/img] width = context.attributes.width; height = context.attributes.height; // handle [img=340x240]url[/img] if (context.attributes.default) { match = context.attributes.default.split(/x/i); // eslint-disable-next-line prefer-destructuring width = match[0]; height = match.length === 2 ? match[1] : match[0]; } if (typeof width !== 'undefined') { attribs += ` width="${escapeCharacters(width, true)}"`; } if (typeof height !== 'undefined') { attribs += ` height="${escapeCharacters(height, true)}"`; } return ``; } }, // END_COMMAND // START_COMMAND: URL url: { allowsEmpty: true, conditions: [ { tag: 'a', attribute: { href: null } } ], quoteType: exports.QuoteType.never, bbcode (context) { const url = getObjectPathValue(context.attributes, 'href'); // make sure this link is not an e-mail, // if it is return e-mail BBCode if (url && url.substring(0, 7) === 'mailto:') { return `[email=${url.substring(7)}]${context.content}[/email]`; } return `[url=${url}]${context.content}[/url]`; }, html (context) { context.attributes.default = context.attributes.default ? escapeCharacters(context.attributes.default, true) : context.content; return `${context.content}`; } }, // END_COMMAND // START_COMMAND: E-mail email: { quoteType: exports.QuoteType.never, html (context) { return `${context.content}`; } }, // END_COMMAND // START_COMMAND: Quote quote: { conditions: [ { tag: 'blockquote' } ], isInline: false, quoteType: exports.QuoteType.never, bbcode (context) { const authorAttr = 'data-author'; let author = getObjectPathValue(context.attributes, authorAttr); if (!author) { let index = -1; for(let i = 0; i < context.token.children.length; i++){ if (context.token.children[i].name.toLowerCase() === 'cite') { index = i; } } if (index > -1) { const citeChild = context.token.children[index].children[0]; if (citeChild) { author = citeChild.value.replace(/(^\s+|\s+$)/g, ''); context.token.children.splice(index, 1); context.content = convertHTMLToBBCode({ tokens: context.token.children, options: context.options, handlers: context.handlers }); } } } return `[quote${author ? `=${author}` : ''}]${context.content}[/quote]`; }, html (context) { if (context.attributes.default) { context.content = `${escapeCharacters(context.attributes.default)}${context.content}`; } return `
    ${context.content}
    `; } }, // END_COMMAND // START_COMMAND: Code code: { conditions: [ { tag: 'code' } ], isInline: false, allowedChildren: [ '#', '#newline' ], bbcode: '[code]{0}[/code]', html: '{0}' }, // END_COMMAND // START_COMMAND: Left left: { conditions: [ { attribute: { style: { textAlign: [ 'left', '-webkit-left', '-moz-left', '-khtml-left' ] } } } ], isInline: false, allowsEmpty: true, bbcode: '[left]{0}[/left]', html: '
    {0}
    ' }, // END_COMMAND // START_COMMAND: Centre center: { conditions: [ { attribute: { style: { textAlign: [ 'center', '-webkit-center', '-moz-center', '-khtml-center' ] } } } ], isInline: false, allowsEmpty: true, bbcode: '[center]{0}[/center]', html: '
    {0}
    ' }, // END_COMMAND // START_COMMAND: Right right: { conditions: [ { attribute: { style: { textAlign: [ 'right', '-webkit-right', '-moz-right', '-khtml-right' ] } } } ], isInline: false, allowsEmpty: true, bbcode: '[right]{0}[/right]', html: '
    {0}
    ' }, // END_COMMAND // START_COMMAND: Justify justify: { conditions: [ { attribute: { style: { textAlign: [ 'justify', '-webkit-justify', '-moz-justify', '-khtml-justify' ] } } } ], isInline: false, allowsEmpty: true, bbcode: '[justify]{0}[/justify]', html: '
    {0}
    ' }, // END_COMMAND // START_COMMAND: YouTube youtube: { allowsEmpty: true, conditions: [ { tag: 'iframe', attribute: { 'data-youtube-id': null } } ], bbcode (context) { const value = getObjectPathValue(context.attributes, 'data-youtube-id'); return value ? `[youtube]${value}[/youtube]` : ''; }, html: '' }, // END_COMMAND // START_COMMAND: Rtl rtl: { conditions: [ { attribute: { style: { direction: [ 'rtl' ] } } } ], isInline: false, bbcode: '[rtl]{0}[/rtl]', html: '
    {0}
    ' }, // END_COMMAND // START_COMMAND: Ltr ltr: { conditions: [ { attribute: { style: { direction: [ 'ltr' ] } } } ], isInline: false, bbcode: '[ltr]{0}[/ltr]', html: '
    {0}
    ' } }; class Handlers { init() { const keys = Object.keys(HandlerPreset); for(let i = 0; i < keys.length; i++){ this.items[keys[i]] = extendHandler(HandlerPreset[keys[i]]); } } get(id) { if (typeof id === 'string') { return this.items[id]; } return this.items; } set(id, handler) { if (isObject(id)) { const keys = Object.keys(id); for(let i = 0; i < keys.length; i++){ this.set(keys[i], id[keys[i]]); } return; } if (typeof handler !== 'undefined') { this.items[id] = extendHandler(handler); } } unset(id) { const keys = Array.isArray(id) ? id : [ id ]; for(let i = 0; i < keys.length; i++){ delete this.items[keys[i]]; } } constructor(items){ this.items = {}; this.init(); if (items) { this.set(items); } } } const ParserDefaultOptions = { /** * Add a set of handlers to the already predefined ones. * * @type {Object} */ handlers: {}, /** * If to add a new line before block level elements * * @type {Boolean} */ breakBeforeBlock: false, /** * If to add a new line after the start of block level elements * * @type {Boolean} */ breakStartBlock: false, /** * If to add a new line before the end of block level elements * * @type {Boolean} */ breakEndBlock: false, /** * If to add a new line after block level elements * * @type {Boolean} */ breakAfterBlock: true, /** * If to remove empty tags * * @type {Boolean} */ removeEmptyTags: true, /** * If to fix invalid nesting, * i.e. block level elements inside inline elements. * * @type {Boolean} */ fixInvalidNesting: true, /** * If to fix invalid children. * i.e. A tag which is inside a parent that doesn't * allow that type of tag. * * @type {Boolean} */ fixInvalidChildren: true, /** * Attribute quote type */ quoteType: exports.QuoteType.auto, /** * Strict handler match. * Otherwise, library will attempt to construct html or bbcode without handler. */ lazyTransformation: true }; function quote(str, quoteType, name) { const needsQuotes = /\s|=/.test(str); if (typeof quoteType === 'function') { return quoteType(str, name); } if (quoteType === exports.QuoteType.never || quoteType === exports.QuoteType.auto && !needsQuotes) { return str; } return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; } function cleanupBBCode(context) { let bbcode; let isBlock; let isSelfClosing; let breakBefore; let breakStart; let breakEnd; let breakAfter; let quoteType; let ret = ''; while(context.tokens.length > 0){ const token = context.tokens.shift(); // eslint-disable-next-line no-cond-assign if (!token) { continue; } bbcode = context.handlers.get(token.name); if (bbcode) { isBlock = !(typeof bbcode.isHtmlInline !== 'undefined' ? bbcode.isHtmlInline : bbcode.isInline); isSelfClosing = !!bbcode.isSelfClosing; breakBefore = isBlock && context.options.breakBeforeBlock && bbcode.breakBefore !== false || !!bbcode.breakBefore; breakStart = isBlock && !isSelfClosing && context.options.breakStartBlock && bbcode.breakStart !== false || !!bbcode.breakStart; breakEnd = isBlock && context.options.breakEndBlock && bbcode.breakEnd !== false || !!bbcode.breakEnd; breakAfter = isBlock && context.options.breakAfterBlock && bbcode.breakAfter !== false || !!bbcode.breakAfter; } else { isBlock = false; isSelfClosing = false; breakBefore = false; breakStart = false; breakEnd = false; breakAfter = false; } quoteType = bbcode?.quoteType || context.options.quoteType || exports.QuoteType.auto; if (!bbcode && token.type === exports.TokenType.OPEN) { ret += token.value; if (token.children) { ret += cleanupBBCode({ tokens: token.children, options: context.options, handlers: context.handlers }); } if (token.closing) { ret += token.closing.value; } } else if (token.type === exports.TokenType.OPEN) { if (breakBefore) { ret += '\n'; } // Convert the tag and it's attributes to BBCode ret += `[${token.name}`; if (token.attrs) { if (token.attrs.default) { ret += `=${quote(token.attrs.default, quoteType, 'default')}`; delete token.attrs.default; } const keys = Object.keys(token.attrs); for(let i = 0; i < keys.length; i++){ if (Object.prototype.hasOwnProperty.call(token.attrs, keys[i])) { ret += ` ${keys[i]}=${quote(token.attrs[keys[i]], quoteType, keys[i])}`; } } } ret += ']'; if (breakStart) { ret += '\n'; } // Convert the tags children to BBCode if (token.children) { ret += cleanupBBCode({ tokens: token.children, options: context.options, handlers: context.handlers }); } // add closing tag if not self-closing if (!isSelfClosing && bbcode && !bbcode.excludeClosing) { if (breakEnd) { ret += '\n'; } ret += `[/${token.name}]`; } if (breakAfter) { ret += '\n'; } // preserve whatever was recognized as the // closing tag if it is a self-closing tag if (token.closing && isSelfClosing) { ret += token.closing.value; } } else { ret += token.value; } } return ret; } class Parser { setHandler(id, handler) { if (typeof id === 'string' && typeof handler !== 'undefined') { this.handlers.set(id, handler); } if (isObject(id)) { this.handlers.set(id); } } unsetHandler(id) { this.handlers.unset(id); } // -------------------------------------------------- /** * Tokenize bbcode input string. * * @param input * @param preserveNewLines */ parseBBCode(input, preserveNewLines) { const tokens = parseTokens({ handlers: this.handlers, items: tokenizeBBCode(input, this.handlers), fixInvalidChildren: this.options.fixInvalidChildren }); if (this.options.fixInvalidNesting) { fixNestingTokens({ handlers: this.handlers, children: tokens, fixInvalidChildren: this.options.fixInvalidChildren }); } normalizeTokenNewLines({ handlers: this.handlers, children: tokens, parent: undefined, options: this.options, onlyRemoveBreakAfter: preserveNewLines }); if (this.options.removeEmptyTags) { removeEmptyTokens(tokens, this.handlers); } return tokens; } /** * Tokenize html input string. * * @param input */ parseHTML(input) { return tokenizeHTML(input); } // ---------------------------------------------------------- /** * Convert a bbcode string to a html string. * * @param input * @param preserveNewLines */ toHTML(input, preserveNewLines) { return convertBBCodeToHTML({ tokens: this.parseBBCode(input, preserveNewLines), options: { isRoot: true, lazy: this.options.lazyTransformation }, handlers: this.handlers }); } /** * Alias for toHTML. * * @alias toHTML * @param input * @param preserveNewLines */ fromBBCode(input, preserveNewLines) { return this.toHTML(input, preserveNewLines); } // ---------------------------------------------------------- /** * Clean up bbcode ( remove unnecessary ident, ... ) * * @param input * @param preserveNewLines */ cleanupBBCode(input, preserveNewLines) { return cleanupBBCode({ tokens: this.parseBBCode(input, preserveNewLines), options: this.options, handlers: this.handlers }); } // ---------------------------------------------------------- /** * Convert a html string to a bbcode string. * * @param input * @param _preserveNewLines */ toBBCode(input, _preserveNewLines) { return convertHTMLToBBCode({ tokens: this.parseHTML(input), options: { isRoot: true, lazy: this.options.lazyTransformation }, handlers: this.handlers }); } /** * Alias for toBBCode. * * @alias toBBCode * @param input */ fromHTML(input) { return this.toBBCode(input); } // -------------------------------------------------- constructor(options){ this.options = { ...ParserDefaultOptions, ...options || {} }; this.handlers = new Handlers(this.options.handlers); } } exports.HandlerPreset = HandlerPreset; exports.Handlers = Handlers; exports.Parser = Parser; exports.ParserDefaultOptions = ParserDefaultOptions; exports.Token = Token; exports.convertBBCodeToHTML = convertBBCodeToHTML; exports.convertHTMLToBBCode = convertHTMLToBBCode; exports.fixNestingTokens = fixNestingTokens; exports.hasToken = hasToken; exports.isChildAllowed = isChildAllowed; exports.normalizeTokenNewLines = normalizeTokenNewLines; exports.parseTokens = parseTokens; exports.removeEmptyTokens = removeEmptyTokens; exports.tokenizeAttrs = tokenizeAttrs; exports.tokenizeBBCode = tokenizeBBCode; exports.tokenizeHTML = tokenizeHTML; exports.tokenizeTag = tokenizeTag; //# sourceMappingURL=index.cjs.map