/** * Pleroma-FE-compatible rendering for FEP-c16b HTML. * * Pleroma parses raw `$[operator.attributes content]` MFM on the server and * exposes HTML spans with `mfm-*` classes and `data-mfm-*` attributes through * the Mastodon API. This mirrors Pleroma-FE's RichContent transformation rather * than attempting to parse raw MFM a second time in the browser. */ import type { RenderOptions } from './html' import { renderHtml } from './html' export interface MfmRenderOptions extends RenderOptions { /** Match Pleroma-FE's default: animations resume when RichText is hovered. */ pause?: boolean /** Scale effects against the surrounding custom-emoji size. */ scale?: boolean /** Colour visual lines whose prose starts with `>`, following Pleroma-FE. */ greentext?: boolean } const LOOPING_OPERATORS = new Set([ 'tada', 'jelly', 'twitch', 'shake', 'jump', 'bounce', 'rainbow', ]) const EMPTY_ELEMENTS = new Set([ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr', ]) const BLOCK_ELEMENTS = new Set([ 'address', 'article', 'aside', 'blockquote', 'details', 'dialog', 'dd', 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'li', 'main', 'nav', 'ol', 'p', 'pre', 'section', 'table', 'ul', ]) const VISUAL_LINE_ELEMENTS = new Set([...BLOCK_ELEMENTS, 'br']) const NON_EMPTY_LINE_ELEMENTS = new Set( [...VISUAL_LINE_ELEMENTS].filter((element) => !EMPTY_ELEMENTS.has(element)), ) const RECOGNIZED_LINE_ELEMENTS = new Set([ ...NON_EMPTY_LINE_ELEMENTS, ...EMPTY_ELEMENTS, ]) interface HtmlLine { level: string[] text: string } function tagName(tag: string): string | null { const match = /(?:<\/(\w+)>|<(\w+)\s?.*?\/?>)/is.exec(tag) return (match?.[1] ?? match?.[2] ?? null)?.toLowerCase() ?? null } /** * Pleroma-FE-compatible visual-line tokenizer. Inline markup remains in its * line, while block elements, `
`, and literal newlines form boundaries. */ function htmlLines(html: string): Array { const output: Array = [] const level: string[] = [] let text = '' let tag: string | null = null const flush = (): void => { output.push(text.trim() ? { level: [...level], text } : text) text = '' } for (const character of html) { if (character === '<' && tag === null) { tag = character } else if (character !== '>' && tag !== null) { tag += character } else if (character === '>' && tag !== null) { tag += character const complete = tag tag = null const name = tagName(complete) if (!name || !RECOGNIZED_LINE_ELEMENTS.has(name)) { text += complete } else if (name === 'br') { flush() output.push(complete) } else if (NON_EMPTY_LINE_ELEMENTS.has(name)) { if (complete[1] === '/') { if (level[0] === name) { flush() output.push(complete) level.shift() } else { text += complete } } else if (complete[complete.length - 2] === '/') { flush() output.push(complete) } else { flush() output.push(complete) level.unshift(name) } } else { text += complete } } else if (character === '\n') { flush() output.push(character) } else { text += character } } if (tag) text += tag flush() return output } /** Add trusted presentation spans after sanitization, never before it. */ export function enhanceGreentextHtml(html: string, enabled: boolean): string { if (!enabled || !html.includes('>')) return html return htmlLines(html) .map((line) => { if (typeof line === 'string') return line if (!line.level.every((element) => element === 'p' || element === 'div')) { return line.text } const container = document.createElement('div') container.innerHTML = line.text const prose = (container.textContent ?? '').replace(/@\w+/gi, '').trim() return prose.startsWith('>') ? `${line.text}` : line.text }) .join('') } function numberAttribute(element: Element, name: string, fallback: number): number { const parsed = Number.parseFloat(element.getAttribute(name) ?? '') return Number.isFinite(parsed) && parsed !== 0 ? parsed : fallback } function timeAttribute(element: Element, name: string, fallback: string): string { const value = element.getAttribute(name)?.trim() return value && /^(?:-?(?:\d+(?:\.\d+)?|\.\d+)(?:ms|s)|0)$/i.test(value) ? value : fallback } function hexAttribute(element: Element, name: string, fallback: string): string { const value = element.getAttribute(name)?.trim() return value && /^[0-9a-f]{1,8}$/i.test(value) ? value : fallback } function lengthAttribute(element: Element, name: string, fallback: string): string { const value = element.getAttribute(name)?.trim() return value && /^(?:0|-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|em|rem|ex|ch|%|vw|vh|vmin|vmax)?)$/i.test(value) ? value : fallback } function borderColorAttribute(element: Element): string { const value = element.getAttribute('data-mfm-color')?.trim() if (!value) return 'transparent' if (/^#?[0-9a-f]{3,8}$/i.test(value) || /^(?:transparent|currentcolor)$/i.test(value)) { return value } return 'transparent' } function borderStyleAttribute(element: Element): string { const value = element.getAttribute('data-mfm-style')?.trim().toLowerCase() return value && /^(?:none|hidden|dotted|dashed|solid|double|groove|ridge|inset|outset)$/.test(value) ? value : 'solid' } function animationRules(name: string, speed: string, delay: string, direction = 'normal'): string { return [ `animation-name: ${name}`, `animation-duration: ${speed}`, 'animation-iteration-count: infinite', `animation-delay: ${delay}`, `animation-direction: ${direction}`, 'animation-fill-mode: none', 'animation-timing-function: linear', ].join(';') } /** Enhance already-sanitized FEP-c16b spans using Pleroma-FE's operator rules. */ export function enhanceMfmHtml(html: string, options: Pick = {}): string { if (!html || !html.includes('mfm-')) return html const container = document.createElement('div') container.innerHTML = html for (const element of Array.from(container.querySelectorAll('[class^="mfm-"]'))) { const originalClass = element.getAttribute('class') ?? '' const operator = /^mfm-(\w+)$/.exec(originalClass)?.[1] if (!operator) continue element.setAttribute( 'class', ['mfm', options.pause !== false ? '-pause' : '', options.scale ? '-scale' : ''] .filter(Boolean) .join(' '), ) element.setAttribute('data-mfm-operator', operator) let style = '' switch (operator) { case 'position': { const x = numberAttribute(element, 'data-mfm-x', 0) const y = numberAttribute(element, 'data-mfm-y', 0) style = `transform: translate(calc(${x} * (var(--emoji-size) / 2)), calc(${y} * (var(--emoji-size) / 2)))` break } case 'scale': { const x = numberAttribute(element, 'data-mfm-x', 1) const y = numberAttribute(element, 'data-mfm-y', 1) style = `transform: scale(${x}, ${y})` break } case 'rotate': { const degrees = numberAttribute(element, 'data-mfm-deg', 0) style = `transform: rotate(${degrees}deg);transform-origin: center` break } case 'bg': style = `background-color: #${hexAttribute(element, 'data-mfm-color', '0')}` break case 'fg': style = `color: #${hexAttribute(element, 'data-mfm-color', '0')}` break case 'spin': { const speed = timeAttribute(element, 'data-mfm-speed', '1s') const delay = timeAttribute(element, 'data-mfm-delay', '0') const animation = element.hasAttribute('data-mfm-x') ? 'mfm-spinX' : element.hasAttribute('data-mfm-y') ? 'mfm-spinY' : 'mfm-spin' const direction = element.hasAttribute('data-mfm-alternate') ? 'alternate' : element.hasAttribute('data-mfm-left') ? 'reverse' : 'normal' style = animationRules(animation, speed, delay, direction) break } case 'flip': style = 'transform: scaleX(-1)' break case 'border': { const width = lengthAttribute(element, 'data-mfm-width', '0') const borderStyle = borderStyleAttribute(element) const color = borderColorAttribute(element) const radius = lengthAttribute(element, 'data-mfm-radius', '0') const overflow = element.hasAttribute('data-mfm-noclip') ? 'visible' : 'clip' style = `border: ${width} ${borderStyle} ${color};border-radius: ${radius};overflow: ${overflow}` break } default: if (LOOPING_OPERATORS.has(operator)) { const speed = timeAttribute(element, 'data-mfm-speed', '1s') const delay = timeAttribute(element, 'data-mfm-delay', '0') style = animationRules(`mfm-${operator}`, speed, delay) } } if (style) element.setAttribute('style', style) } return container.innerHTML } /** Sanitize, emojify, relink, then apply Pleroma-FE's MFM presentation. */ export function renderMfmHtml( source: string | null | undefined, options: MfmRenderOptions = {}, ): string { const html = enhanceGreentextHtml(renderHtml(source, options), options.greentext === true) return enhanceMfmHtml(html, options) }