Files
esports/src/lib/bbcode.ts

258 lines
8.3 KiB
TypeScript

/**
* 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<string, string>;
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, '<br>');
} else {
html += renderTag(node);
}
}
return html;
}
function renderTag(node: Exclude<Node, string>): string {
const tag = node.tag.toLowerCase();
const value = attr(node);
const inner = () => renderNodes(trimNewlines(node.content));
switch (tag) {
case 'b':
return `<strong>${inner()}</strong>`;
case 'i':
return `<em>${inner()}</em>`;
case 'u':
return `<u>${inner()}</u>`;
case 's':
case 'strike':
return `<s>${inner()}</s>`;
case 'color': {
const color = safeColor(value);
return color ? `<span style="color:${color}">${inner()}</span>` : inner();
}
case 'size':
return `<span class="bb-size-${sizeClass(value)}">${inner()}</span>`;
case 'centre':
case 'center':
return `<div class="bb-centre">${inner()}</div>`;
case 'left':
case 'right':
return `<div style="text-align:${tag}">${inner()}</div>`;
case 'heading':
return `<h3 class="bb-heading">${inner()}</h3>`;
case 'notice':
return `<div class="bb-notice">${inner()}</div>`;
case 'spoiler':
return `<span class="bb-spoiler">${inner()}</span>`;
case 'box':
case 'spoilerbox':
return `<details class="bb-box"><summary>${escape(value || (tag === 'spoilerbox' ? 'Spoiler' : 'Show'))}</summary><div class="bb-box-body">${inner()}</div></details>`;
case 'quote':
return `<blockquote class="bb-quote">${value ? `<cite>${escape(value)} wrote:</cite>` : ''}${inner()}</blockquote>`;
case 'code':
return `<pre class="bb-code"><code>${escape(verbatim(node).replace(/^\n|\n$/g, ''))}</code></pre>`;
case 'c':
return `<code class="bb-inline-code">${escape(verbatim(node))}</code>`;
case 'url': {
const href = safeUrl(value || text(node.content));
return href
? `<a href="${escape(href)}" target="_blank" rel="nofollow noopener noreferrer">${inner()}</a>`
: inner();
}
case 'email': {
const address = (value || text(node.content)).trim();
return /^[^\s@<>"]+@[^\s@<>"]+\.[a-z]{2,}$/i.test(address)
? `<a href="mailto:${escape(address)}">${inner()}</a>`
: inner();
}
case 'profile': {
const name = text(node.content).trim();
const id = (value || name).trim();
return `<a class="bb-profile" href="https://osu.ppy.sh/users/${encodeURIComponent(id)}" target="_blank" rel="noopener">${escape(name || id)}</a>`;
}
case 'img': {
const src = safeUrl(text(node.content).trim());
return src
? `<img class="bb-img" src="${escape(src)}" alt="" loading="lazy" referrerpolicy="no-referrer">`
: '';
}
case 'imagemap':
return renderImagemap(text(node.content));
case 'youtube': {
const id = text(node.content).trim();
return /^[A-Za-z0-9_-]{11}$/.test(id)
? `<div class="bb-video"><iframe src="https://www.youtube-nocookie.com/embed/${id}" title="YouTube video" loading="lazy" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe></div>`
: '';
}
case 'audio': {
const src = safeUrl(text(node.content).trim());
return src
? `<audio class="bb-audio" controls preload="none" src="${escape(src)}"></audio>`
: '';
}
case 'list':
return renderList(node, value);
case '*':
return `<li>${inner()}</li>`;
default:
// Not an osu! tag: show what the host typed.
return `${escape(`[${node.tag}${value ? `=${value}` : ''}]`)}${inner()}${node.content ? escape(`[/${node.tag}]`) : ''}`;
}
}
/** [list] children arrive flat, with empty [*] markers between the items. */
function renderList(node: Exclude<Node, string>, value: string): string {
const items: Node[][] = [];
for (const child of node.content ?? []) {
if (typeof child !== 'string' && child.tag === '*') {
items.push([...(child.content ?? [])]);
} else if (items.length) {
items[items.length - 1].push(child);
} else if (typeof child !== 'string' || child.trim()) {
items.push([child]);
}
}
const kind = value === '1' || value === 'a' ? 'ol' : 'ul';
return `<${kind} class="bb-list">${items.map((item) => `<li>${renderNodes(trimNewlines(item))}</li>`).join('')}</${kind}>`;
}
/** First line is the image; each further line is "x y width height link title", in percent. */
function renderImagemap(body: string): string {
const lines = body
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
const src = safeUrl(lines.shift() ?? '');
if (!src) return '';
const areas = lines
.map((line) => {
const [x, y, width, height, link, ...title] = line.split(/\s+/);
const box = [x, y, width, height].map(Number);
if (box.some((n) => !Number.isFinite(n) || n < 0 || n > 100)) return '';
const style = `left:${box[0]}%;top:${box[1]}%;width:${box[2]}%;height:${box[3]}%`;
const href = link && link !== '#' ? safeUrl(link) : null;
const label = escape(title.join(' '));
return href
? `<a class="bb-area" style="${style}" href="${escape(href)}" title="${label}" target="_blank" rel="nofollow noopener noreferrer"></a>`
: `<span class="bb-area" style="${style}" title="${label}"></span>`;
})
.join('');
return `<div class="bb-imagemap"><img src="${escape(src)}" alt="" loading="lazy" referrerpolicy="no-referrer">${areas}</div>`;
}
function attr(node: Exclude<Node, string>): string {
const keys = Object.keys(node.attrs ?? {});
if (keys.length === 0) return '';
// [tag=value] arrives as { value: value }; [quote="Name"] keeps its quotes out.
const value = node.attrs[keys[0]];
return String(value ?? keys[0]).replace(/^"(.*)"$/, '$1');
}
/** The exact text between a tag's opening and closing, tags included. */
function verbatim(node: Exclude<Node, string>): string {
if (node.start && node.end) return source.slice(node.start.to, node.end.from);
return text(node.content);
}
function text(nodes: Node[] | null): string {
return (nodes ?? [])
.map((node) => (typeof node === 'string' ? node : text(node.content)))
.join('');
}
function trimNewlines(nodes: Node[] | null): Node[] {
const list = [...(nodes ?? [])];
while (list[0] === '\n') list.shift();
while (list.at(-1) === '\n') list.pop();
return list;
}
function safeUrl(raw: string): string | null {
const value = raw.trim();
try {
const url = new URL(value.startsWith('//') ? `https:${value}` : value);
return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : null;
} catch {
return null;
}
}
function safeColor(raw: string): string | null {
const value = raw.trim();
if (/^#[0-9a-f]{3,8}$/i.test(value)) return value;
if (/^[a-z]{3,20}$/i.test(value)) return value.toLowerCase();
return null;
}
function sizeClass(raw: string): number {
const size = Number(raw);
if (!Number.isFinite(size)) return 100;
if (size <= 60) return 50;
if (size <= 92) return 85;
if (size <= 120) return 100;
return 150;
}
function escape(value: string): string {
return value.replace(
/[&<>"']/g,
(char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]!
);
}