import { Dict } from "src/util"; const VERSION = "0.4.0"; export interface BBCodeConfig { showQuotePrefix?: boolean; classPrefix?: string; mentionPrefix?: string; } // default options const defaults: BBCodeConfig = { showQuotePrefix: true, classPrefix: "bbcode_", mentionPrefix: "@" }; export const version = VERSION; // copied from here: // http://blog.mattheworiordan.com/post/13174566389/url-regular-expression-for-links-with-or-without-the had to make an // update to allow / in the query string, since some sites will have a / there made another update to support colons in // the query string made another update to disallow an ending dot(.) const URL_PATTERN = new RegExp("(" // overall match + "(" // brackets covering match for protocol (optional) and domain + "([A-Za-z]{3,9}:(?:\\/\\/)?)" // allow something@ for email addresses + "(?:[\\-;:&=\\+\\$,\\w]+@)?[A-Za-z0-9\\.\\-]+[A-Za-z0-9\\-]" // anything looking at all like a domain, non-unicode domains + "|" // or instead of above + "(?:www\\.|[\\-;:&=\\+\\$,\\w]+@)" // starting with something@ or www. + "[A-Za-z0-9\\.\\-]+[A-Za-z0-9\\-]" // anything looking at all like a domain + ")" // end protocol/domain + "(" // brackets covering match for path, query string and anchor + "(?:\\/[\\+~%\\/\\.\\w\\-_]*)?" // allow optional /path + "\\??(?:[\\-\\+=&;%@\\.\\w_\\/:]*)" // allow optional query string starting with ? + "#?(?:[\\.\\!\\/\\\\\\w]*)" // allow optional anchor #anchor + ")?" // make URL suffix optional + ")"); function doReplace(content: string, matches: Replacement[], options: BBCodeConfig) { let i, obj, regex, hasMatch, tmp; // match/replace until we don't change the input anymore do { hasMatch = false; for (i = 0; i < matches.length; ++i) { obj = matches[i]; regex = new RegExp(obj.e, "gi"); tmp = content.replace(regex, obj.func.bind(undefined, options)); if (tmp !== content) { content = tmp; hasMatch = true; } } } while (hasMatch); return content; } function listItemReplace(options: BBCodeConfig, fullMatch: string, tag: string, value: string) { return "
  • " + doReplace(value.trim(), BBCODE_PATTERN, options) + "
  • "; } export function extractQuotedText(value: string, parts?: string[]): (string | string[] | undefined)[] { const quotes = ["\"", "'"]; let i, quote, nextPart; for (i = 0; i < quotes.length; ++i) { quote = quotes[i]; if (value && value[0] === quote) { value = value.slice(1); if (value[value.length - 1] !== quote) { while (parts && parts.length) { nextPart = parts.shift(); if (!nextPart) continue; value += " " + nextPart; if (nextPart[nextPart.length - 1] === quote) { break; } } } value = value.replace(new RegExp("[" + quote + "]+$"), ""); break; } } return [value, parts]; } export function parseParams(tagName: string, params?: string): Dict { const paramMap: Dict = {}; if (!params) { return paramMap; } // first, collapse spaces next to equals params = params.replace(/\s*[=]\s*/g, "="); let parts = params.split(/\s+/); while (parts.length) { const part = parts.shift() ?? ""; // check if the param itself is a valid url if (!URL_PATTERN.exec(part)) { const index = part.indexOf("="); if (index > 0) { const rv = extractQuotedText(part.slice(index + 1), parts); paramMap[part.slice(0, index).toLowerCase()] = rv[0] as string; parts = rv[1] as string[]; } else { const rv = extractQuotedText(part, parts); paramMap[tagName] = rv[0] as string; parts = rv[1] as string[]; } } else { const rv = extractQuotedText(part, parts); paramMap[tagName] = rv[0] as string; parts = rv[1] as string[]; } } return paramMap; } const BBCODE_PATTERN = [{ e: "\\[(\\w+)(?:[= ]([^\\]]+))?]((?:.|[\r\n])*?)\\[/\\1]", func: tagReplace }]; function tagReplace(options: BBCodeConfig, fullMatch: string, tag: string, params: string | undefined, value: string) { let val: string; tag = tag.toLowerCase(); const paramsObj = parseParams(tag, params || undefined); let inlineValue = paramsObj[tag]; switch (tag) { case "attach": return ""; case "spoiler": return ""; case "center": return doReplace(value, BBCODE_PATTERN, options); case "size": return doReplace(value, BBCODE_PATTERN, options); case "quote": val = "
    " + (inlineValue ? inlineValue + " wrote:" : (options.showQuotePrefix ? "Quote:" : "")) + "
    " + value + "
    "; case "url": return "" + value + ""; case "email": return "" + value + ""; case "anchor": return "" + value + ""; case "b": return "" + value + ""; case "i": return "" + value + ""; case "u": return "" + value + ""; case "s": return "" + value + ""; case "indent": return "
    " + value + "
    "; case "list": { tag = "ul"; let className = options.classPrefix + "list"; if (inlineValue && /[1Aa]/.test(inlineValue)) { tag = "ol"; if (/1/.test(inlineValue)) { className += "_numeric"; } else if (/A/.test(inlineValue)) { className += "_alpha"; } else if (/a/.test(inlineValue)) { className += "_alpha_lower"; } } val = "<" + tag + " class=\"" + className + "\">"; // parse the value val += doReplace(value, [{ e: "\\[([*])\\]([^\r\n]+)", func: listItemReplace }], options); return val + ""; } case "code": case "php": case "java": case "javascript": case "cpp": case "ruby": case "python": return "
    " + value + "
    "; case "highlight": return "" + value + ""; case "html": return value; case "mention": val = "" + (options.mentionPrefix || "") + value + ""; case "span": case "h1": case "h2": case "h3": case "h4": case "h5": case "h6": return "<" + tag + ">" + value + ""; case "youtube": return ""; case "gvideo": return ""; case "google": return "" + value + ""; case "wikipedia": return "" + value + ""; case "img": { let dims = new RegExp("^(\\d+)x(\\d+)$").exec(inlineValue || ""); if (!dims || (dims.length !== 3)) { dims = new RegExp("^width=(\\d+)\\s+height=(\\d+)$").exec(inlineValue || ""); } if (dims && dims.length === 3) { params = undefined; } val = ""; } } // return the original return fullMatch; } interface Replacement { e: string; func: (options: BBCodeConfig, fullMatch: string, tag: string, params: string, value: string) => string; } /** * Renders the content as html * @param content the given content to render * @param options optional object with control parameters * @returns rendered html */ export function render(content: string, options?: BBCodeConfig): string { options = options || {}; if (!options.classPrefix) options.classPrefix = defaults.classPrefix; if (!options.mentionPrefix) options.mentionPrefix = defaults.mentionPrefix; if (!options.showQuotePrefix) options.showQuotePrefix = defaults.showQuotePrefix; // for now, only one rule return doReplace(content, BBCODE_PATTERN, options); }