bbcode-js.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. import { Dict } from "src/util";
  2. const VERSION = "0.4.0";
  3. export interface BBCodeConfig {
  4. showQuotePrefix?: boolean;
  5. classPrefix?: string;
  6. mentionPrefix?: string;
  7. }
  8. // default options
  9. const defaults: BBCodeConfig = {
  10. showQuotePrefix: true,
  11. classPrefix: "bbcode_",
  12. mentionPrefix: "@"
  13. };
  14. export const version = VERSION;
  15. // copied from here:
  16. // http://blog.mattheworiordan.com/post/13174566389/url-regular-expression-for-links-with-or-without-the had to make an
  17. // update to allow / in the query string, since some sites will have a / there made another update to support colons in
  18. // the query string made another update to disallow an ending dot(.)
  19. const URL_PATTERN = new RegExp("(" // overall match
  20. + "(" // brackets covering match for protocol (optional) and domain
  21. + "([A-Za-z]{3,9}:(?:\\/\\/)?)" // allow something@ for email addresses
  22. + "(?:[\\-;:&=\\+\\$,\\w]+@)?[A-Za-z0-9\\.\\-]+[A-Za-z0-9\\-]"
  23. // anything looking at all like a domain, non-unicode domains
  24. + "|" // or instead of above
  25. + "(?:www\\.|[\\-;:&=\\+\\$,\\w]+@)" // starting with something@ or www.
  26. + "[A-Za-z0-9\\.\\-]+[A-Za-z0-9\\-]" // anything looking at all like a domain
  27. + ")" // end protocol/domain
  28. + "(" // brackets covering match for path, query string and anchor
  29. + "(?:\\/[\\+~%\\/\\.\\w\\-_]*)?" // allow optional /path
  30. + "\\??(?:[\\-\\+=&;%@\\.\\w_\\/:]*)" // allow optional query string starting with ?
  31. + "#?(?:[\\.\\!\\/\\\\\\w]*)" // allow optional anchor #anchor
  32. + ")?" // make URL suffix optional
  33. + ")");
  34. function doReplace(content: string, matches: Replacement[], options: BBCodeConfig) {
  35. let i, obj, regex, hasMatch, tmp;
  36. // match/replace until we don't change the input anymore
  37. do {
  38. hasMatch = false;
  39. for (i = 0; i < matches.length; ++i) {
  40. obj = matches[i];
  41. regex = new RegExp(obj.e, "gi");
  42. tmp = content.replace(regex, obj.func.bind(undefined, options));
  43. if (tmp !== content) {
  44. content = tmp;
  45. hasMatch = true;
  46. }
  47. }
  48. } while (hasMatch);
  49. return content;
  50. }
  51. function listItemReplace(options: BBCodeConfig, fullMatch: string, tag: string, value: string) {
  52. return "<li>" + doReplace(value.trim(), BBCODE_PATTERN, options) + "</li>";
  53. }
  54. export function extractQuotedText(value: string, parts?: string[]): (string | string[] | undefined)[] {
  55. const quotes = ["\"", "'"];
  56. let i, quote, nextPart;
  57. for (i = 0; i < quotes.length; ++i) {
  58. quote = quotes[i];
  59. if (value && value[0] === quote) {
  60. value = value.slice(1);
  61. if (value[value.length - 1] !== quote) {
  62. while (parts && parts.length) {
  63. nextPart = parts.shift();
  64. if (!nextPart)
  65. continue;
  66. value += " " + nextPart;
  67. if (nextPart[nextPart.length - 1] === quote) {
  68. break;
  69. }
  70. }
  71. }
  72. value = value.replace(new RegExp("[" + quote + "]+$"), "");
  73. break;
  74. }
  75. }
  76. return [value, parts];
  77. }
  78. export function parseParams(tagName: string, params?: string): Dict<string> {
  79. const paramMap: Dict<string> = {};
  80. if (!params) {
  81. return paramMap;
  82. }
  83. // first, collapse spaces next to equals
  84. params = params.replace(/\s*[=]\s*/g, "=");
  85. let parts = params.split(/\s+/);
  86. while (parts.length) {
  87. const part = parts.shift() ?? "";
  88. // check if the param itself is a valid url
  89. if (!URL_PATTERN.exec(part)) {
  90. const index = part.indexOf("=");
  91. if (index > 0) {
  92. const rv = extractQuotedText(part.slice(index + 1), parts);
  93. paramMap[part.slice(0, index).toLowerCase()] = rv[0] as string;
  94. parts = rv[1] as string[];
  95. }
  96. else {
  97. const rv = extractQuotedText(part, parts);
  98. paramMap[tagName] = rv[0] as string;
  99. parts = rv[1] as string[];
  100. }
  101. } else {
  102. const rv = extractQuotedText(part, parts);
  103. paramMap[tagName] = rv[0] as string;
  104. parts = rv[1] as string[];
  105. }
  106. }
  107. return paramMap;
  108. }
  109. const BBCODE_PATTERN = [{ e: "\\[(\\w+)(?:[= ]([^\\]]+))?]((?:.|[\r\n])*?)\\[/\\1]", func: tagReplace }];
  110. function tagReplace(options: BBCodeConfig, fullMatch: string, tag: string, params: string | undefined, value: string) {
  111. let val: string;
  112. tag = tag.toLowerCase();
  113. const paramsObj = parseParams(tag, params || undefined);
  114. let inlineValue = paramsObj[tag];
  115. switch (tag) {
  116. case "attach":
  117. return "";
  118. case "spoiler":
  119. return "";
  120. case "center":
  121. return doReplace(value, BBCODE_PATTERN, options);
  122. case "size":
  123. return doReplace(value, BBCODE_PATTERN, options);
  124. case "quote":
  125. val = "<div class=\"" + options.classPrefix + "quote\"";
  126. for (const i in paramsObj) {
  127. const tmp = paramsObj[i];
  128. if (!inlineValue && (i === "author" || i === "name")) {
  129. inlineValue = tmp;
  130. } else if (i !== tag) {
  131. val += " data-" + i + "=\"" + tmp + "\"";
  132. }
  133. }
  134. return val + ">" + (inlineValue ? inlineValue + " wrote:" : (options.showQuotePrefix ? "Quote:" : "")) + "<blockquote>" + value + "</blockquote></div>";
  135. case "url":
  136. return "<a class=\"" + options.classPrefix + "link\" target=\"_blank\" href=\"" + (inlineValue || value) + "\">" + value + "</a>";
  137. case "email":
  138. return "<a class=\"" + options.classPrefix + "link\" target=\"_blank\" href=\"mailto:" + (inlineValue || value) + "\">" + value + "</a>";
  139. case "anchor":
  140. return "<a name=\"" + (inlineValue || paramsObj.a || value) + "\">" + value + "</a>";
  141. case "b":
  142. return "<strong>" + value + "</strong>";
  143. case "i":
  144. return "<em>" + value + "</em>";
  145. case "u":
  146. return "<span style=\"text-decoration:underline\">" + value + "</span>";
  147. case "s":
  148. return "<span style=\"text-decoration:line-through\">" + value + "</span>";
  149. case "indent":
  150. return "<blockquote>" + value + "</blockquote>";
  151. case "list": {
  152. tag = "ul";
  153. let className = options.classPrefix + "list";
  154. if (inlineValue && /[1Aa]/.test(inlineValue)) {
  155. tag = "ol";
  156. if (/1/.test(inlineValue)) {
  157. className += "_numeric";
  158. }
  159. else if (/A/.test(inlineValue)) {
  160. className += "_alpha";
  161. }
  162. else if (/a/.test(inlineValue)) {
  163. className += "_alpha_lower";
  164. }
  165. }
  166. val = "<" + tag + " class=\"" + className + "\">";
  167. // parse the value
  168. val += doReplace(value, [{ e: "\\[([*])\\]([^\r\n]+)", func: listItemReplace }], options);
  169. return val + "</" + tag + ">";
  170. }
  171. case "code":
  172. case "php":
  173. case "java":
  174. case "javascript":
  175. case "cpp":
  176. case "ruby":
  177. case "python":
  178. return "<pre class=\"" + options.classPrefix + (tag === "code" ? "" : "code_") + tag + "\">" + value + "</pre>";
  179. case "highlight":
  180. return "<span class=\"" + options.classPrefix + tag + "\">" + value + "</span>";
  181. case "html":
  182. return value;
  183. case "mention":
  184. val = "<span class=\"" + options.classPrefix + "mention\"";
  185. if (inlineValue) {
  186. val += " data-mention-id=\"" + inlineValue + "\"";
  187. }
  188. return val + ">" + (options.mentionPrefix || "") + value + "</span>";
  189. case "span":
  190. case "h1":
  191. case "h2":
  192. case "h3":
  193. case "h4":
  194. case "h5":
  195. case "h6":
  196. return "<" + tag + ">" + value + "</" + tag + ">";
  197. case "youtube":
  198. return "<object class=\"" + options.classPrefix + "video\" width=\"425\" height=\"350\"><param name=\"movie\" value=\"http://www.youtube.com/v/" + value + "\"></param><embed src=\"http://www.youtube.com/v/" + value + "\" type=\"application/x-shockwave-flash\" width=\"425\" height=\"350\"></embed></object>";
  199. case "gvideo":
  200. return "<embed class=\"" + options.classPrefix + "video\" style=\"width:400px; height:325px;\" id=\"VideoPlayback\" type=\"application/x-shockwave-flash\" src=\"http://video.google.com/googleplayer.swf?docId=" + value + "&amp;hl=en\">";
  201. case "google":
  202. return "<a class=\"" + options.classPrefix + "link\" target=\"_blank\" href=\"http://www.google.com/search?q=" + (inlineValue || value) + "\">" + value + "</a>";
  203. case "wikipedia":
  204. return "<a class=\"" + options.classPrefix + "link\" target=\"_blank\" href=\"http://www.wikipedia.org/wiki/" + (inlineValue || value) + "\">" + value + "</a>";
  205. case "img": {
  206. let dims = new RegExp("^(\\d+)x(\\d+)$").exec(inlineValue || "");
  207. if (!dims || (dims.length !== 3)) {
  208. dims = new RegExp("^width=(\\d+)\\s+height=(\\d+)$").exec(inlineValue || "");
  209. }
  210. if (dims && dims.length === 3) {
  211. params = undefined;
  212. }
  213. val = "<img class=\"" + options.classPrefix + "image\" src=\"" + value + "\"";
  214. if (dims && dims.length === 3) {
  215. val += " width=\"" + dims[1] + "\" height=\"" + dims[2] + "\"";
  216. } else {
  217. for (let i in paramsObj) {
  218. const tmp = paramsObj[i];
  219. if (i === "img") {
  220. i = "alt";
  221. }
  222. val += " " + i + "=\"" + tmp + "\"";
  223. }
  224. }
  225. return val + "/>";
  226. }
  227. }
  228. // return the original
  229. return fullMatch;
  230. }
  231. interface Replacement {
  232. e: string;
  233. func: (options: BBCodeConfig, fullMatch: string, tag: string, params: string, value: string) => string;
  234. }
  235. /**
  236. * Renders the content as html
  237. * @param content the given content to render
  238. * @param options optional object with control parameters
  239. * @returns rendered html
  240. */
  241. export function render(content: string, options?: BBCodeConfig): string {
  242. options = options || {};
  243. if (!options.classPrefix)
  244. options.classPrefix = defaults.classPrefix;
  245. if (!options.mentionPrefix)
  246. options.mentionPrefix = defaults.mentionPrefix;
  247. if (!options.showQuotePrefix)
  248. options.showQuotePrefix = defaults.showQuotePrefix;
  249. // for now, only one rule
  250. return doReplace(content, BBCODE_PATTERN, options);
  251. }