From f51c5769525cc9eda9c0e227ed6063afa47e6a3e Mon Sep 17 00:00:00 2001
From: "Moon.eth"
Date: Wed, 29 Jul 2026 15:13:45 +0900
Subject: [PATCH] add mfm support
---
src/components/blog/BlogEntry.svelte | 6 +-
src/components/blog/BlogEntry.test.ts | 18 ++
src/components/common/MfmContent.svelte | 51 ++++
src/components/common/MfmContent.test.ts | 73 +++++
src/lib/util/html.ts | 26 +-
src/lib/util/mfm.test.ts | 104 +++++++
src/lib/util/mfm.ts | 177 +++++++++++
src/routes/Home.svelte | 3 +-
src/routes/StatusPage.svelte | 6 +-
src/styles/index.css | 1 +
src/styles/mfm.css | 369 +++++++++++++++++++++++
11 files changed, 826 insertions(+), 8 deletions(-)
create mode 100644 src/components/common/MfmContent.svelte
create mode 100644 src/components/common/MfmContent.test.ts
create mode 100644 src/lib/util/mfm.test.ts
create mode 100644 src/lib/util/mfm.ts
create mode 100644 src/styles/mfm.css
diff --git a/src/components/blog/BlogEntry.svelte b/src/components/blog/BlogEntry.svelte
index db6cf72..36cdeff 100644
--- a/src/components/blog/BlogEntry.svelte
+++ b/src/components/blog/BlogEntry.svelte
@@ -17,7 +17,7 @@
import { isoDate, longDate, stampDate } from '$lib/util/time'
import { extractYouTubeVideoIds } from '$lib/util/youtube'
import Avatar from '../common/Avatar.svelte'
- import RichText from '../common/RichText.svelte'
+ import MfmContent from '../common/MfmContent.svelte'
import Attachments from './Attachments.svelte'
import PollView from './PollView.svelte'
import PreviewCardView from './PreviewCardView.svelte'
@@ -181,7 +181,7 @@
{#if entry.spoiler_text}
{entry.spoiler_text}
-
{:else}
- {
expect(view.getByTitle('YouTube video')).toBeInTheDocument()
})
})
+
+describe('BlogEntry MFM rendering', () => {
+ it('renders the Pleroma API MFM HTML inside a backend-free blog entry', () => {
+ const view = renderEntry({
+ content:
+ 'It works
',
+ })
+
+ const effect = view.getByText('works')
+ expect(effect).toHaveClass('mfm', '-pause')
+ expect(effect).toHaveAttribute('data-mfm-operator', 'spin')
+ expect(effect).toHaveStyle({
+ animationName: 'mfm-spin',
+ animationDuration: '2s',
+ animationDirection: 'reverse',
+ })
+ })
+})
diff --git a/src/components/common/MfmContent.svelte b/src/components/common/MfmContent.svelte
new file mode 100644
index 0000000..6b475d6
--- /dev/null
+++ b/src/components/common/MfmContent.svelte
@@ -0,0 +1,51 @@
+
+
+{#if rendered}
+
+{/if}
diff --git a/src/components/common/MfmContent.test.ts b/src/components/common/MfmContent.test.ts
new file mode 100644
index 0000000..28a29a3
--- /dev/null
+++ b/src/components/common/MfmContent.test.ts
@@ -0,0 +1,73 @@
+import { render } from '@testing-library/svelte'
+import { describe, expect, it } from 'vitest'
+import MfmContent from './MfmContent.svelte'
+
+function mfm(view: { getByText(text: string): HTMLElement }, text: string): HTMLElement {
+ const element = view.getByText(text)
+ expect(element).toHaveClass('mfm')
+ return element
+}
+
+describe('MfmContent', () => {
+ it('renders Pleroma server-produced MFM with its paused-until-hover default', () => {
+ const view = render(MfmContent, {
+ props: {
+ html: 'Hello world
',
+ },
+ })
+
+ const element = mfm(view, 'world')
+ expect(element).toHaveClass('-pause')
+ expect(element).toHaveAttribute('data-mfm-operator', 'spin')
+ expect(element.style.animationName).toBe('mfm-spin')
+ expect(element.style.animationDuration).toBe('0.5s')
+ expect(element.style.animationDirection).toBe('alternate')
+ expect(element.style.animationIterationCount).toBe('infinite')
+ })
+
+ it('supports Pleroma-FE pause and scale options without a backend', () => {
+ const view = render(MfmContent, {
+ props: {
+ html: 'large',
+ pause: false,
+ scale: true,
+ },
+ })
+
+ const element = mfm(view, 'large')
+ expect(element).not.toHaveClass('-pause')
+ expect(element).toHaveClass('-scale')
+ expect(element).toHaveAttribute('data-mfm-operator', 'x2')
+ })
+
+ it('preserves RichText sanitizing, custom emoji, and local link rewriting', () => {
+ const view = render(MfmContent, {
+ props: {
+ html: '@alice :party:
',
+ mentions: [
+ {
+ id: 'account-1',
+ username: 'alice',
+ acct: 'alice',
+ url: 'https://example.test/@alice',
+ },
+ ],
+ emojis: [
+ {
+ shortcode: 'party',
+ url: 'https://example.test/emoji/party.png',
+ static_url: 'https://example.test/emoji/party.png',
+ visible_in_picker: true,
+ },
+ ],
+ },
+ })
+
+ expect(view.container.querySelector('script')).not.toBeInTheDocument()
+ expect(view.getByRole('link', { name: '@alice' })).toHaveAttribute('href', '#/@alice')
+ expect(view.getByRole('img', { name: ':party:' })).toHaveAttribute(
+ 'src',
+ 'https://example.test/emoji/party.png',
+ )
+ })
+})
diff --git a/src/lib/util/html.ts b/src/lib/util/html.ts
index 5613f3e..45b9277 100644
--- a/src/lib/util/html.ts
+++ b/src/lib/util/html.ts
@@ -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:
diff --git a/src/lib/util/mfm.test.ts b/src/lib/util/mfm.test.ts
new file mode 100644
index 0000000..4698c09
--- /dev/null
+++ b/src/lib/util/mfm.test.ts
@@ -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(`effect`)
+ expect(element.dataset.mfmOperator).toBe(operator)
+ expect(element.style.transform).toBe(expected)
+ })
+
+ it('renders foreground and background colors', () => {
+ const fg = renderedElement('fg')
+ const bg = renderedElement('bg')
+ 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(
+ `effect`,
+ )
+ 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(
+ 'spin',
+ )
+ expect(element.style.animationName).toBe('mfm-spinX')
+ expect(element.style.animationDirection).toBe('alternate')
+ })
+
+ it('renders border attributes and noclip', () => {
+ const element = renderedElement(
+ 'box',
+ )
+ 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(`effect`)
+ 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(
+ 'outer inner',
+ )
+ 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(
+ 'safe',
+ )
+ 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(
+ 'safe',
+ )
+ expect(element).not.toHaveAttribute('onclick')
+ expect(element.style.position).toBe('')
+ expect(element.style.transform).toBe('scaleX(-1)')
+ })
+})
diff --git a/src/lib/util/mfm.ts b/src/lib/util/mfm.ts
new file mode 100644
index 0000000..bb7c43e
--- /dev/null
+++ b/src/lib/util/mfm.ts
@@ -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 = {}): 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)
+}
diff --git a/src/routes/Home.svelte b/src/routes/Home.svelte
index 654617b..0f5931a 100644
--- a/src/routes/Home.svelte
+++ b/src/routes/Home.svelte
@@ -22,6 +22,7 @@
import Module from '$components/common/Module.svelte'
import Avatar from '$components/common/Avatar.svelte'
import RichText from '$components/common/RichText.svelte'
+ import MfmContent from '$components/common/MfmContent.svelte'
import Composer from '$components/blog/Composer.svelte'
const { endpoints, session } = useAppServices()
@@ -328,7 +329,7 @@
{displayNameOf(entry.account)}
- ${entry.spoiler_text}
` : entry.content}
emojis={entry.emojis}
mentions={entry.mentions}
diff --git a/src/routes/StatusPage.svelte b/src/routes/StatusPage.svelte
index c618efe..435e53c 100644
--- a/src/routes/StatusPage.svelte
+++ b/src/routes/StatusPage.svelte
@@ -15,7 +15,7 @@
import { useTimelineRefresh } from '$lib/timeline-refresh'
import Module from '$components/common/Module.svelte'
import Avatar from '$components/common/Avatar.svelte'
- import RichText from '$components/common/RichText.svelte'
+ import MfmContent from '$components/common/MfmContent.svelte'
import BlogEntry from '$components/blog/BlogEntry.svelte'
import Composer from '$components/blog/Composer.svelte'
@@ -229,7 +229,7 @@
{#if reply.status.spoiler_text}
{reply.status.spoiler_text}
-
{:else}
-