/** * 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'))}
${inner()}
`; case 'quote': return `
${value ? `${escape(value)} wrote:` : ''}${inner()}
`; case 'code': return `
${escape(verbatim(node).replace(/^\n|\n$/g, ''))}
`; case 'c': return `${escape(verbatim(node))}`; case 'url': { const href = safeUrl(value || text(node.content)); return href ? `${inner()}` : inner(); } case 'email': { const address = (value || text(node.content)).trim(); return /^[^\s@<>"]+@[^\s@<>"]+\.[a-z]{2,}$/i.test(address) ? `${inner()}` : inner(); } case 'profile': { const name = text(node.content).trim(); const id = (value || name).trim(); return `${escape(name || id)}`; } case 'img': { const src = safeUrl(text(node.content).trim()); return src ? `` : ''; } case 'imagemap': return renderImagemap(text(node.content)); case 'youtube': { const id = text(node.content).trim(); return /^[A-Za-z0-9_-]{11}$/.test(id) ? `
` : ''; } case 'audio': { const src = safeUrl(text(node.content).trim()); return src ? `` : ''; } case 'list': return renderList(node, value); case '*': return `
  • ${inner()}
  • `; 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, 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) => `
  • ${renderNodes(trimNewlines(item))}
  • `).join('')}`; } /** 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 ? `` : ``; }) .join(''); return `
    ${areas}
    `; } function attr(node: Exclude): 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): 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]! ); }