merge: Extract inline media from remote posts (resolves #1074, #1104, and #1105) (!1113)

View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1113

Closes #1074, #1104, and #1105

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
Hazelnoot 2025-06-19 23:36:44 +00:00
commit 8926ba06a6
16 changed files with 960 additions and 181 deletions

4
locales/index.d.ts vendored
View file

@ -12954,6 +12954,10 @@ export interface Locale extends ILocale {
* Unable to process quote. This post may be missing context.
*/
"quoteUnavailable": string;
/**
* One or more media attachments are unavailable and cannot be shown.
*/
"attachmentFailed": string;
};
/**
* Authorized Fetch

View file

@ -90,7 +90,7 @@
"@simplewebauthn/server": "12.0.0",
"@sinonjs/fake-timers": "11.3.1",
"@smithy/node-http-handler": "2.5.0",
"mfm-js": "npm:@transfem-org/sfm-js@0.24.6",
"mfm-js": "npm:@transfem-org/sfm-js@0.24.8",
"@twemoji/parser": "15.1.1",
"accepts": "1.3.8",
"ajv": "8.17.1",

View file

@ -335,6 +335,38 @@ export class MfmService {
break;
}
// Replace iframe with link so we can generate previews.
// We shouldn't normally see this, but federated blogging platforms (WordPress, MicroBlog.Pub) can send it.
case 'iframe': {
const txt: string | undefined = node.attribs.title || node.attribs.alt;
const href: string | undefined = node.attribs.src;
if (href) {
if (href.match(/[\s>]/)) {
if (txt) {
// href is invalid + has a label => render a pseudo-link
text += `${text} (${href})`;
} else {
// href is invalid + no label => render plain text
text += href;
}
} else {
if (txt) {
// href is valid + has a label => render a link
const label = txt
.replaceAll('[', '(')
.replaceAll(']', ')')
.replaceAll(/\r?\n/, ' ')
.replaceAll('`', '\'');
text += `[${label}](<${href}>)`;
} else {
// href is valid + no label => render a plain URL
text += `<${href}>`;
}
}
}
break;
}
default: // includes inline elements
{
appendChildren(node.childNodes);

View file

@ -0,0 +1,83 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { load as cheerio } from 'cheerio/slim';
import type { IApDocument } from '@/core/activitypub/type.js';
import type { CheerioAPI } from 'cheerio/slim';
/**
* Finds HTML elements representing inline media and returns them as simulated AP documents.
* Returns an empty array if the input cannot be parsed, or no media was found.
* @param html Input HTML to analyze.
*/
export function extractMediaFromHtml(html: string): IApDocument[] {
const $ = parseHtml(html);
if (!$) return [];
const attachments = new Map<string, IApDocument>();
// <img> tags, including <picture> and <object> fallback elements
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img
$('img[src]')
.toArray()
.forEach(img => attachments.set(img.attribs.src, {
type: 'Image',
url: img.attribs.src,
name: img.attribs.alt || img.attribs.title || null,
}));
// <object> tags
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/object
$('object[data]')
.toArray()
.forEach(object => attachments.set(object.attribs.data, {
type: 'Document',
url: object.attribs.data,
name: object.attribs.alt || object.attribs.title || null,
}));
// <embed> tags
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/embed
$('embed[src]')
.toArray()
.forEach(embed => attachments.set(embed.attribs.src, {
type: 'Document',
url: embed.attribs.src,
name: embed.attribs.alt || embed.attribs.title || null,
}));
// <audio> tags
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/audio
$('audio[src]')
.toArray()
.forEach(audio => attachments.set(audio.attribs.src, {
type: 'Audio',
url: audio.attribs.src,
name: audio.attribs.alt || audio.attribs.title || null,
}));
// <video> tags
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/video
$('video[src]')
.toArray()
.forEach(audio => attachments.set(audio.attribs.src, {
type: 'Video',
url: audio.attribs.src,
name: audio.attribs.alt || audio.attribs.title || null,
}));
// TODO support <svg>? We would need to extract it directly from the HTML and save to a temp file.
return Array.from(attachments.values());
}
function parseHtml(html: string): CheerioAPI | null {
try {
return cheerio(html);
} catch {
// Don't worry about invalid HTML
return null;
}
}

View file

@ -0,0 +1,61 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { parse, inspect, extract } from 'mfm-js';
import type { IApDocument } from '@/core/activitypub/type.js';
import type { MfmNode, MfmText } from 'mfm-js';
/**
* Finds MFM notes representing inline media and returns them as simulated AP documents.
* Returns an empty array if the input cannot be parsed, or no media was found.
* @param mfm Input MFM to analyze.
*/
export function extractMediaFromMfm(mfm: string): IApDocument[] {
const nodes = parseMfm(mfm);
if (nodes == null) return [];
const attachments = new Map<string, IApDocument>();
inspect(nodes, node => {
if (node.type === 'link' && node.props.image) {
const alt: string[] = [];
inspect(node.children, node => {
switch (node.type) {
case 'text':
alt.push(node.props.text);
break;
case 'unicodeEmoji':
alt.push(node.props.emoji);
break;
case 'emojiCode':
alt.push(':');
alt.push(node.props.name);
alt.push(':');
break;
}
});
attachments.set(node.props.url, {
type: 'Image',
url: node.props.url,
name: alt.length > 0
? alt.join('')
: null,
});
}
});
return Array.from(attachments.values());
}
function parseMfm(mfm: string): MfmNode[] | null {
try {
return parse(mfm);
} catch {
// Don't worry about invalid MFM
return null;
}
}

View file

@ -0,0 +1,74 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { IPost } from '@/core/activitypub/type.js';
import { toArray } from '@/misc/prelude/array.js';
/**
* Gets content of a specified media type from a provided object.
*
* Optionally supports a "permissive" mode which enables the following changes:
* 1. MIME types are checked in a case-insensitive manner.
* 2. MIME types are matched based on inclusion, not strict equality.
* 3. A candidate content is considered to match if it has no specified MIME type.
*
* Note: this method is written defensively to protect against malform remote objects.
* When extending or modifying it, please be sure to work with "unknown" type and validate everything.
*
* Note: the logic in this method is carefully ordered to match the selection priority of existing code in ApNoteService.
* Please do not re-arrange it without testing!
* New checks can be added to the end of the method to safely extend the existing logic.
*
* @param object AP object to extract content from.
* @param mimeType MIME type to look for.
* @param permissive Enables permissive mode, as described above. Defaults to false (disabled).
*/
export function getContentByType(object: IPost | Record<string, unknown>, mimeType: string, permissive = false): string | null {
// Case 1: Extended "source" property
if (object.source && typeof(object.source) === 'object') {
// "source" is permitted to be an array, though no implementations are known to do this yet.
const sources = toArray(object.source) as Record<string, unknown>[];
for (const source of sources) {
if (typeof (source.content) === 'string' && checkMediaType(source.mediaType)) {
return source.content;
}
}
}
// Case 2: Special case for MFM
if (typeof(object._misskey_content) === 'string' && mimeType === 'text/x.misskeymarkdown') {
return object._misskey_content;
}
// Case 3: AP native "content" property
if (typeof(object.content) === 'string' && checkMediaType(object.mediaType)) {
return object.content;
}
return null;
// Checks if the provided media type matches the input parameters.
function checkMediaType(mediaType: unknown): boolean {
if (typeof(mediaType) === 'string') {
// Strict match
if (mediaType === mimeType) {
return true;
}
// Permissive match
if (permissive && mediaType.toLowerCase().includes(mimeType.toLowerCase())) {
return true;
}
}
// Permissive fallback match
if (permissive && mediaType == null) {
return true;
}
// No match
return false;
}
}

View file

@ -86,7 +86,7 @@ export class ApImageService {
uri: image.url,
sensitive: !!(image.sensitive),
isLink: !shouldBeCached,
comment: truncate(image.name ?? undefined, this.config.maxRemoteAltTextLength),
comment: truncate(image.summary || image.name || undefined, this.config.maxRemoteAltTextLength),
});
if (!file.isLink || file.url === image.url) return file;

