258 lines
8.3 KiB
TypeScript
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]!
|
|
);
|
|
}
|