re-implement preview groups as SkUrlPreviewGroup

This commit is contained in:
Hazelnoot 2025-05-28 13:32:51 -04:00
parent a91c0de9b5
commit 69ed5611cf
5 changed files with 337 additions and 120 deletions

View file

@ -60,6 +60,7 @@
"misskey-reversi": "workspace:*", "misskey-reversi": "workspace:*",
"moment": "^2.30.1", "moment": "^2.30.1",
"photoswipe": "5.4.4", "photoswipe": "5.4.4",
"promise-limit": "2.7.0",
"punycode.js": "2.3.1", "punycode.js": "2.3.1",
"rollup": "4.40.0", "rollup": "4.40.0",
"sanitize-html": "2.16.0", "sanitize-html": "2.16.0",

View file

@ -62,8 +62,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #icon><i class="ti ti-message-2"></i></template> <template #icon><i class="ti ti-message-2"></i></template>
<template #label>{{ i18n.ts.details }}</template> <template #label>{{ i18n.ts.details }}</template>
<div class="_gaps_s"> <div class="_gaps_s">
<Mfm :text="report.comment" :isBlock="true" :linkNavigationBehavior="'window'"/> <Mfm :text="report.comment" :parsedNodes="parsedComment" :isBlock="true" :linkNavigationBehavior="'window'" :author="report.reporter" :nyaize="false" :isAnim="false"/>
<MkUrlPreview v-for="url in urls" :key="url" :group="previewGroup" :url="url" :compact="false" :detail="false" :showAsQuote="true"/> <SkUrlPreviewGroup :sourceNodes="parsedComment" :compact="false" :detail="false" :showAsQuote="true"/>
</div> </div>
</MkFolder> </MkFolder>
@ -111,13 +111,12 @@ import RouterView from '@/components/global/RouterView.vue';
import MkTextarea from '@/components/MkTextarea.vue'; import MkTextarea from '@/components/MkTextarea.vue';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { createRouter } from '@/router.js'; import { createRouter } from '@/router.js';
import MkUrlPreview, { PreviewGroup } from '@/components/MkUrlPreview.vue';
import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm';
import { getProxiedImageUrlNullable } from '@/utility/media-proxy'; import { getProxiedImageUrlNullable } from '@/utility/media-proxy';
import InstanceInfo from '@/pages/instance-info.vue'; import InstanceInfo from '@/pages/instance-info.vue';
import { iAmAdmin } from '@/i'; import { iAmAdmin } from '@/i';
import { misskeyApi } from '@/utility/misskey-api'; import { misskeyApi } from '@/utility/misskey-api';
import AdminUser from '@/pages/admin-user.vue'; import AdminUser from '@/pages/admin-user.vue';
import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue';
const props = defineProps<{ const props = defineProps<{
report: Misskey.entities.AdminAbuseUserReportsResponse[number]; report: Misskey.entities.AdminAbuseUserReportsResponse[number];
@ -134,9 +133,7 @@ const reporterRouter = createRouter(`/admin/user/${props.report.reporterId}`);
reporterRouter.init(); reporterRouter.init();
*/ */
const parsed = computed(() => props.report.comment ? mfm.parse(props.report.comment) : null); const parsedComment = computed(() => mfm.parse(props.report.comment));
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value) : null);
const previewGroup = computed(() => new PreviewGroup()); // Lazy-load
const metaHint = ref<Misskey.entities.AdminMetaResponse | undefined>(undefined); const metaHint = ref<Misskey.entities.AdminMetaResponse | undefined>(undefined);
const targetInstanceIcon = computed(() => props.report.targetInstance?.faviconUrl const targetInstanceIcon = computed(() => props.report.targetInstance?.faviconUrl

View file

@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<div v-if="groupLockout" style="display: none"></div> <template v-if="player.url && playerEnabled">
<template v-else-if="player.url && playerEnabled">
<div <div
:class="$style.player" :class="$style.player"
:style="player.width ? `padding: ${(player.height || 0) / player.width * 100}% 0 0` : `padding: ${(player.height || 0)}px 0 0`" :style="player.width ? `padding: ${(player.height || 0) / player.width * 100}% 0 0` : `padding: ${(player.height || 0)}px 0 0`"
@ -77,7 +76,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</I18n> </I18n>
<p v-else-if="linkAttribution" :class="$style.linkAttribution"><MkEllipsis/></p> <p v-else-if="linkAttribution" :class="$style.linkAttribution"><MkEllipsis/></p>
<template v-if="showActions && !groupLockout"> <template v-if="showActions">
<div v-if="tweetId" :class="$style.action"> <div v-if="tweetId" :class="$style.action">
<MkButton :small="true" inline @click="tweetExpanded = true"> <MkButton :small="true" inline @click="tweetExpanded = true">
<i class="ti ti-brand-x"></i> {{ i18n.ts.expandTweet }} <i class="ti ti-brand-x"></i> {{ i18n.ts.expandTweet }}
@ -100,53 +99,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</template> </template>
<script lang="ts">
/**
* Links a group of previews to de-duplicate the results.
* Between all MkUrlPreview instances that share a group, each URL and Note are guaranteed to appear only once.
*/
export class PreviewGroup {
private readonly urls = new Map<string, number>();
private readonly noteIds = new Map<string, number>();
public claimUrl(url: string, componentUid: number): boolean {
return this.claim(this.urls, url, componentUid);
}
public claimNoteId(noteId: string, componentUid: number): boolean {
return this.claim(this.noteIds, noteId, componentUid);
}
private claim(group: Map<string, number>, key: string, uid: number): boolean {
const claim = group.get(key);
// Already claimed
if (claim != null && claim !== uid) {
return false;
}
group.set(key, uid);
return true;
}
public releaseUrl(url: string, componentUid: number): void {
this.release(this.urls, url, componentUid);
}
public releaseNoteId(noteId: string, componentUid: number): void {
this.release(this.noteIds, noteId, componentUid);
}
private release(group: Map<string, number>, key: string, uid: number): void {
if (group.get(key) === uid) {
group.delete(key);
}
}
}
</script>
<script lang="ts" setup> <script lang="ts" setup>
import { defineAsyncComponent, onDeactivated, onUnmounted, ref, getCurrentInstance } from 'vue'; import { defineAsyncComponent, onDeactivated, onUnmounted, ref } from 'vue';
import { url as local } from '@@/js/config.js'; import { url as local } from '@@/js/config.js';
import { versatileLang } from '@@/js/intl-const.js'; import { versatileLang } from '@@/js/intl-const.js';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
@ -165,12 +119,12 @@ import DynamicNoteSimple from '@/components/DynamicNoteSimple.vue';
import { $i } from '@/i'; import { $i } from '@/i';
import { userPage } from '@/filters/user.js'; import { userPage } from '@/filters/user.js';
const uid = getCurrentInstance()?.uid ?? -1; type SummalyResult = Awaited<ReturnType<typeof summaly>> & {
if (uid === -1) { haveNoteLocally?: boolean,
console.warn('[MkUrlPreview] Component has null instance??'); linkAttribution?: {
} userId: string,
}
type SummalyResult = Awaited<ReturnType<typeof summaly>>; };
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
url: string; url: string;
@ -179,24 +133,21 @@ const props = withDefaults(defineProps<{
showAsQuote?: boolean; showAsQuote?: boolean;
showActions?: boolean; showActions?: boolean;
skipNoteIds?: (string | undefined)[]; skipNoteIds?: (string | undefined)[];
group?: PreviewGroup; previewHint?: SummalyResult;
noteHint?: Misskey.entities.Note | null;
}>(), { }>(), {
detail: false, detail: false,
compact: false, compact: false,
showAsQuote: false, showAsQuote: false,
showActions: true, showActions: true,
skipNoteIds: undefined, skipNoteIds: undefined,
group: undefined, previewHint: undefined,
noteHint: undefined,
}); });
const emit = defineEmits<{
(event: 'loaded', preview: SummalyResult & { haveNoteLocally?: boolean } | null, note: Misskey.entities.Note | null): void;
}>();
const MOBILE_THRESHOLD = 500; const MOBILE_THRESHOLD = 500;
const isMobile = ref(deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD); const isMobile = ref(deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD);
const groupLockout = ref<boolean>(false);
const hidePreview = ref<boolean>(false); const hidePreview = ref<boolean>(false);
const maybeRelativeUrl = maybeMakeRelative(props.url, local); const maybeRelativeUrl = maybeMakeRelative(props.url, local);
const self = maybeRelativeUrl !== props.url; const self = maybeRelativeUrl !== props.url;
@ -228,13 +179,12 @@ const tweetHeight = ref(150);
const unknownUrl = ref(false); const unknownUrl = ref(false);
const theNote = ref<Misskey.entities.Note | null>(null); const theNote = ref<Misskey.entities.Note | null>(null);
const fetchingTheNote = ref(false); const fetchingTheNote = ref(false);
const preview = ref<SummalyResult & { haveNoteLocally?: boolean } | null>(null);
onDeactivated(() => { onDeactivated(() => {
playerEnabled.value = false; playerEnabled.value = false;
}); });
async function fetchNote() { async function fetchNote(initial: boolean) {
if (!props.showAsQuote) return; if (!props.showAsQuote) return;
if (!activityPub.value) return; if (!activityPub.value) return;
if (theNote.value) return; if (theNote.value) return;
@ -242,16 +192,20 @@ async function fetchNote() {
fetchingTheNote.value = true; fetchingTheNote.value = true;
try { try {
const response = await misskeyApi('ap/show', { uri: activityPub.value }); const response = (initial && props.noteHint !== undefined)
? { type: 'Note', object: props.noteHint }
: await misskeyApi('ap/show', { uri: activityPub.value });
if (response.type !== 'Note') return; if (response.type !== 'Note') return;
if (!response.object) {
activityPub.value = null;
theNote.value = null;
return;
}
const theNoteId = response['object'].id; const theNoteId = response['object'].id;
if (theNoteId && props.skipNoteIds && props.skipNoteIds.includes(theNoteId)) { if (theNoteId && props.skipNoteIds && props.skipNoteIds.includes(theNoteId)) {
hidePreview.value = true; hidePreview.value = true;
return; return;
} }
if (props.group && !props.group.claimNoteId(response['object'].id, uid)) {
groupLockout.value = true;
}
theNote.value = response['object']; theNote.value = response['object'];
} catch (err) { } catch (err) {
if (_DEV_) { if (_DEV_) {
@ -272,22 +226,16 @@ if (requestUrl.hostname === 'twitter.com' || requestUrl.hostname === 'mobile.twi
if (m) tweetId.value = m[1]; if (m) tweetId.value = m[1];
} }
// This is now handled on the backend
/*
if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) { if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) {
requestUrl.hostname = 'www.youtube.com'; requestUrl.hostname = 'www.youtube.com';
} }
requestUrl.hash = ''; requestUrl.hash = '';
*/
const refresh = (withFetch = false) => { function refresh(withFetch = false, initial = false) {
// Release URL/noteID when refreshing, in case it changes.
// (Could happen since redirects are allowed.)
if (preview.value && props.group) {
props.group.releaseUrl(preview.value.url, uid);
}
if (theNote.value && props.group) {
props.group.releaseNoteId(theNote.value.id, uid);
}
const params = new URLSearchParams({ const params = new URLSearchParams({
url: requestUrl.href, url: requestUrl.href,
lang: versatileLang, lang: versatileLang,
@ -297,24 +245,21 @@ const refresh = (withFetch = false) => {
} }
const headers = $i ? { Authorization: `Bearer ${$i.token}` } : undefined; const headers = $i ? { Authorization: `Bearer ${$i.token}` } : undefined;
return fetching.value ??= window.fetch(`/url?${params.toString()}`, { headers }) const fetchPromise: Promise<SummalyResult | null> = (initial && props.previewHint)
.then(res => { ? Promise.resolve(props.previewHint)
if (!res.ok) { : window.fetch(`/url?${params.toString()}`, { headers })
if (_DEV_) { .then(res => {
console.warn(`[HTTP${res.status}] Failed to fetch url preview`); if (!res.ok) {
if (_DEV_) {
console.warn(`[HTTP${res.status}] Failed to fetch url preview`);
}
return null;
} }
return null;
}
return res.json(); return res.json();
}) });
.then(async (info: SummalyResult & { return fetching.value ??= fetchPromise
haveNoteLocally?: boolean, .then(async (info: SummalyResult | null) => {
linkAttribution?: {
userId: string,
}
} | null) => {
preview.value = info;
unknownUrl.value = info == null; unknownUrl.value = info == null;
title.value = info?.title ?? null; title.value = info?.title ?? null;
description.value = info?.description ?? null; description.value = info?.description ?? null;
@ -329,7 +274,6 @@ const refresh = (withFetch = false) => {
}; };
sensitive.value = info?.sensitive ?? false; sensitive.value = info?.sensitive ?? false;
activityPub.value = info?.activityPub ?? null; activityPub.value = info?.activityPub ?? null;
groupLockout.value = info != null && props.group != null && !props.group.claimUrl(info.url, uid);
linkAttribution.value = info?.linkAttribution ?? null; linkAttribution.value = info?.linkAttribution ?? null;
if (linkAttribution.value) { if (linkAttribution.value) {
try { try {
@ -343,14 +287,13 @@ const refresh = (withFetch = false) => {
theNote.value = null; theNote.value = null;
if (info?.haveNoteLocally) { if (info?.haveNoteLocally) {
await fetchNote(); await fetchNote(initial);
} }
}) })
.finally(() => { .finally(() => {
fetching.value = null; fetching.value = null;
emit('loaded', preview.value, theNote.value);
}); });
}; }
function adjustTweetHeight(message: MessageEvent) { function adjustTweetHeight(message: MessageEvent) {
if (message.origin !== 'https://platform.twitter.com') return; if (message.origin !== 'https://platform.twitter.com') return;
@ -375,17 +318,10 @@ window.addEventListener('message', adjustTweetHeight);
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('message', adjustTweetHeight); window.removeEventListener('message', adjustTweetHeight);
if (preview.value && props.group) {
props.group.releaseUrl(preview.value.url, uid);
}
if (theNote.value && props.group) {
props.group.releaseNoteId(theNote.value.id, uid);
}
}); });
// Load initial data // Load initial data
refresh(); refresh(false, true);
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

@ -0,0 +1,280 @@
<!--
SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div v-if="isRefreshing">
<MkLoading :class="$style.loading"></MkLoading>
</div>
<template v-else>
<MkUrlPreview
v-for="preview of urlPreviews"
:key="preview.url"
:url="preview.url"
:previewHint="preview"
:noteHint="preview.note"
:detail="detail"
:compact="compact"
:showAsQuote="showAsQuote"
:showActions="showActions"
:skipNoteIds="skipNoteIds"
></MkUrlPreview>
</template>
</template>
<script setup lang="ts">
import * as Misskey from 'misskey-js';
import * as mfm from '@transfem-org/sfm-js';
import { computed, ref, watch } from 'vue';
import { versatileLang } from '@@/js/intl-const';
import promiseLimit from 'promise-limit';
import type { summaly } from '@misskey-dev/summaly';
import { extractPreviewUrls } from '@/utility/extract-preview-urls';
import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm';
import { $i } from '@/i';
import { misskeyApi } from '@/utility/misskey-api';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
type Summary = Awaited<ReturnType<typeof summaly>> & {
haveNoteLocally?: boolean;
note?: Misskey.entities.Note | null;
};
const props = withDefaults(defineProps<{
sourceUrls?: string[];
sourceNodes?: mfm.MfmNode[];
sourceText?: string;
sourceNote?: Misskey.entities.Note;
detail?: boolean;
compact?: boolean;
showAsQuote?: boolean;
showActions?: boolean;
skipNoteIds?: string[];
}>(), {
sourceUrls: undefined,
sourceText: undefined,
sourceNodes: undefined,
sourceNote: undefined,
detail: undefined,
compact: undefined,
showAsQuote: undefined,
showActions: undefined,
skipNoteIds: () => [],
});
const urlPreviews = ref<Summary[]>([]);
const urls = computed<string[]>(() => {
if (props.sourceUrls) {
return props.sourceUrls;
}
// sourceNodes > sourceText > sourceNote
const source =
props.sourceNodes ??
(props.sourceText ? mfm.parse(props.sourceText) : null) ??
(props.sourceNote?.text ? mfm.parse(props.sourceNote.text) : null);
if (source) {
if (props.sourceNote) {
return extractPreviewUrls(props.sourceNote, source);
} else {
return extractUrlFromMfm(source);
}
}
return [];
});
const isRefreshing = ref<Promise<void> | false>(false);
const cachedNotes = ref(new Map<string, Misskey.entities.Note | null>());
const cachedPreviews = ref(new Map<string, Summary | null>());
/**
* Refreshes the group.
* Calls are automatically de-duplicated.
*/
function refresh(): Promise<void> {
if (isRefreshing.value) return isRefreshing.value;
const promise = doRefresh();
promise.finally(() => isRefreshing.value = false);
isRefreshing.value = promise;
return promise;
}
/**
* Refreshes the group.
* Don't call this directly - use refresh() instead!
*/
async function doRefresh(): Promise<void> {
let previews = await fetchPreviews();
// Remove duplicates
previews = deduplicatePreviews(previews);
// Remove any with hidden notes
previews = previews.filter(preview => !preview.note || !props.skipNoteIds.includes(preview.note.id));
urlPreviews.value = previews;
}
async function fetchPreviews(): Promise<Summary[]> {
const noteLimiter = promiseLimit<Misskey.entities.Note | null>(2);
const summaryLimiter = promiseLimit<Summary | null>(5);
const summaries = await Promise.all(urls.value.map(url =>
summaryLimiter(async () => {
return await fetchPreview(url);
}).then(async (summary) => {
if (summary && props.showAsQuote && summary.activityPub && summary.haveNoteLocally) {
// Have to pull this out to make TS happy
const noteUri = summary.activityPub;
summary.note = await noteLimiter(async () => {
return await fetchNote(noteUri);
});
}
return summary;
})));
return summaries.filter((preview): preview is Summary => preview != null);
}
async function fetchPreview(url: string): Promise<Summary | null> {
const cached = cachedPreviews.value.get(url);
if (cached) {
return cached;
}
const headers = $i ? { Authorization: `Bearer ${$i.token}` } : undefined;
const params = new URLSearchParams({ url, lang: versatileLang });
const res = await window.fetch(`/url?${params.toString()}`, { headers }).catch(() => null);
if (res?.ok) {
// Success - got the summary
const summary: Summary = await res.json();
cachedPreviews.value.set(url, summary);
if (summary.url !== url) {
cachedPreviews.value.set(summary.url, summary);
}
return summary;
}
// Failed, blocked, or not found
cachedPreviews.value.set(url, null);
return null;
}
async function fetchNote(noteUri: string): Promise<Misskey.entities.Note | null> {
const cached = cachedNotes.value.get(noteUri);
if (cached) {
return cached;
}
const response = await misskeyApi('ap/show', { uri: noteUri }).catch(() => null);
if (response && response.type === 'Note') {
const note = response['object'];
// Success - got the note
cachedNotes.value.set(noteUri, note);
if (note.uri && note.uri !== noteUri) {
cachedNotes.value.set(note.uri, note);
}
return note;
}
// Failed, blocked, or not found
cachedNotes.value.set(noteUri, null);
return null;
}
function deduplicatePreviews(previews: Summary[]): Summary[] {
// eslint-disable-next-line no-param-reassign
previews = previews
// Remove any previews with duplicate URL
.filter((preview, index) => !previews.some((p, i) => {
// Skip the current preview (don't count self as duplicate).
if (p === preview) return false;
// Skip differing URLs (not duplicate).
if (p.url !== preview.url) return false;
// Skip if we have AP and the other doesn't
if (preview.activityPub && !p.activityPub) return false;
// Skip later previews (keep the earliest instance)...
// ...but only if we have AP or the later one doesn't.
if (i > index && (preview.activityPub || !p.activityPub)) return false;
// If we get here, then "preview" is a duplicate of "p" and should be skipped.
return true;
}));
// eslint-disable-next-line no-param-reassign
previews = previews
// Remove any previews with duplicate AP
.filter((preview, index) => !previews.some((p, i) => {
// Skip the current preview (don't count self as duplicate).
if (p === preview) return false;
// Skip if we don't have AP
if (!preview.activityPub) return false;
// Skip if other does not have AP
if (!p.activityPub) return false;
// Skip differing URLs (not duplicate).
if (p.activityPub !== preview.activityPub) return false;
// Skip later previews (keep the earliest instance)
if (i > index) return false;
// If we get here, then "preview" is a duplicate of "p" and should be skipped.
return true;
}));
// eslint-disable-next-line no-param-reassign
previews = previews
// Remove any previews with duplicate note
.filter((preview, index) => !previews.some((p, i) => {
// Skip the current preview (don't count self as duplicate).
if (p === preview) return false;
// Skip if we don't have a note
if (!preview.note) return false;
// Skip if other does not have a note
if (!p.note) return false;
// Skip differing notes (not duplicate).
if (p.note.id !== preview.note.id) return false;
// Skip later previews (keep the earliest instance)
if (i > index) return false;
// If we get here, then "preview" is a duplicate of "p" and should be skipped.
return true;
}));
return previews;
}
// Kick everything off, and watch for changes.
watch(
[urls, () => props.showAsQuote, () => props.skipNoteIds],
() => refresh(),
{ immediate: true },
);
</script>
<style module lang="scss">
.loading {
box-shadow: 0 0 0 1px var(--MI_THEME-divider);
border-radius: var(--MI-radius-sm);
}
</style>

17
pnpm-lock.yaml generated
View file

@ -847,6 +847,9 @@ importers:
photoswipe: photoswipe:
specifier: 5.4.4 specifier: 5.4.4
version: 5.4.4 version: 5.4.4
promise-limit:
specifier: 2.7.0
version: 2.7.0
punycode.js: punycode.js:
specifier: 2.3.1 specifier: 2.3.1
version: 2.3.1 version: 2.3.1
@ -1013,7 +1016,7 @@ importers:
version: 8.31.0(eslint@9.25.1)(typescript@5.8.3) version: 8.31.0(eslint@9.25.1)(typescript@5.8.3)
'@vitest/coverage-v8': '@vitest/coverage-v8':
specifier: 3.1.2 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)(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(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))
'@vue/compiler-core': '@vue/compiler-core':
specifier: 3.5.14 specifier: 3.5.14
version: 3.5.14 version: 3.5.14
@ -1082,7 +1085,7 @@ importers:
version: 1.0.3 version: 1.0.3
vitest: vitest:
specifier: 3.1.2 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)(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(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-fetch-mock: vitest-fetch-mock:
specifier: 0.4.5 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)) 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))
@ -1209,7 +1212,7 @@ importers:
version: 8.31.0(eslint@9.25.1)(typescript@5.8.3) version: 8.31.0(eslint@9.25.1)(typescript@5.8.3)
'@vitest/coverage-v8': '@vitest/coverage-v8':
specifier: 3.1.2 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)(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(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))
'@vue/runtime-core': '@vue/runtime-core':
specifier: 3.5.14 specifier: 3.5.14
version: 3.5.14 version: 3.5.14
@ -14749,7 +14752,7 @@ snapshots:
vite: 6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.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) 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)(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(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: dependencies:
'@ampproject/remapping': 2.3.0 '@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2 '@bcoe/v8-coverage': 1.0.2
@ -14763,7 +14766,7 @@ snapshots:
std-env: 3.9.0 std-env: 3.9.0
test-exclude: 7.0.1 test-exclude: 7.0.1
tinyrainbow: 2.0.0 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)(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)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@ -21931,9 +21934,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)): 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: dependencies:
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): 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: dependencies:
'@vitest/expect': 3.1.2 '@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)) '@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))