View file

@ -6,6 +6,7 @@
import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import { UnrecoverableError } from 'bullmq';
import promiseLimit from 'promise-limit';
import { DI } from '@/di-symbols.js';
import type { UsersRepository, PollsRepository, EmojisRepository, NotesRepository, MiMeta } from '@/models/_.js';
import type { Config } from '@/config.js';
@ -27,6 +28,9 @@ import { checkHttps } from '@/misc/check-https.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { isRetryableError } from '@/misc/is-retryable-error.js';
import { renderInlineError } from '@/misc/render-inline-error.js';
import { extractMediaFromHtml } from '@/core/activitypub/misc/extract-media-from-html.js';
import { extractMediaFromMfm } from '@/core/activitypub/misc/extract-media-from-mfm.js';
import { getContentByType } from '@/core/activitypub/misc/get-content-by-type.js';
import { getOneApId, getApId, validPost, isEmoji, getApType, isApObject, isDocument, IApDocument, isLink } from '../type.js';
import { ApLoggerService } from '../ApLoggerService.js';
import { ApMfmService } from '../ApMfmService.js';
@ -206,12 +210,10 @@ export class ApNoteService {
const cw = note.summary === '' ? null : note.summary;
// テキストのパース
let text: string | null = null;
if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') {
text = note.source.content;
} else if (typeof note._misskey_content !== 'undefined') {
text = note._misskey_content;
} else if (typeof note.content === 'string') {
let text =
getContentByType(note, 'text/x.misskeymarkdown') ??
getContentByType(note, 'text/markdown');
if (text == null && typeof note.content === 'string') {
text = this.apMfmService.htmlToMfm(note.content, note.tag);
}
@ -248,21 +250,14 @@ export class ApNoteService {
}
}
const processErrors: string[] = [];
// 添付ファイル
const files: MiDriveFile[] = [];
for (const attach of toArray(note.attachment)) {
attach.sensitive ??= note.sensitive;
const file = await this.apImageService.resolveImage(actor, attach);
if (file) files.push(file);
}
// Some software (Peertube) attaches a thumbnail under "icon" instead of "attachment"
const icon = getBestIcon(note);
if (icon) {
icon.sensitive ??= note.sensitive;
const file = await this.apImageService.resolveImage(actor, icon);
if (file) files.push(file);
// Note: implementation moved to getAttachment function to avoid duplication.
// Please copy any upstream changes to that method! (It's in the bottom of this class)
const { files, hasFileError } = await this.getAttachments(note, actor);
if (hasFileError) {
processErrors.push('attachmentFailed');
}
// リプライ
@ -284,7 +279,9 @@ export class ApNoteService {
// 引用
const quote = await this.getQuote(note, entryUri, resolver);
const processErrors = quote === null ? ['quoteUnavailable'] : null;
if (quote === null) {
processErrors.push('quoteUnavailable');
}
if (reply && reply.userHost == null && reply.localOnly) {
throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot reply to local-only note');
@ -328,7 +325,7 @@ export class ApNoteService {
files,
reply,
renote: quote ?? null,
processErrors,
processErrors: processErrors.length > 0 ? processErrors : null,
name: note.name,
cw,
text,
@ -412,12 +409,10 @@ export class ApNoteService {
const cw = note.summary === '' ? null : note.summary;
// テキストのパース
let text: string | null = null;
if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') {
text = note.source.content;
} else if (typeof note._misskey_content !== 'undefined') {
text = note._misskey_content;
} else if (typeof note.content === 'string') {
let text =
getContentByType(note, 'text/x.misskeymarkdown') ??
getContentByType(note, 'text/markdown');
if (text == null && typeof note.content === 'string') {
text = this.apMfmService.htmlToMfm(note.content, note.tag);
}
@ -446,21 +441,12 @@ export class ApNoteService {
}
}
const processErrors: string[] = [];
// 添付ファイル
const files: MiDriveFile[] = [];
for (const attach of toArray(note.attachment)) {
attach.sensitive ??= note.sensitive;
const file = await this.apImageService.resolveImage(actor, attach);
if (file) files.push(file);
}
// Some software (Peertube) attaches a thumbnail under "icon" instead of "attachment"
const icon = getBestIcon(note);
if (icon) {
icon.sensitive ??= note.sensitive;
const file = await this.apImageService.resolveImage(actor, icon);
if (file) files.push(file);
const { files, hasFileError } = await this.getAttachments(note, actor);
if (hasFileError) {
processErrors.push('attachmentFailed');
}
// リプライ
@ -482,7 +468,9 @@ export class ApNoteService {
// 引用
const quote = await this.getQuote(note, entryUri, resolver);
const processErrors = quote === null ? ['quoteUnavailable'] : null;
if (quote === null) {
processErrors.push('quoteUnavailable');
}
if (quote && quote.userHost == null && quote.localOnly) {
throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot quote a local-only note');
@ -523,7 +511,7 @@ export class ApNoteService {
files,
reply,
renote: quote ?? null,
processErrors,
processErrors: processErrors.length > 0 ? processErrors : null,
name: note.name,
cw,
text,
@ -722,10 +710,95 @@ export class ApNoteService {
// Permanent error - return null
return null;
}
/**
* Extracts and saves all media attachments from the provided note.
* Returns an array of all the created files.
*/
private async getAttachments(note: IPost, actor: MiRemoteUser): Promise<{ files: MiDriveFile[], hasFileError: boolean }> {
const attachments = new Map<string, IApDocument & { url: string }>();
// Extract inline media from HTML content.
// Don't use source.content, _misskey_content, or anything else because those aren't HTML.
const htmlContent = getContentByType(note, 'text/html', true);
if (htmlContent) {
for (const attach of extractMediaFromHtml(htmlContent)) {
if (hasUrl(attach)) {
attachments.set(attach.url, attach);
}
}
}
// Extract inline media from MFM / markdown content.
const mfmContent =
getContentByType(note, 'text/x.misskeymarkdown') ??
getContentByType(note, 'text/markdown');
if (mfmContent) {
for (const attach of extractMediaFromMfm(mfmContent)) {
if (hasUrl(attach)) {
attachments.set(attach.url, attach);
}
}
}
// Some software (Peertube) attaches a thumbnail under "icon" instead of "attachment"
const icon = getBestIcon(note);
if (icon) {
if (hasUrl(icon)) {
attachments.set(icon.url, icon);
}
}
// Populate AP attachments last, to overwrite any "fallback" elements that may have been inlined in HTML.
// AP attachments should be considered canonical.
for (const attach of toArray(note.attachment)) {
if (hasUrl(attach)) {
attachments.set(attach.url, attach);
}
}
// Resolve all files w/ concurrency 2.
// This prevents one big file from blocking the others.
const limiter = promiseLimit<MiDriveFile | null>(2);
const results = await Promise
.all(Array
.from(attachments.values())
.map(attach => limiter(async () => {
attach.sensitive ??= note.sensitive;
return await this.resolveImage(actor, attach);
})));
// Process results
let hasFileError = false;
const files: MiDriveFile[] = [];
for (const result of results) {
if (result != null) {
files.push(result);
} else {
hasFileError = true;
}
}
return { files, hasFileError };
}
private async resolveImage(actor: MiRemoteUser, attachment: IApDocument & { url: string }): Promise<MiDriveFile | null> {
try {
return await this.apImageService.resolveImage(actor, attachment);
} catch (err) {
if (isRetryableError(err)) {
this.logger.warn(`Temporary failure to resolve attachment at ${attachment.url}: ${renderInlineError(err)}`);
throw err;
} else {
this.logger.warn(`Permanent failure to resolve attachment at ${attachment.url}: ${renderInlineError(err)}`);
return null;
}
}
}
}
function getBestIcon(note: IObject): IObject | null {
const icons: IObject[] = toArray(note.icon);
function getBestIcon(note: IObject): IApDocument | null {
const icons: IApDocument[] = toArray(note.icon);
if (icons.length < 2) {
return icons[0] ?? null;
}
@ -741,3 +814,8 @@ function getBestIcon(note: IObject): IObject | null {
return best;
}, null as IApDocument | null) ?? null;
}
// Need this to make TypeScript happy...
function hasUrl<T extends IObject>(object: T): object is T & { url: string } {
return typeof(object.url) === 'string';
}

View file

@ -24,7 +24,7 @@ export interface IObject {
cc?: ApObject;
to?: ApObject;
attributedTo?: ApObject;
attachment?: any[];
attachment?: IApDocument[];
inReplyTo?: any;
replies?: ICollection | IOrderedCollection | string;
content?: string | null;

View file

@ -0,0 +1,297 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { extractMediaFromHtml } from '@/core/activitypub/misc/extract-media-from-html.js';
describe(extractMediaFromHtml, () => {
it('should return empty for invalid input', () => {
const result = extractMediaFromHtml('<broken html');
expect(result).toEqual([]);
});
it('should return empty for empty input', () => {
const result = extractMediaFromHtml('');
expect(result).toEqual([]);
});
it('should return empty for input without attachments', () => {
const result = extractMediaFromHtml('<div>No media here!</div>');
expect(result).toEqual([]);
});
it('should extract img tags', () => {
const result = extractMediaFromHtml('<img src="https://example.com/img.png" alt=""/>');
expect(result).toEqual([{
type: 'Image',
url: 'https://example.com/img.png',
name: null,
}]);
});
it('should ignore img tags without src', () => {
const result = extractMediaFromHtml('<img alt=""/>');
expect(result).toEqual([]);
});
it('should extract picture tags with img', () => {
const result = extractMediaFromHtml('<picture><img src="https://example.com/picture.png" alt=""/></picture>');
expect(result).toEqual([{
type: 'Image',
url: 'https://example.com/picture.png',
name: null,
}]);
});
it('should ignore picture tags without img', () => {
const result = extractMediaFromHtml('<picture><source src="https://example.com/picture.png"/></picture>');
expect(result).toEqual([]);
});
it('should ignore picture tags without src', () => {
const result = extractMediaFromHtml('<picture><source/><img alt=""/></picture>');
expect(result).toEqual([]);
});
it('should extract object tags', () => {
const result = extractMediaFromHtml('<object data="https://example.com/object.dat"></object>');
expect(result).toEqual([{
type: 'Document',
url: 'https://example.com/object.dat',
name: null,
}]);
});
it('should ignore object tags without data', () => {
const result = extractMediaFromHtml('<object></object>');
expect(result).toEqual([]);
});
it('should extract object tags with img fallback', () => {
const result = extractMediaFromHtml('<object><img src="https://example.com/object.png" alt=""/></object>');
expect(result).toEqual([{
type: 'Image',
url: 'https://example.com/object.png',
name: null,
}]);
});
it('should ignore object tags with empty img fallback', () => {
const result = extractMediaFromHtml('<object><img alt=""/></object>');
expect(result).toEqual([]);
});
it('should extract embed tags', () => {
const result = extractMediaFromHtml('<embed src="https://example.com/embed.dat"/>');
expect(result).toEqual([{
type: 'Document',
url: 'https://example.com/embed.dat',
name: null,
}]);
});
it('should ignore embed tags without src', () => {
const result = extractMediaFromHtml('<embed/>');
expect(result).toEqual([]);
});
it('should extract audio tags', () => {
const result = extractMediaFromHtml('<audio src="https://example.com/audio.mp3"></audio>');
expect(result).toEqual([{
type: 'Audio',
url: 'https://example.com/audio.mp3',
name: null,
}]);
});
it('should ignore audio tags without src', () => {
const result = extractMediaFromHtml('<audio></audio>');
expect(result).toEqual([]);
});
it('should extract video tags', () => {
const result = extractMediaFromHtml('<video src="https://example.com/video.mp4"></video>');
expect(result).toEqual([{
type: 'Video',
url: 'https://example.com/video.mp4',
name: null,
}]);
});
it('should ignore video tags without src', () => {
const result = extractMediaFromHtml('<video></video>');
expect(result).toEqual([]);
});
it('should extract alt text from alt property', () => {
const result = extractMediaFromHtml(`
<img src="https://example.com/img.png" alt="img tag" title="wrong"/>
<picture><img src="https://example.com/picture.png" alt="picture tag" title="wrong"/></picture>
<object data="https://example.com/object-1.dat" alt="object tag" title="wrong"></object>
<object><img src="https://example.com/object-2.png" alt="object tag" title="wrong"/></object>
<embed src="https://example.com/embed.dat" alt="embed tag" title="wrong"/>
<audio src="https://example.com/audio.mp3" alt="audio tag" title="wrong"/>
<video src="https://example.com/video.mp4" alt="video tag" title="wrong"/>
`);
expect(result).toEqual([
{
type: 'Image',
url: 'https://example.com/img.png',
name: 'img tag',
},
{
type: 'Image',
url: 'https://example.com/picture.png',
name: 'picture tag',
},
{
type: 'Image',
url: 'https://example.com/object-2.png',
name: 'object tag',
},
{
type: 'Document',
url: 'https://example.com/object-1.dat',
name: 'object tag',
},
{
type: 'Document',
url: 'https://example.com/embed.dat',
name: 'embed tag',
},
{
type: 'Audio',
url: 'https://example.com/audio.mp3',
name: 'audio tag',
},
{
type: 'Video',
url: 'https://example.com/video.mp4',
name: 'video tag',
},
]);
});
it('should extract alt text from title property', () => {
const result = extractMediaFromHtml(`
<img src="https://example.com/img.png" title="img tag"/>
<picture><img src="https://example.com/picture.png" title="picture tag"/></picture>
<object data="https://example.com/object-1.dat" title="object tag"></object>
<object><img src="https://example.com/object-2.png" title="object tag"/></object>
<embed src="https://example.com/embed.dat" title="embed tag"/>
<audio src="https://example.com/audio.mp3" title="audio tag"/>
<video src="https://example.com/video.mp4" title="video tag"/>
`);
expect(result).toEqual([
{
type: 'Image',
url: 'https://example.com/img.png',
name: 'img tag',
},
{
type: 'Image',
url: 'https://example.com/picture.png',
name: 'picture tag',
},
{
type: 'Image',
url: 'https://example.com/object-2.png',
name: 'object tag',
},
{
type: 'Document',
url: 'https://example.com/object-1.dat',
name: 'object tag',
},
{
type: 'Document',
url: 'https://example.com/embed.dat',
name: 'embed tag',
},
{
type: 'Audio',
url: 'https://example.com/audio.mp3',
name: 'audio tag',
},
{
type: 'Video',
url: 'https://example.com/video.mp4',
name: 'video tag',
},
]);
});
it('should ignore missing alt text', () => {
const result = extractMediaFromHtml(`
<img src="https://example.com/img.png"/>
<picture><img src="https://example.com/picture.png"/></picture>
<object data="https://example.com/object-1.dat"></object>
<object><img src="https://example.com/object-2.png"/></object>
<embed src="https://example.com/embed.dat"/>
<audio src="https://example.com/audio.mp3"/>
<video src="https://example.com/video.mp4"/>
`);
expect(result).toEqual([
{
type: 'Image',
url: 'https://example.com/img.png',
name: null,
},
{
type: 'Image',
url: 'https://example.com/picture.png',
name: null,
},
{
type: 'Image',
url: 'https://example.com/object-2.png',
name: null,
},
{
type: 'Document',
url: 'https://example.com/object-1.dat',
name: null,
},
{
type: 'Document',
url: 'https://example.com/embed.dat',
name: null,
},
{
type: 'Audio',
url: 'https://example.com/audio.mp3',
name: null,
},
{
type: 'Video',
url: 'https://example.com/video.mp4',
name: null,
},
]);
});
it('should de-duplicate attachments', () => {
const result = extractMediaFromHtml(`
<img src="https://example.com/1.png" alt="img 1"/>
<img src="https://example.com/2.png" alt="img 2"/>
<embed src="https://example.com/1.png" alt="embed 1"/>
`);
expect(result).toEqual([
{
type: 'Document',
url: 'https://example.com/1.png',
name: 'embed 1',
},
{
type: 'Image',
url: 'https://example.com/2.png',
name: 'img 2',
},
]);
});
});

View file

@ -0,0 +1,92 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { extractMediaFromMfm } from '@/core/activitypub/misc/extract-media-from-mfm.js';
describe(extractMediaFromMfm, () => {
it('should return empty for empty input', () => {
const result = extractMediaFromMfm('');
expect(result).toEqual([]);
});
it('should return empty for invalid input', () => {
const result = extractMediaFromMfm('*broken markdown\0');
expect(result).toEqual([]);
});
it('should extract all image links', () => {
const result = extractMediaFromMfm(`
![1](https://example.com/images/1.png)
![](https://example.com/images/2.png)
**![3](https://example.com/images/3.png)**
`);
expect(result).toEqual([
{
type: 'Image',
url: 'https://example.com/images/1.png',
name: '1',
},
{
type: 'Image',
url: 'https://example.com/images/2.png',
name: null,
},
{
type: 'Image',
url: 'https://example.com/images/3.png',
name: '3',
},
]);
});
it('should ignore regular links', () => {
const result = extractMediaFromMfm(`
[1](https://example.com/images/1.png)
[](https://example.com/images/2.png)
**[3](https://example.com/images/3.png)**
`);
expect(result).toEqual([]);
});
it('should ignore silent links', () => {
const result = extractMediaFromMfm(`
?[1](https://example.com/images/1.png)
?[](https://example.com/images/2.png)
**?[3](https://example.com/images/3.png)**
`);
expect(result).toEqual([]);
});
it('should extract complex text', () => {
const result = extractMediaFromMfm('![this is an **image** with *complex* text! :owo: 💙](https://example.com/image.png)');
expect(result).toEqual([
{
type: 'Image',
url: 'https://example.com/image.png',
name: 'this is an image with complex text! :owo: 💙',
},
]);
});
it('should de-duplicate images', () => {
const result = extractMediaFromMfm(`
![1](https://example.com/images/1.png)
![](https://example.com/images/1.png)
**![3](https://example.com/images/1.png)**
`);
expect(result).toEqual([
{
type: 'Image',
url: 'https://example.com/images/1.png',
name: '3',
},
]);
});
});

View file

@ -0,0 +1,167 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { getContentByType } from '@/core/activitypub/misc/get-content-by-type.js';
describe(getContentByType, () => {
describe('when permissive', () => {
it('should return source.content when it matches', () => {
const obj = {
source: {
content: 'source content',
},
_misskey_content: 'misskey content',
content: 'native content',
mediaType: 'text/x.misskeYMarkdown, text/markdown',
};
const content = getContentByType(obj, 'text/x.misskeymarkdown', true);
expect(content).toBe('source content');
});
it('should return _misskey_content when it matches', () => {
const obj = {
source: {
content: 'source content',
mediaType: 'text/plain',
},
_misskey_content: 'misskey content',
content: 'native content',
mediaType: 'text/x.misskeYMarkdown, text/markdown',
};
const content = getContentByType(obj, 'text/x.misskeymarkdown', true);
expect(content).toBe('misskey content');
});
it('should return content when it matches', () => {
const obj = {
source: {
content: 'source content',
mediaType: 'text/plain',
},
_misskey_content: null,
content: 'native content',
mediaType: 'text/x.misskeYMarkdown, text/markdown',
};
const content = getContentByType(obj, 'text/x.misskeymarkdown', true);
expect(content).toBe('native content');
});
it('should return null when nothing matches', () => {
const obj = {
source: {
content: 'source content',
mediaType: 'text/plain',
},
_misskey_content: null,
content: 'native content',
mediaType: 'text/plain',
};
const content = getContentByType(obj, 'text/x.misskeymarkdown', true);
expect(content).toBe(null);
});
it('should return null for invalid inputs', () => {
const objects = [
{},
{ source: 'nope' },
{ content: null },
{ _misskey_content: 123 },
];
const results = objects.map(c => getContentByType(c, 'text/misskeymarkdown', true));
const expected = objects.map(() => null);
expect(results).toEqual(expected);
});
});
describe('when not permissive', () => {
it('should return source.content when it matches', () => {
const obj = {
source: {
content: 'source content',
mediaType: 'text/x.misskeymarkdown',
},
_misskey_content: 'misskey content',
content: 'native content',
mediaType: 'text/x.misskeymarkdown',
};
const content = getContentByType(obj, 'text/x.misskeymarkdown');
expect(content).toBe('source content');
});
it('should return _misskey_content when it matches', () => {
const obj = {
source: {
content: 'source content',
mediaType: 'text/plain',
},
_misskey_content: 'misskey content',
content: 'native content',
mediaType: 'text/x.misskeymarkdown',
};
const content = getContentByType(obj, 'text/x.misskeymarkdown');
expect(content).toBe('misskey content');
});
it('should return content when it matches', () => {
const obj = {
source: {
content: 'source content',
mediaType: 'text/plain',
},
_misskey_content: null,
content: 'native content',
mediaType: 'text/x.misskeymarkdown',
};
const content = getContentByType(obj, 'text/x.misskeymarkdown');
expect(content).toBe('native content');
});
it('should return null when nothing matches', () => {
const obj = {
source: {
content: 'source content',
mediaType: 'text/plain',
},
_misskey_content: null,
content: 'native content',
mediaType: 'text/plain',
};
const content = getContentByType(obj, 'text/x.misskeymarkdown');
expect(content).toBe(null);
});
it('should return null for invalid inputs', () => {
const objects = [
{},
{ source: 'nope' },
{ content: null },
{ _misskey_content: 123 },
];
const results = objects.map(c => getContentByType(c, 'text/misskeymarkdown'));
const expected = objects.map(() => null);
expect(results).toEqual(expected);
});
});
});

View file

@ -12,7 +12,7 @@
"dependencies": {
"@discordapp/twemoji": "15.1.0",
"@phosphor-icons/web": "2.1.2",
"mfm-js": "npm:@transfem-org/sfm-js@0.24.6",
"mfm-js": "npm:@transfem-org/sfm-js@0.24.8",
"buraha": "0.0.1",
"frontend-shared": "workspace:*",
"json5": "2.2.3",

View file

@ -112,7 +112,7 @@
"@vue/compiler-core": "3.5.14",
"@vue/compiler-sfc": "3.5.14",
"@vue/runtime-core": "3.5.14",
"mfm-js": "npm:@transfem-org/sfm-js@0.24.6",
"mfm-js": "npm:@transfem-org/sfm-js@0.24.8",
"acorn": "8.14.1",
"astring": "1.9.0",
"cross-env": "7.0.3",

150
pnpm-lock.yaml generated
View file

@ -282,8 +282,8 @@ importers:
specifier: 0.50.0
version: 0.50.0
mfm-js:
specifier: npm:@transfem-org/sfm-js@0.24.6
version: '@transfem-org/sfm-js@0.24.6'
specifier: npm:@transfem-org/sfm-js@0.24.8
version: '@transfem-org/sfm-js@0.24.8'
mime-types:
specifier: 2.1.35
version: 2.1.35
@ -935,7 +935,7 @@ importers:
version: 5.2.3(vite@6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))(vue@3.5.14(typescript@5.8.3))
'@vitest/coverage-v8':
specifier: 3.1.2
version: 3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))
version: 3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))
'@vue/compiler-core':
specifier: 3.5.14
version: 3.5.14
@ -976,8 +976,8 @@ importers:
specifier: 0.30.17
version: 0.30.17
mfm-js:
specifier: npm:@transfem-org/sfm-js@0.24.6
version: '@transfem-org/sfm-js@0.24.6'
specifier: npm:@transfem-org/sfm-js@0.24.8
version: '@transfem-org/sfm-js@0.24.8'
micromatch:
specifier: 4.0.8
version: 4.0.8
@ -1037,7 +1037,7 @@ importers:
version: 1.0.3
vitest:
specifier: 3.1.2
version: 3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)
version: 3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)
vitest-fetch-mock:
specifier: 0.4.5
version: 0.4.5(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))
@ -1069,8 +1069,8 @@ importers:
specifier: 2.2.3
version: 2.2.3
mfm-js:
specifier: npm:@transfem-org/sfm-js@0.24.6
version: '@transfem-org/sfm-js@0.24.6'
specifier: npm:@transfem-org/sfm-js@0.24.8
version: '@transfem-org/sfm-js@0.24.8'
misskey-js:
specifier: workspace:*
version: link:../misskey-js
@ -1137,7 +1137,7 @@ importers:
version: 5.2.3(vite@6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))(vue@3.5.14(typescript@5.8.3))
'@vitest/coverage-v8':
specifier: 3.1.2
version: 3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))
version: 3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))
'@vue/compiler-sfc':
specifier: 3.5.14
version: 3.5.14
@ -1283,7 +1283,7 @@ importers:
version: 29.7.0
ts-jest:
specifier: 29.3.4
version: 29.3.4(@babel/core@7.24.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.24.7))(esbuild@0.25.3)(jest@29.7.0(@types/node@22.15.2))(typescript@5.8.3)
version: 29.3.4(@babel/core@7.23.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.5))(esbuild@0.25.3)(jest@29.7.0(@types/node@22.15.2))(typescript@5.8.3)
packages/misskey-bubble-game:
dependencies:
@ -4012,8 +4012,8 @@ packages:
resolution: {integrity: sha1-LSVFMGgZU9oQlHSVb5XEzOG+yeQ=, tarball: https://activitypub.software/api/v4/projects/229/packages/npm/@transfem-org/cli-highlight/-/@transfem-org/cli-highlight-2.1.12.tgz}
engines: {node: ^22.0.0}
'@transfem-org/sfm-js@0.24.6':
resolution: {integrity: sha1-7t+TkCd3PZk+RbbrGbZ/iMs2y7o=, tarball: https://activitypub.software/api/v4/projects/2/packages/npm/@transfem-org/sfm-js/-/@transfem-org/sfm-js-0.24.6.tgz}
'@transfem-org/sfm-js@0.24.8':
resolution: {integrity: sha1-G97++XwNPZZaxIExiJbm2kJZSg0=, tarball: https://activitypub.software/api/v4/projects/2/packages/npm/@transfem-org/sfm-js/-/@transfem-org/sfm-js-0.24.8.tgz}
'@transfem-org/summaly@5.2.2':
resolution: {integrity: sha1-MO7cCppxE0luitQqz9A6RiWHpco=, tarball: https://activitypub.software/api/v4/projects/217/packages/npm/@transfem-org/summaly/-/@transfem-org/summaly-5.2.2.tgz}
@ -11427,56 +11427,26 @@ snapshots:
'@babel/core': 7.23.5
'@babel/helper-plugin-utils': 7.22.5
'@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.24.7)':
dependencies:
'@babel/core': 7.24.7
'@babel/helper-plugin-utils': 7.22.5
optional: true
'@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.23.5)':
dependencies:
'@babel/core': 7.23.5
'@babel/helper-plugin-utils': 7.22.5
'@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.24.7)':
dependencies:
'@babel/core': 7.24.7
'@babel/helper-plugin-utils': 7.22.5
optional: true
'@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.23.5)':
dependencies:
'@babel/core': 7.23.5
'@babel/helper-plugin-utils': 7.22.5
'@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.24.7)':
dependencies:
'@babel/core': 7.24.7
'@babel/helper-plugin-utils': 7.22.5
optional: true
'@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.23.5)':
dependencies:
'@babel/core': 7.23.5
'@babel/helper-plugin-utils': 7.22.5
'@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.24.7)':
dependencies:
'@babel/core': 7.24.7
'@babel/helper-plugin-utils': 7.22.5
optional: true
'@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.23.5)':
dependencies:
'@babel/core': 7.23.5
'@babel/helper-plugin-utils': 7.22.5
'@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.24.7)':
dependencies:
'@babel/core': 7.24.7
'@babel/helper-plugin-utils': 7.22.5
optional: true
'@babel/plugin-syntax-jsx@7.23.3(@babel/core@7.23.5)':
dependencies:
'@babel/core': 7.23.5
@ -11487,78 +11457,36 @@ snapshots:
'@babel/core': 7.23.5
'@babel/helper-plugin-utils': 7.22.5
'@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.24.7)':
dependencies:
'@babel/core': 7.24.7
'@babel/helper-plugin-utils': 7.22.5
optional: true
'@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.23.5)':
dependencies:
'@babel/core': 7.23.5
'@babel/helper-plugin-utils': 7.22.5
'@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.24.7)':
dependencies:
'@babel/core': 7.24.7
'@babel/helper-plugin-utils': 7.22.5
optional: true
'@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.23.5)':
dependencies:
'@babel/core': 7.23.5
'@babel/helper-plugin-utils': 7.22.5
'@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.24.7)':
dependencies:
'@babel/core': 7.24.7
'@babel/helper-plugin-utils': 7.22.5
optional: true
'@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.23.5)':
dependencies:
'@babel/core': 7.23.5
'@babel/helper-plugin-utils': 7.22.5
'@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.24.7)':
dependencies:
'@babel/core': 7.24.7
'@babel/helper-plugin-utils': 7.22.5
optional: true
'@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.23.5)':
dependencies:
'@babel/core': 7.23.5
'@babel/helper-plugin-utils': 7.22.5
'@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.24.7)':
dependencies:
'@babel/core': 7.24.7
'@babel/helper-plugin-utils': 7.22.5
optional: true
'@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.23.5)':
dependencies:
'@babel/core': 7.23.5
'@babel/helper-plugin-utils': 7.22.5
'@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.24.7)':
dependencies:
'@babel/core': 7.24.7
'@babel/helper-plugin-utils': 7.22.5
optional: true
'@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.23.5)':
dependencies:
'@babel/core': 7.23.5
'@babel/helper-plugin-utils': 7.22.5
'@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.24.7)':
dependencies:
'@babel/core': 7.24.7
'@babel/helper-plugin-utils': 7.22.5
optional: true
'@babel/plugin-syntax-typescript@7.23.3(@babel/core@7.23.5)':
dependencies:
'@babel/core': 7.23.5
@ -14159,7 +14087,7 @@ snapshots:
highlight.js: 11.11.1
htmlparser2: 9.1.0
'@transfem-org/sfm-js@0.24.6':
'@transfem-org/sfm-js@0.24.8':
dependencies:
'@twemoji/parser': 15.0.0
@ -14595,7 +14523,7 @@ snapshots:
vite: 6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)
vue: 3.5.14(typescript@5.8.3)
'@vitest/coverage-v8@3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))':
'@vitest/coverage-v8@3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
@ -14609,7 +14537,7 @@ snapshots:
std-env: 3.9.0
test-exclude: 7.0.1
tinyrainbow: 2.0.0
vitest: 3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)
vitest: 3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)
transitivePeerDependencies:
- supports-color
@ -15265,20 +15193,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
babel-jest@29.7.0(@babel/core@7.24.7):
dependencies:
'@babel/core': 7.24.7
'@jest/transform': 29.7.0
'@types/babel__core': 7.20.0
babel-plugin-istanbul: 6.1.1
babel-preset-jest: 29.6.3(@babel/core@7.24.7)
chalk: 4.1.2
graceful-fs: 4.2.11
slash: 3.0.0
transitivePeerDependencies:
- supports-color
optional: true
babel-plugin-istanbul@6.1.1:
dependencies:
'@babel/helper-plugin-utils': 7.22.5
@ -15312,36 +15226,12 @@ snapshots:
'@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.23.5)
'@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.23.5)
babel-preset-current-node-syntax@1.0.1(@babel/core@7.24.7):
dependencies:
'@babel/core': 7.24.7
'@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.24.7)
'@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.24.7)
'@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.24.7)
'@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.24.7)
'@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.24.7)
'@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.24.7)
'@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.24.7)
'@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.24.7)
'@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.24.7)
'@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.24.7)
'@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.24.7)
'@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.24.7)
optional: true
babel-preset-jest@29.6.3(@babel/core@7.23.5):
dependencies:
'@babel/core': 7.23.5
babel-plugin-jest-hoist: 29.6.3
babel-preset-current-node-syntax: 1.0.1(@babel/core@7.23.5)
babel-preset-jest@29.6.3(@babel/core@7.24.7):
dependencies:
'@babel/core': 7.24.7
babel-plugin-jest-hoist: 29.6.3
babel-preset-current-node-syntax: 1.0.1(@babel/core@7.24.7)
optional: true
babel-walk@3.0.0-canary-5:
dependencies:
'@babel/types': 7.27.1
@ -21316,7 +21206,7 @@ snapshots:
ts-dedent@2.2.0: {}
ts-jest@29.3.4(@babel/core@7.24.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.24.7))(esbuild@0.25.3)(jest@29.7.0(@types/node@22.15.2))(typescript@5.8.3):
ts-jest@29.3.4(@babel/core@7.23.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.5))(esbuild@0.25.3)(jest@29.7.0(@types/node@22.15.2))(typescript@5.8.3):
dependencies:
bs-logger: 0.2.6
ejs: 3.1.10
@ -21331,10 +21221,10 @@ snapshots:
typescript: 5.8.3
yargs-parser: 21.1.1
optionalDependencies:
'@babel/core': 7.24.7
'@babel/core': 7.23.5
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
babel-jest: 29.7.0(@babel/core@7.24.7)
babel-jest: 29.7.0(@babel/core@7.23.5)
esbuild: 0.25.3
ts-map@1.0.3: {}
@ -21700,9 +21590,9 @@ snapshots:
vitest-fetch-mock@0.4.5(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)):
dependencies:
vitest: 3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)
vitest: 3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)
vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3):
vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3):
dependencies:
'@vitest/expect': 3.1.2
'@vitest/mocker': 3.1.2(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(vite@6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))

View file

@ -530,6 +530,7 @@ translationFailed: "Failed to translate note. Please try again later or contact
_processErrors:
quoteUnavailable: "Unable to process quote. This post may be missing context."
attachmentFailed: "One or more media attachments are unavailable and cannot be shown."
authorizedFetchSection: "Authorized Fetch"
authorizedFetchLabel: "Allow unsigned ActivityPub requests:"