mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-14 03:02:31 +00:00
338 lines
9.7 KiB
TypeScript
338 lines
9.7 KiB
TypeScript
/**
|
|
* 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, `<br>`, and literal newlines form boundaries.
|
|
*/
|
|
function htmlLines(html: string): Array<string | HtmlLine> {
|
|
const output: Array<string | HtmlLine> = []
|
|
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('>')
|
|
? `<span class="greentext">${line.text}</span>`
|
|
: 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<MfmRenderOptions, 'pause' | 'scale'> = {}): 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)
|
|
}
|