type SmileyItem = [number, string, string] interface SmileyData { smilies_array: Record smilies_type: Record } export class BbcodeToHtmlConverter { private smiliesArray: Record = {} private smiliesType: Record = {} private readonly mtCdn = 'https://cdn-bbs.mt2.cn' constructor() { this.initSmilies() } private async initSmilies(): Promise { if (typeof window !== 'undefined' && (window as unknown as { smilies_array?: unknown }).smilies_array) { const win = window as unknown as SmileyData this.smiliesArray = win.smilies_array || {} this.smiliesType = win.smilies_type || {} return } try { const response = await fetch(`${this.mtCdn}/data/cache/common_smilies_var.js?LOt`) const scriptText = await response.text() const match = scriptText.match(/window\.smilies_array\s*=\s*(\[.+\]);/) if (match) { try { this.smiliesArray = JSON.parse(match[1]) as Record } catch { this.smiliesArray = {} } } } catch { this.smiliesArray = {} this.smiliesType = {} } } private getSmileyUrl(smileyKey: string): string | null { const base = `${this.mtCdn}/static/image/smiley/` for (const t in this.smiliesArray) { const pages = this.smiliesArray[t] for (const p in pages) { for (const s of pages[p]) { if (s[1] === smileyKey) { const folder = this.smiliesType[`_${t}`]?.[1] as string return folder ? base + folder + '/' + s[2] : null } } } } return null } private escapeHtml(s: string): string { if (typeof s !== 'string') return s const map: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } return s.replace(/[&<>"']/g, m => map[m]) } convert(bbcode: string): string { if (!bbcode || typeof bbcode !== 'string') { return '' } let html = this.escapeHtml(bbcode) const codes: string[] = [] let idx = 0 html = html.replace(/\[code]([\s\S]*?)\[\/code]/g, (_m, c) => { const lines = c.split('\n').map((l: string) => `
  • ${l || ' '}
  • `).join('') codes.push(`
      ${lines}
    `) return `__CODE__${idx++}__` }) html = this.convertBasicTags(html) html = this.convertStructuralTags(html) html = this.convertLinksAndMedia(html) html = this.convertImagesAndAttachments(html) html = this.convertTables(html) html = this.convertEmoticons(html) html = html.replace(/\n/g, '
    ') codes.forEach((c, i) => { html = html.replace(`__CODE__${i}__`, c) }) return html } private convertBasicTags(html: string): string { const conversions: { pattern: RegExp; replacement: string }[] = [ { pattern: /\[size=([^\]]+)]/gi, replacement: '' }, { pattern: /\[\/size]/gi, replacement: '' }, { pattern: /\[color=([^\]]+)]/gi, replacement: '' }, { pattern: /\[\/color]/gi, replacement: '' }, { pattern: /\[backcolor=([^\]]+)]/gi, replacement: '' }, { pattern: /\[\/backcolor]/gi, replacement: '' }, { pattern: /\[background=([^\]]+)]/gi, replacement: '' }, { pattern: /\[\/background]/gi, replacement: '' }, { pattern: /\[align=([^\]]+)]/gi, replacement: '
    ' }, { pattern: /\[\/align]/gi, replacement: '
    ' }, { pattern: /\[b]/gi, replacement: '' }, { pattern: /\[\/b]/gi, replacement: '' }, { pattern: /\[i]/gi, replacement: '' }, { pattern: /\[\/i]/gi, replacement: '' }, { pattern: /\[font=([^\]]+)]/gi, replacement: '' }, { pattern: /\[\/font]/gi, replacement: '' }, { pattern: /\[u]/gi, replacement: '' }, { pattern: /\[\/u]/gi, replacement: '' }, { pattern: /\[s]/gi, replacement: '' }, { pattern: /\[\/s]/gi, replacement: '' }, { pattern: /\[hr\]/g, replacement: '
    ' } ] for (const { pattern, replacement } of conversions) { html = html.replace(pattern, replacement) } return html } private convertStructuralTags(html: string): string { html = html.replace(/\[quote]([\s\S]*?)\[\/quote]/g, '
    回复 $1
    ') html = html.replace(/\[free]([\s\S]*?)\[\/free]/g, '
    $1
    ') html = html.replace(/\[hide(?:=[^\]]*)?]([\s\S]*?)\[\/hide]/g, '

    本帖隐藏的内容:

    $1
    ') html = html.replace(/\[list=1]/gi, '
      ') html = html.replace(/\[list=a]/gi, '
        ') html = html.replace(/\[list]/gi, '
          ') html = html.replace(/\[\/list]/gi, '
        ') html = html.replace(/\[\*]/gi, '
      • ') return html } private convertLinksAndMedia(html: string): string { html = html.replace(/\[email=([^\]]+)]([\s\S]*?)\[\/email]/gi, '$2') html = html.replace(/\[email]([\s\S]*?)\[\/email]/gi, '$1') html = html.replace(/\[qq](\d+)\[\/qq]/gi, ``) const urlMatches = html.match(/\[url(=[\s\S]*?)?]([\s\S]*?)\[\/url]/gi) if (urlMatches) { urlMatches.forEach(item => { const m = item.match(/\[url(?:=([^\]]*))?]([\s\S]*?)\[\/url]/i) if (m) { const url = m[1] || m[2] const text = m[2] html = html.replace(item, `${text}`) } }) } html = html.replace(/\[media(=[^\]]+)?\]([\s\S]+?)\[\/media\]/gi, (_, _params, url) => { const cleanUrl = url.trim().replace(/[<>"']/g, '') let vid = '' if (/bilibili\.com\/video\/(BV[a-zA-Z0-9]+)/i.test(cleanUrl)) { vid = RegExp.$1 } else if (/bilibili\.com\/video\/av(\d+)/i.test(cleanUrl)) { vid = 'BV' + RegExp.$1 } else if (/b23\.tv\/(BV[a-zA-Z0-9]+)/i.test(cleanUrl)) { vid = RegExp.$1 } if (vid) { return `` } return `${cleanUrl}` }) return html } private convertImagesAndAttachments(html: string): string { html = html.replace(/\[attach]([\s\S]+?)\[\/attach]/g, (_match, aid) => ``) const attMatches = html.match(/\[attachimg]([\s\S]+?)\[\/attachimg]/g) || [] for (const item of attMatches) { const id = item.match(/\[attachimg]([\s\S]+?)\[\/attachimg]/)?.[1] || '' const el = document.getElementById(`aimg_${id}`) as HTMLImageElement | null html = html.replace(item, ``) } const imgMatches = html.match(/\[img(|\=[\s\S]+?)]([\s\S]*?)\[\/img]/g) || [] for (const item of imgMatches) { const sizeMatch = item.match(/\[img=([\s\S]+?)]/) const width = sizeMatch ? sizeMatch[1].split(',')[0] : '100%' const src = item.match(/\[img(?:=.*?)?]([\s\S]*?)\[\/img]/)?.[1] || '' html = html.replace(item, ``) } return html } private convertTables(html: string): string { html = html.replace(/\[td]([\s\S]*?)\[\/td]/g, '$1') html = html.replace(/\[tr]([\s\S]*?)\[\/tr]/g, '$1') html = html.replace(/\[table]([\s\S]*?)\[\/table]/g, '$1
        ') return html } private convertEmoticons(html: string): string { const smMatches = html.match(/\[([\#\w\u4e00-\u9fa5]+)]/g) || [] for (const item of smMatches) { const url = this.getSmileyUrl(item) if (url) { html = html.replace(item, ``) } } return html } } export const bbcodeToHtmlConverter = new BbcodeToHtmlConverter()