fchat-rising/bbcode/parser.ts

202 lines
8.5 KiB
TypeScript

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 = <HTMLElement>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: '<root>', line: 1, column: 1};
this.parse(input, 0, undefined, parent, () => true);
//if(process.env.NODE_ENV !== 'production' && this._warnings.length > 0)
// console.log(this._warnings);
return parent;
}
createElement<K extends keyof HTMLElementTagNameMap>(tag: K): HTMLElementTagNameMap[K] {
return document.createElement(tag);
}
addTag(impl: BBCodeTag): void {
this._tags[impl.tag] = impl;
}
removeTag(tag: string): void {
delete this._tags[tag];
}
get warnings(): ReadonlyArray<string> {
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): 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;
for(let i = start; i < input.length; ++i) {
const c = input[i];
++this._column;
if(c === '\n') {
++this._line;
this._column = 1;
}
if(c === '[') {
tagStart = i;
paramStart = -1;
} else if(c === '=' && paramStart === -1)
paramStart = i;
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(!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) {
i = this.parse(input, i + 1, tag, parent, isAllowed);
mark = i + 1;
continue;
}
let element: HTMLElement | undefined;
if(tag instanceof BBCodeTextTag) {
i = this.parse(input, i + 1, tag, undefined, isAllowed);
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);
if(element === undefined)
parent.appendChild(document.createTextNode(input.substring(input.lastIndexOf('[', i), i + 1)));
}
mark = i + 1;
this._currentTag = currentTag;
if(element === undefined) continue;
(<HTMLElement & {bbcodeTag: string}>element).bbcodeTag = tagKey;
if(param.length > 0) (<HTMLElement & {bbcodeParam: string}>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;
}
}