add mfm support

This commit is contained in:
Moon.eth
2026-07-29 15:13:45 +09:00
parent 110b659213
commit f51c576952
11 changed files with 826 additions and 8 deletions
+25 -1
View File
@@ -41,7 +41,31 @@ const ALLOWED_TAGS = [
'img',
]
const ALLOWED_ATTR = ['href', 'rel', 'class', 'title', 'lang', 'src', 'alt', 'draggable', 'data-plspace-to']
const ALLOWED_ATTR = [
'href',
'rel',
'class',
'title',
'lang',
'src',
'alt',
'draggable',
'data-plspace-to',
// FEP-c16b HTML emitted by Pleroma's MFM parser. These are inert here;
// MfmContent validates and interprets them after this sanitization pass.
'data-mfm-x',
'data-mfm-y',
'data-mfm-deg',
'data-mfm-color',
'data-mfm-speed',
'data-mfm-delay',
'data-mfm-left',
'data-mfm-alternate',
'data-mfm-width',
'data-mfm-style',
'data-mfm-radius',
'data-mfm-noclip',
]
/**
* Rewrite outbound anchors:
+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest'
import { renderMfmHtml } from './mfm'
function renderedElement(source: string): HTMLElement {
const container = document.createElement('div')
container.innerHTML = renderMfmHtml(source)
const element = container.firstElementChild
if (!(element instanceof HTMLElement)) throw new Error('Expected one rendered element')
return element
}
describe('Pleroma-FE MFM compatibility', () => {
it.each([
['position', 'data-mfm-x="2" data-mfm-y="-1"', 'translate(calc(2 * (var(--emoji-size) / 2)), calc(-1 * (var(--emoji-size) / 2)))'],
['scale', 'data-mfm-x="2" data-mfm-y="3"', 'scale(2, 3)'],
['rotate', 'data-mfm-deg="45"', 'rotate(45deg)'],
['flip', '', 'scaleX(-1)'],
])('renders the %s transform', (operator, attributes, expected) => {
const element = renderedElement(`<span class="mfm-${operator}" ${attributes}>effect</span>`)
expect(element.dataset.mfmOperator).toBe(operator)
expect(element.style.transform).toBe(expected)
})
it('renders foreground and background colors', () => {
const fg = renderedElement('<span class="mfm-fg" data-mfm-color="12abEF">fg</span>')
const bg = renderedElement('<span class="mfm-bg" data-mfm-color="fed">bg</span>')
expect(fg.style.color).toBe('rgb(18, 171, 239)')
expect(bg.style.backgroundColor).toBe('rgb(255, 238, 221)')
})
it.each(['tada', 'jelly', 'twitch', 'shake', 'jump', 'bounce', 'rainbow'])(
'renders the %s looping animation',
(operator) => {
const element = renderedElement(
`<span class="mfm-${operator}" data-mfm-speed="2s" data-mfm-delay="100ms">effect</span>`,
)
expect(element.style.animationName).toBe(`mfm-${operator}`)
expect(element.style.animationDuration).toBe('2s')
expect(element.style.animationDelay).toBe('100ms')
},
)
it('chooses spin axis and direction with Pleroma-FE precedence', () => {
const element = renderedElement(
'<span class="mfm-spin" data-mfm-x data-mfm-y data-mfm-left data-mfm-alternate>spin</span>',
)
expect(element.style.animationName).toBe('mfm-spinX')
expect(element.style.animationDirection).toBe('alternate')
})
it('renders border attributes and noclip', () => {
const element = renderedElement(
'<span class="mfm-border" data-mfm-width="2px" data-mfm-style="dashed" data-mfm-color="#123456" data-mfm-radius="0.5em" data-mfm-noclip>box</span>',
)
expect(element.style.border).toBe('2px dashed rgb(18, 52, 86)')
expect(element.style.borderRadius).toBe('0.5em')
expect(element.style.overflow).toBe('visible')
})
it.each(['sparkle', 'x2', 'x3', 'x4'])(
'leaves the %s operator for the matching Pleroma-FE CSS rule',
(operator) => {
const element = renderedElement(`<span class="mfm-${operator}">effect</span>`)
expect(element).toHaveClass('mfm', '-pause')
expect(element.dataset.mfmOperator).toBe(operator)
expect(element.getAttribute('style')).toBeNull()
},
)
it('supports nested server-rendered MFM spans', () => {
const container = document.createElement('div')
container.innerHTML = renderMfmHtml(
'<span class="mfm-x2">outer <span class="mfm-spin">inner</span></span>',
)
expect(container.querySelectorAll('.mfm')).toHaveLength(2)
expect(container.querySelector('[data-mfm-operator="spin"]')).toHaveStyle({
animationName: 'mfm-spin',
})
})
it('does not interpret raw MFM source—the Pleroma backend owns parsing', () => {
const html = renderMfmHtml('$[spin.speed=1s plain source]')
expect(html).toBe('$[spin.speed=1s plain source]')
})
it('rejects CSS injection in FEP attributes while keeping the effect', () => {
const element = renderedElement(
'<span class="mfm-spin" data-mfm-speed="1s;color:red" data-mfm-delay="0;display:none">safe</span>',
)
expect(element.style.animationDuration).toBe('1s')
expect(element.getAttribute('style')).toContain('animation-delay: 0')
expect(element.style.color).toBe('')
expect(element.style.display).toBe('')
})
it('drops source style and event handlers before adding trusted effect styles', () => {
const element = renderedElement(
'<span class="mfm-flip" style="position:fixed" onclick="alert(1)">safe</span>',
)
expect(element).not.toHaveAttribute('onclick')
expect(element.style.position).toBe('')
expect(element.style.transform).toBe('scaleX(-1)')
})
})
+177
View File
@@ -0,0 +1,177 @@
/**
* 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
}
const LOOPING_OPERATORS = new Set([
'tada',
'jelly',
'twitch',
'shake',
'jump',
'bounce',
'rainbow',
])
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 {
return enhanceMfmHtml(renderHtml(source, options), options)
}