abstract class BBCodeTag { noClosingTag = false; allowedTags: {[tag: string]: boolean | undefined} | undefined; constructor(public tag: string, tagList?: string[]) { if(tagList !== undefined) this.setAllowedTags(tagList); } isAllowed(tag: string): boolean { return this.allowedTags === undefined || this.allowedTags[tag] !== undefined; } setAllowedTags(allowed: string[]): void { this.allowedTags = {}; for(const tag of allowed) this.allowedTags[tag] = true; } abstract createElement(parser: BBCodeParser, parent: HTMLElement, param: string, content: string): HTMLElement | undefined; } export class BBCodeSimpleTag extends BBCodeTag { constructor(tag: string, private elementName: keyof HTMLElementTagNameMap, private classes?: string[], tagList?: string[]) { super(tag, tagList); } createElement(parser: BBCodeParser, parent: HTMLElement, param: string): HTMLElement { if(param.length > 0) parser.warning('Unexpected parameter'); const el = parser.createElement(this.elementName); if(this.classes !== undefined && this.classes.length > 0) el.className = this.classes.join(' '); parent.appendChild(el); /*tslint:disable-next-line:no-unsafe-any*/// false positive return el; } } export class BBCodeCustomTag extends BBCodeTag { constructor(tag: string, private customCreator: (parser: BBCodeParser, parent: HTMLElement, param: string) => HTMLElement | undefined, tagList?: string[]) { super(tag, tagList); } createElement(parser: BBCodeParser, parent: HTMLElement, param: string): HTMLElement | undefined { return this.customCreator(parser, parent, param); } } export class BBCodeTextTag extends BBCodeTag { constructor(tag: string, private customCreator: (parser: BBCodeParser, parent: HTMLElement, param: string, content: string) => HTMLElement | undefined) { super(tag, []); } createElement(parser: BBCodeParser, parent: HTMLElement, param: string, content: string): HTMLElement | undefined { return this.customCreator(parser, parent, param, content); } } export class BBCodeParser { private _warnings: string[] = []; private _tags: {[tag: string]: BBCodeTag | undefined} = {}; private _line = -1; private _column = -1; private _storeWarnings = false; private _currentTag!: {tag: string, line: number, column: number}; parseEverything(input: string): HTMLElement { if(input.length === 0) return this.createElement('span'); this._warnings = []; this._line = 1; this._column = 1; const parent = document.createElement('span'); parent.className = 'bbcode'; this._currentTag = {tag: '', line: 1, column: 1}; this.parse(input, 0, undefined, parent, () => true, 0); //if(process.env.NODE_ENV !== 'production' && this._warnings.length > 0) // console.log(this._warnings); return parent; } createElement(tag: K): HTMLElementTagNameMap[K] { return document.createElement(tag); } addTag(impl: BBCodeTag): void { this._tags[impl.tag] = impl; } getTag(tag: string): BBCodeTag | undefined { return this._tags[tag]; } removeTag(tag: string): void { delete this._tags[tag]; } get warnings(): ReadonlyArray { return this._warnings; } set storeWarnings(store: boolean) { this._storeWarnings = store; if(!store) this._warnings = []; } warning(message: string): void { if(!this._storeWarnings) return; const cur = this._currentTag; const newMessage = `Error on ${this._line}:${this._column} while inside tag [${cur.tag} @ ${cur.line}:${cur.column}]: ${message}`; this._warnings.push(newMessage); } private parse(input: string, start: number, self: BBCodeTag | undefined, parent: HTMLElement | undefined, isAllowed: (tag: string) => boolean, depth: number): number { let currentTag = this._currentTag; const selfAllowed = self !== undefined ? isAllowed(self.tag) : true; if(self !== undefined) { const parentAllowed = isAllowed; isAllowed = (name) => self.isAllowed(name) && parentAllowed(name); currentTag = this._currentTag = {tag: self.tag, line: this._line, column: this._column}; } let tagStart = -1, paramStart = -1, mark = start, isInCollapseParam = false; for(let i = start; i < input.length; ++i) { const c = input[i]; ++this._column; if(c === '\n') { ++this._line; this._column = 1; } if(c === '[' && !isInCollapseParam) { tagStart = i; paramStart = -1; } else if(c === '=' && paramStart === -1) { paramStart = i; const paramIndex = paramStart === -1 ? i : paramStart; let tagKey = input .substring(tagStart + 1, paramIndex) .trim() .toLowerCase(); if (tagKey == "collapse") isInCollapseParam = true; } else if(c === ']') { const paramIndex = paramStart === -1 ? i : paramStart; let tagKey = input.substring(tagStart + 1, paramIndex).trim().toLowerCase(); if(tagKey.length === 0) { tagStart = -1; continue; } const param = paramStart > tagStart ? input.substring(paramStart + 1, i).trim() : ''; const close = tagKey[0] === '/'; if(close) tagKey = tagKey.substr(1).trim(); if(this._tags[tagKey] === undefined) { tagStart = -1; continue; } if (isInCollapseParam) { let fullTagKey = input .substring(tagStart + 1, i + 1) .trim() .toLowerCase(); if (fullTagKey.endsWith("[hr]")) { continue; } } isInCollapseParam = false; if(!close) { const tag = this._tags[tagKey]!; const allowed = isAllowed(tagKey); if(parent !== undefined) { parent.appendChild(document.createTextNode(input.substring(mark, allowed ? tagStart : i + 1))); mark = i + 1; } if(!allowed || parent === undefined || depth > 100) { i = this.parse(input, i + 1, tag, parent, isAllowed, depth + 1); mark = i + 1; continue; } let element: HTMLElement | undefined; if(tag instanceof BBCodeTextTag) { i = this.parse(input, i + 1, tag, undefined, isAllowed, depth + 1); element = tag.createElement(this, parent, param, input.substring(mark, input.lastIndexOf('[', i))); if(element === undefined) parent.appendChild(document.createTextNode(input.substring(tagStart, i + 1))); } else { element = tag.createElement(this, parent, param, ''); if(element === undefined) parent.appendChild(document.createTextNode(input.substring(tagStart, i + 1))); if(!tag.noClosingTag) i = this.parse(input, i + 1, tag, element !== undefined ? element : parent, isAllowed, depth + 1); if(element === undefined) parent.appendChild(document.createTextNode(input.substring(input.lastIndexOf('[', i), i + 1))); } mark = i + 1; this._currentTag = currentTag; if(element === undefined) continue; (element).bbcodeTag = tagKey; if(param.length > 0) (element).bbcodeParam = param; } else if(self !== undefined) { //tslint:disable-line:curly if(self.tag === tagKey) { if(parent !== undefined) parent.appendChild(document.createTextNode(input.substring(mark, selfAllowed ? tagStart : i + 1))); return i; } if(!selfAllowed) return mark - 1; if(isAllowed(tagKey)) this.warning(`Unexpected closing ${tagKey} tag. Needed ${self} tag instead.`); } else if(isAllowed(tagKey)) this.warning(`Found closing ${tagKey} tag that was never opened.`); } } if(mark < input.length && parent !== undefined) { parent.appendChild(document.createTextNode(input.substring(mark))); mark = input.length; } if(self !== undefined) this.warning('Automatically closing tag at end of input.'); return mark; } }