258 lines
10 KiB
TypeScript
258 lines
10 KiB
TypeScript
export 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;
|
|
}
|
|
|
|
//tslint:disable-next-line:no-empty
|
|
afterClose(_: BBCodeParser, __: HTMLElement, ___: HTMLElement | undefined, ____?: string): void {
|
|
}
|
|
|
|
abstract createElement(parser: BBCodeParser, parent: HTMLElement, param: 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 type CustomElementCreator = (parser: BBCodeParser, parent: HTMLElement, param: string) => HTMLElement | undefined;
|
|
export type CustomCloser = (parser: BBCodeParser, current: HTMLElement, parent: HTMLElement, param: string) => void;
|
|
|
|
export class BBCodeCustomTag extends BBCodeTag {
|
|
constructor(tag: string, private customCreator: CustomElementCreator, private customCloser?: CustomCloser, tagList?: string[]) {
|
|
super(tag, tagList);
|
|
}
|
|
|
|
createElement(parser: BBCodeParser, parent: HTMLElement, param: string): HTMLElement | undefined {
|
|
return this.customCreator(parser, parent, param);
|
|
}
|
|
|
|
afterClose(parser: BBCodeParser, current: HTMLElement, parent: HTMLElement, param: string): void {
|
|
if(this.customCloser !== undefined)
|
|
this.customCloser(parser, current, parent, param);
|
|
}
|
|
}
|
|
|
|
enum BufferType { Raw, Tag }
|
|
|
|
class ParserTag {
|
|
constructor(public tag: string, public param: string, public element: HTMLElement, public parent: HTMLElement | undefined,
|
|
public line: number, public column: number) {
|
|
}
|
|
|
|
appendElement(child: HTMLElement): void {
|
|
this.element.appendChild(child);
|
|
}
|
|
|
|
append(content: string, start: number, end: number): void {
|
|
if(content.length === 0)
|
|
return;
|
|
this.element.appendChild(document.createTextNode(content.substring(start, end)));
|
|
}
|
|
}
|
|
|
|
export class BBCodeParser {
|
|
private _warnings: string[] = [];
|
|
private _tags: {[tag: string]: BBCodeTag | undefined} = {};
|
|
private _line = -1;
|
|
private _column = -1;
|
|
private _currentTag!: ParserTag;
|
|
private _storeWarnings = false;
|
|
|
|
parseEverything(input: string): HTMLElement {
|
|
if(input.length === 0)
|
|
return this.createElement('span');
|
|
this._warnings = [];
|
|
this._line = 1;
|
|
this._column = 1;
|
|
const stack: ParserTag[] = this.parse(input, 0, input.length);
|
|
|
|
for(let i = stack.length - 1; i > 0; i--) {
|
|
this._currentTag = <ParserTag>stack.pop();
|
|
this.warning('Automatically closing tag at end of input.');
|
|
}
|
|
if(process.env.NODE_ENV !== 'production' && this._warnings.length > 0)
|
|
console.log(this._warnings);
|
|
return stack[0].element;
|
|
}
|
|
|
|
createElement<K extends keyof HTMLElementTagNameMap>(tag: K): HTMLElementTagNameMap[K] {
|
|
return document.createElement(tag);
|
|
}
|
|
|
|
addTag(tag: string, impl: BBCodeTag): void {
|
|
this._tags[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, end: number): ParserTag[] {
|
|
const ignoreClosing: {[key: string]: number} = {};
|
|
|
|
function ignoreNextClosingTag(tagName: string): void {
|
|
//tslint:disable-next-line:strict-boolean-expressions
|
|
ignoreClosing[tagName] = (ignoreClosing[tagName] || 0) + 1;
|
|
}
|
|
|
|
const stack: ParserTag[] = [];
|
|
|
|
function stackTop(): ParserTag {
|
|
return stack[stack.length - 1];
|
|
}
|
|
|
|
function quickReset(i: number): void {
|
|
stackTop().append(input, start, i + 1);
|
|
start = i + 1;
|
|
curType = BufferType.Raw;
|
|
}
|
|
|
|
let curType: BufferType = BufferType.Raw;
|
|
// Root tag collects output.
|
|
const rootTag = new ParserTag('<root>', '', this.createElement('span'), undefined, 1, 1);
|
|
stack.push(rootTag);
|
|
this._currentTag = rootTag;
|
|
let paramStart = -1;
|
|
for(let i = start; i < end; ++i) {
|
|
const c = input[i];
|
|
++this._column;
|
|
if(c === '\n') {
|
|
++this._line;
|
|
this._column = 1;
|
|
quickReset(i);
|
|
stackTop().appendElement(this.createElement('br'));
|
|
}
|
|
switch(curType) {
|
|
case BufferType.Raw:
|
|
if(c === '[') {
|
|
stackTop().append(input, start, i);
|
|
start = i;
|
|
curType = BufferType.Tag;
|
|
}
|
|
break;
|
|
case BufferType.Tag:
|
|
if(c === '[') {
|
|
stackTop().append(input, start, i);
|
|
start = i;
|
|
} else if(c === '=' && paramStart === -1)
|
|
paramStart = i;
|
|
else if(c === ']') {
|
|
const paramIndex = paramStart === -1 ? i : paramStart;
|
|
let tagKey = input.substring(start + 1, paramIndex).trim();
|
|
if(tagKey.length === 0) {
|
|
quickReset(i);
|
|
continue;
|
|
}
|
|
let param = '';
|
|
if(paramStart !== -1)
|
|
param = input.substring(paramStart + 1, i).trim();
|
|
paramStart = -1;
|
|
const close = tagKey[0] === '/';
|
|
if(close) tagKey = tagKey.substr(1).trim();
|
|
if(typeof this._tags[tagKey] === 'undefined') {
|
|
quickReset(i);
|
|
continue;
|
|
}
|
|
if(!close) {
|
|
let allowed = true;
|
|
for(let k = stack.length - 1; k > 0; --k) {
|
|
allowed = allowed && this._tags[stack[k].tag]!.isAllowed(tagKey);
|
|
if(!allowed)
|
|
break;
|
|
}
|
|
const tag = this._tags[tagKey]!;
|
|
if(!allowed) {
|
|
ignoreNextClosingTag(tagKey);
|
|
quickReset(i);
|
|
continue;
|
|
}
|
|
const parent = stackTop().element;
|
|
const el: HTMLElement | undefined = tag.createElement(this, parent, param);
|
|
if(el === undefined) {
|
|
quickReset(i);
|
|
continue;
|
|
}
|
|
(<HTMLElement & {bbcodeTag: string}>el).bbcodeTag = tagKey;
|
|
if(param.length > 0) (<HTMLElement & {bbcodeParam: string}>el).bbcodeParam = param;
|
|
if(!this._tags[tagKey]!.noClosingTag)
|
|
stack.push(new ParserTag(tagKey, param, el, parent, this._line, this._column));
|
|
} else if(ignoreClosing[tagKey] > 0) {
|
|
ignoreClosing[tagKey] -= 1;
|
|
stackTop().append(input, start, i + 1);
|
|
} else {
|
|
let closed = false;
|
|
for(let k = stack.length - 1; k >= 0; --k) {
|
|
if(stack[k].tag !== tagKey) continue;
|
|
for(let y = stack.length - 1; y >= k; --y) {
|
|
const closeTag = <ParserTag>stack.pop();
|
|
this._currentTag = closeTag;
|
|
if(y > k)
|
|
this.warning(`Unexpected closing ${tagKey} tag. Needed ${closeTag.tag} tag instead.`);
|
|
this._tags[closeTag.tag]!.afterClose(this, closeTag.element, closeTag.parent, closeTag.param);
|
|
}
|
|
this._currentTag = stackTop();
|
|
closed = true;
|
|
break;
|
|
}
|
|
if(!closed) {
|
|
this.warning(`Found closing ${tagKey} tag that was never opened.`);
|
|
stackTop().append(input, start, i + 1);
|
|
}
|
|
}
|
|
start = i + 1;
|
|
curType = BufferType.Raw;
|
|
}
|
|
}
|
|
}
|
|
if(start < input.length)
|
|
stackTop().append(input, start, input.length);
|
|
|
|
return stack;
|
|
}
|
|
} |