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} +
+ + {@html 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} -