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
+3 -3
View File
@@ -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}
<details class="content-warning">
<summary class="content-warning-summary">{entry.spoiler_text}</summary>
<RichText
<MfmContent
html={entry.content}
emojis={entry.emojis}
mentions={entry.mentions}
@@ -194,7 +194,7 @@
<YouTubeEmbeds videoIds={youtubeVideoIds} sensitive={entry.sensitive} />
</details>
{:else}
<RichText
<MfmContent
html={entry.content}
emojis={entry.emojis}
mentions={entry.mentions}
+18
View File
@@ -48,3 +48,21 @@ describe('BlogEntry YouTube embeds', () => {
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:
'<p>It <span class="mfm-spin" data-mfm-speed="2s" data-mfm-left>works</span></p>',
})
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',
})
})
})
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts">
/**
* Status HTML renderer with Pleroma-FE-compatible MFM effects.
*
* This consumes the server-rendered FEP-c16b spans found in `status.content`;
* it intentionally does not parse raw `$[...]` source syntax.
*/
import type { CustomEmoji, StatusMention, StatusTag } from '$lib/api/types'
import { renderMfmHtml } from '$lib/util/mfm'
interface Props {
html: string | null | undefined
emojis?: CustomEmoji[]
mentions?: StatusMention[]
tags?: StatusTag[]
inline?: boolean
class?: string
lang?: string | null
/** Pleroma-FE defaults to pausing animation until hover. */
pause?: boolean
scale?: boolean
}
let {
html,
emojis,
mentions,
tags,
inline = false,
class: extraClass = '',
lang,
pause = true,
scale = false,
}: Props = $props()
const rendered = $derived(
renderMfmHtml(html, { emojis, mentions, tags, inline, pause, scale }),
)
</script>
{#if rendered}
<div
class="rich-text {extraClass}"
class:rich-text--inline={inline}
lang={lang ?? undefined}
dir="auto"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized in renderMfmHtml -->
{@html rendered}
</div>
{/if}
+73
View File
@@ -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: '<p>Hello <span class="mfm-spin" data-mfm-speed="0.5s" data-mfm-alternate>world</span></p>',
},
})
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: '<span class="mfm-x2">large</span>',
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: '<script>bad()</script><p><a href="https://example.test/@alice">@alice</a> :party:</p>',
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',
)
})
})