/**
* osu! forum BBCode to safe HTML.
*
* Tournament hosts paste the forum post they already wrote. @bbob/parser
* builds the tree; this renderer knows osu!'s tags ([centre], [box],
* [notice], [imagemap], [profile], [spoiler], …) and emits only a fixed set
* of elements. All text is escaped, links must be http(s), colors and sizes
* are validated, and the only embed is youtube-nocookie. Anything unknown is
* shown as the literal text the host typed.
*/
import { parse } from '@bbob/parser';
type Node =
| string
| {
tag: string;
attrs: Record;
content: Node[] | null;
start?: { to: number };
end?: { from: number };
};
/** The source being rendered, for tags whose content must stay verbatim. */
let source = '';
const BLOCK = new Set([
'centre',
'center',
'box',
'spoilerbox',
'notice',
'list',
'heading',
'quote',
'code',
'imagemap',
'youtube',
'left',
'right'
]);
export function renderBBCode(input: string | null | undefined): string {
if (!input?.trim()) return '';
source = input.replace(/\r\n?/g, '\n');
return renderNodes(parse(source) as Node[]);
}
function renderNodes(nodes: Node[] | null): string {
if (!nodes) return '';
let html = '';
for (let index = 0; index < nodes.length; index++) {
const node = nodes[index];
if (typeof node === 'string') {
// A newline right after a block tag is formatting, not content.
const previous = nodes[index - 1];
if (
node === '\n' &&
previous &&
typeof previous !== 'string' &&
BLOCK.has(previous.tag.toLowerCase())
)
continue;
html += escape(node).replace(/\n/g, ' ');
} else {
html += renderTag(node);
}
}
return html;
}
function renderTag(node: Exclude): string {
const tag = node.tag.toLowerCase();
const value = attr(node);
const inner = () => renderNodes(trimNewlines(node.content));
switch (tag) {
case 'b':
return `${inner()}`;
case 'i':
return `${inner()}`;
case 'u':
return `${inner()}`;
case 's':
case 'strike':
return `${inner()}`;
case 'color': {
const color = safeColor(value);
return color ? `${inner()}` : inner();
}
case 'size':
return `${inner()}`;
case 'centre':
case 'center':
return `
${inner()}
`;
case 'left':
case 'right':
return `
${inner()}
`;
case 'heading':
return `
${inner()}
`;
case 'notice':
return `
${inner()}
`;
case 'spoiler':
return `${inner()}`;
case 'box':
case 'spoilerbox':
return `${escape(value || (tag === 'spoilerbox' ? 'Spoiler' : 'Show'))}