Merge branch 'main' into fix/bluesky-external-card-thumbnail

This commit is contained in:
dangered wolf
2026-09-27 10:02:21 -07:00
committed by GitHub
15 changed files with 290 additions and 44 deletions
@@ -46,12 +46,12 @@ Paths use `/profile/` before the handle, matching other FxBluesky profile URLs.
All parameters are optional.
| Parameter | Applies to | Description |
| ------------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `count` | Timeline and media | Number of items to fetch, **1–100** (default **90**). |
| `with_replies` or `withReplies` | Timeline only | Include reply posts. |
| `safe` | Timeline and media | Hides posts (and quoted bodies) marked sensitive. |
| `lang` or `language` | Timeline and media | Requests for automatic translation of content when available. Currently it's a bit iffy whether it works or not, so don't be surprised if things aren't translated just yet. |
| Parameter | Applies to | Description |
| ------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `count` | Timeline and media | Number of items to fetch, **1–100** (default **90**). |
| `with_replies` or `withReplies` | Timeline and Bluesky media | Include reply posts. Bluesky media feeds exclude replies by default. |
| `safe` | Timeline and media | Hides posts (and quoted bodies) marked sensitive. |
| `lang` or `language` | Timeline and media | Requests for automatic translation of content when available. Currently it's a bit iffy whether it works or not, so don't be surprised if things aren't translated just yet. |
Example (X):
@@ -17,6 +17,12 @@ https://d.fxtwitter.com/user/status/123456789/photo/3
https://d.fixupx.com/user/status/123456789/video/2
```
For Bluesky image posts, use the same `/photo/N` suffix:
```
https://fxbsky.app/profile/user.bsky.social/post/abc123/photo/2
```
<img
src="/guide/readme/selectphoto.png"
alt="Discord embed showing a specific photo or video from a post"
+24 -8
View File
@@ -25,13 +25,13 @@
"@aws-sdk/client-s3": "^3.1136.0",
"@aws-sdk/s3-request-presigner": "^3.1136.0",
"@cloudflare/vitest-pool-workers": "^0.22.0",
"@cloudflare/workers-types": "^5.20260919.1",
"@cloudflare/workers-types": "^5.20260920.1",
"@eslint/js": "^10.0.1",
"@microsoft/eslint-formatter-sarif": "^3.1.0",
"@sentry/esbuild-plugin": "^5.4.0",
"@typescript-eslint/eslint-plugin": "^8.70.0",
"@typescript-eslint/parser": "^8.70.0",
"dotenv": "^17.4.2",
"dotenv": "^18.0.1",
"eslint": "^10.10.0",
"eslint-config-prettier": "^10.1.8",
"eslint-config-typescript": "^3.0.0",
@@ -881,9 +881,9 @@
}
},
"node_modules/@cloudflare/workers-types": {
"version": "5.20260919.1",
"resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260919.1.tgz",
"integrity": "sha512-xUdKJoTHhNcXwnBrfrM1STkzm3oSUc9cRVzP8fO5NyJZ/UtnDnWZHDk9z8IkTnbC2WRWFAS87HICmqZhbHDvQw==",
"version": "5.20260920.1",
"resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260920.1.tgz",
"integrity": "sha512-wP7wbKqfQjFRGhE/tvu7bwvmBBTqJjvrETTvNzF4u5oolVEXt0gy6JxbqcUC2Khz0Cy+M/q7rhOOODng8VpnPw==",
"dev": true,
"license": "MIT OR Apache-2.0"
},
@@ -2962,6 +2962,19 @@
}
}
},
"node_modules/@sentry/bundler-plugins/node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/@sentry/cli": {
"version": "2.58.6",
"resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.6.tgz",
@@ -4499,11 +4512,14 @@
}
},
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"version": "18.0.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.1.tgz",
"integrity": "sha512-0eR4m4D/jH5eaI3evo2ZqqMii5mrTCR12v12VChdie6O1gPHs7XAjzTep0CWc1Bd+oNx3vVfbi734dK4zghigw==",
"dev": true,
"license": "BSD-2-Clause",
"bin": {
"dotenv": "dist/index.cjs"
},
"engines": {
"node": ">=12"
},
+2 -2
View File
@@ -36,13 +36,13 @@
"@aws-sdk/client-s3": "^3.1136.0",
"@aws-sdk/s3-request-presigner": "^3.1136.0",
"@cloudflare/vitest-pool-workers": "^0.22.0",
"@cloudflare/workers-types": "^5.20260919.1",
"@cloudflare/workers-types": "^5.20260920.1",
"@eslint/js": "^10.0.1",
"@microsoft/eslint-formatter-sarif": "^3.1.0",
"@sentry/esbuild-plugin": "^5.4.0",
"@typescript-eslint/eslint-plugin": "^8.70.0",
"@typescript-eslint/parser": "^8.70.0",
"dotenv": "^17.4.2",
"dotenv": "^18.0.1",
"eslint": "^10.10.0",
"eslint-config-prettier": "^10.1.8",
"eslint-config-typescript": "^3.0.0",
@@ -143,6 +143,7 @@ async function blueskyAuthorFeedSearchPage(
filter: BlueskyAuthorFeedFilter;
language?: string;
groupThreads?: boolean;
excludeReplies?: boolean;
},
host: BlueskyBuildHost
): Promise<APISearchResultsBluesky | APIGroupedSearchResultsBluesky> {
@@ -163,7 +164,8 @@ async function blueskyAuthorFeedSearchPage(
return { code: 500, results: [], cursor: { top: null, bottom: null } };
}
const feed = result.data.feed ?? [];
const rawFeed = result.data.feed ?? [];
const feed = options.excludeReplies ? rawFeed.filter(item => !item.post?.record?.reply) : rawFeed;
const nextCursor = result.data.cursor ?? null;
const results = options.groupThreads
? await feedViewPostsToGroupedTimeline(host, feed, options.language)
@@ -206,7 +208,7 @@ export const blueskyProfileStatusesAPI = async (
export const blueskyProfileMediaAPI = async (
actor: string,
options: { count: number; cursor: string | null; language?: string },
options: { count: number; cursor: string | null; language?: string; withReplies?: boolean },
host: BlueskyBuildHost
): Promise<APISearchResultsBluesky> =>
blueskyAuthorFeedSearchPage(
@@ -215,7 +217,8 @@ export const blueskyProfileMediaAPI = async (
count: options.count,
cursor: options.cursor,
filter: 'posts_with_media',
language: options.language
language: options.language,
excludeReplies: options.withReplies === false
},
host
);
@@ -323,6 +326,7 @@ export const blueskyProfileMediaAPIPaginated = async (
actor: string,
maxTotal: number,
host: BlueskyBuildHost,
withReplies = false,
language?: string
): Promise<APISearchResultsBluesky> => {
const target = Math.min(BLUESKY_PROFILE_FEED_TARGET_CAP, Math.max(1, maxTotal));
@@ -337,7 +341,7 @@ export const blueskyProfileMediaAPIPaginated = async (
pages += 1;
const page = await blueskyProfileMediaAPI(
actor,
{ count: BLUESKY_PROFILE_FEED_PER_PAGE, cursor, language },
{ count: BLUESKY_PROFILE_FEED_PER_PAGE, cursor, language, withReplies },
host
);
@@ -358,10 +362,6 @@ export const blueskyProfileMediaAPIPaginated = async (
anySuccessfulPage = true;
lastCursors = page.cursor;
if (page.results.length === 0) {
break;
}
for (const r of page.results) {
if (seenIds.has(r.id)) continue;
seenIds.add(r.id);
+7
View File
@@ -260,6 +260,13 @@ export const handleStatus = async (
console.log(api);
return returnError(c, Strings.ERROR_API_FAIL);
}
if (
provider === DataProvider.Bluesky &&
mediaNumber &&
!status.media?.photos?.[mediaNumber - 1]
) {
return c.text('Photo not found', 404);
}
/* Should sensitive statuses be allowed Instant View? */
let useIV = false;
+12
View File
@@ -7,6 +7,18 @@ export const getPbsProxyDomain = (): string | null =>
export const shouldProxyTelegramPbsPhotos = (isTelegram: boolean): boolean =>
isTelegram && experimentCheck(Experiment.TELEGRAM_PBS_PROXY, !!getPbsProxyDomain());
export const formatTelegramTwitterPostPhotoUrl = (url: string, isTelegram: boolean): string => {
if (!isTelegram || !url) return url;
try {
const u = new URL(url);
if (u.hostname !== 'pbs.twimg.com' || !u.pathname.includes('/media/')) return url;
u.searchParams.set('name', 'large');
return u.toString();
} catch {
return url;
}
};
export const proxyTwitterPostPhotoUrl = (url: string, enabled: boolean): string => {
const domain = getPbsProxyDomain();
if (!enabled || !domain || !url) return url;
+14
View File
@@ -19,7 +19,21 @@ bluesky.use(trimTrailingSlash());
bluesky.get('/owoembed', oembed);
bluesky.get('/api/v1/statuses/:snowcode', activityRequest);
bluesky.get('/:prefix/:handle/post/:id', blueskyStatusRequest);
bluesky.get('/:prefix/:handle/post/:id/photo/:mediaNumber{[1-9][0-9]*}', blueskyStatusRequest);
bluesky.get(
'/:prefix/:handle/post/:id/photo/:mediaNumber{[1-9][0-9]*}/:language',
blueskyStatusRequest
);
bluesky.get('/profile/:handle/post/:id', blueskyStatusRequest);
bluesky.get('/profile/:handle/post/:id/photo/:mediaNumber{[1-9][0-9]*}', blueskyStatusRequest);
bluesky.get(
'/profile/:handle/post/:id/photo/:mediaNumber{[1-9][0-9]*}/:language',
blueskyStatusRequest
);
bluesky.get(
'/:prefix/profile/:handle/post/:id/photo/:mediaNumber{[1-9][0-9]*}/:language',
blueskyStatusRequest
);
bluesky.get('/:prefix/profile/:handle/post/:id/:language', blueskyStatusRequest);
bluesky.get('/profile/:handle/post/:id/:language', blueskyStatusRequest);
bluesky.get('/profile/:handle/feed.xml', profileFeedRssBluesky);
+2 -11
View File
@@ -49,16 +49,6 @@ function parseFeedQuery(c: Context): {
};
}
function parseMediaFeedQuery(c: Context): { count: number; safe: boolean; language?: string } {
const q = c.req.query();
const language = feedRequestLanguage(c);
return {
count: clampCount(q.count),
safe: isParamTruthy(q.safe),
...(language ? { language } : {})
};
}
function feedAuthorName(handle: string, results: APIBlueskyStatus[]): string {
const screen = handle.replace(/^@/, '');
const fromPost = results.find(s => s.author?.name?.trim())?.author?.name?.trim();
@@ -157,12 +147,13 @@ async function serveFeed(
q.language
);
} else {
const q = parseMediaFeedQuery(c);
const q = parseFeedQuery(c);
omitSensitive = q.safe;
apiResult = await blueskyProfileMediaAPIPaginated(
handle,
q.count,
blueskyBuildHostFromContext(c),
q.withReplies,
q.language
);
}
+2 -2
View File
@@ -9,7 +9,7 @@ import { isHorizonEmbedParam } from '../../twitter/router';
export const blueskyStatusRequest = async (c: Context) => {
console.log('bluesky status request!!!');
const { handle, id, language } = c.req.param();
const { handle, id, mediaNumber, language } = c.req.param();
const actualId = id.match(/\w+/g)?.[0] ?? '';
const userAgent = c.req.header('User-Agent') || '';
@@ -73,7 +73,7 @@ export const blueskyStatusRequest = async (c: Context) => {
c,
actualId,
handle,
undefined, //mediaNumber ? parseInt(mediaNumber) : undefined,
mediaNumber ? parseInt(mediaNumber, 10) : undefined,
userAgent,
flags,
language,
+15 -6
View File
@@ -10,7 +10,11 @@ import type { APIStatusTombstone, APITwitterStatus } from '../realms/api/schemas
import { isTombstone } from '../helpers/tombstone';
import { getVideoTranscodeDomain, getVideoTranscodeDomainBluesky } from '../helpers/transcode';
import { experimentCheck, Experiment } from '../experiments';
import { proxyTwitterPostPhotoUrl, shouldProxyTelegramPbsPhotos } from '../helpers/pbsProxy';
import {
formatTelegramTwitterPostPhotoUrl,
proxyTwitterPostPhotoUrl,
shouldProxyTelegramPbsPhotos
} from '../helpers/pbsProxy';
/**
* Check if the tweet text is essentially just an article URL with no meaningful additional content.
@@ -69,7 +73,7 @@ const populateUserLinks = (text: string, status: APIStatus): string => {
return text;
};
const generateStatusMedia = (status: APIStatus, proxyPbs: boolean): string => {
const generateStatusMedia = (status: APIStatus, proxyPbs: boolean, isTelegram: boolean): string => {
let media = '';
if (status.media?.all?.length) {
status.media.all.forEach(mediaItem => {
@@ -86,6 +90,9 @@ const generateStatusMedia = (status: APIStatus, proxyPbs: boolean): string => {
case 'photo':
// eslint-disable-next-line no-case-declarations
const { altText } = mediaItem as APIPhoto;
if (status.provider === DataProvider.Twitter) {
url = formatTelegramTwitterPostPhotoUrl(url, isTelegram);
}
url = proxyTwitterPostPhotoUrl(url, proxyPbs);
media += `<img src="{url}" {altText}/>`.format({
altText: altText ? `alt="${altText}"` : '',
@@ -332,7 +339,8 @@ const generateStatus = (
language: string,
isQuote = false,
authorActionType: AuthorActionType | null,
proxyPbs: boolean
proxyPbs: boolean,
isTelegram: boolean
): string => {
if (isTombstone(status)) {
const inner = `<i>${sanitizeText(status.message)}</i>`;
@@ -383,7 +391,7 @@ const generateStatus = (
<!-- Embed article (if applicable) -->
${articleHtml || notApplicableComment}
<!-- Embed media -->
${generateStatusMedia(status, proxyPbs)}
${generateStatusMedia(status, proxyPbs, isTelegram)}
<!-- Translated text (if applicable) -->
${translatedText ? translatedText : notApplicableComment}
<!-- Inline author (if applicable) -->
@@ -399,7 +407,7 @@ const generateStatus = (
!isQuote && status.quote
? isTombstone(status.quote)
? `<blockquote><i>${sanitizeText(status.quote.message)}</i></blockquote>`
: generateStatus(status.quote, author, language, true, null, proxyPbs)
: generateStatus(status.quote, author, language, true, null, proxyPbs, isTelegram)
: notApplicableComment
}`.format({
quoteHeader: isQuote
@@ -524,7 +532,8 @@ export const renderInstantView = (properties: RenderProperties): ResponseInstruc
properties?.targetLanguage ?? 'en',
false,
authorAction,
proxyPbs
proxyPbs,
isTelegram
);
})
.join('')}
+92
View File
@@ -0,0 +1,92 @@
import { afterEach, expect, test, vi } from 'vitest';
import { app } from '../src/worker';
import authorFeed from './fixtures/bluesky/author-feed.json';
afterEach(() => vi.restoreAllMocks());
test.each(['media.xml', 'media.atom.xml'])(
'%s excludes replies by default and includes them with with_replies',
async format => {
const makePost = (rkey: string, reply = false) => {
const entry = structuredClone(authorFeed.feed[0]);
entry.post.uri = `at://did:plc:test111/app.bsky.feed.post/${rkey}`;
entry.post.record.text = rkey;
if (reply) {
Object.assign(entry.post.record, {
reply: { parent: { uri: 'at://did:plc:test111/app.bsky.feed.post/parent' } }
});
}
return entry;
};
vi.spyOn(globalThis, 'fetch').mockImplementation(async input => {
const url = new URL(typeof input === 'string' ? input : input.url);
if (url.pathname.endsWith('getAuthorFeed')) {
const secondPage = url.searchParams.has('cursor');
return Response.json(
secondPage
? { feed: [makePost('next')] }
: { feed: [makePost('reply', true), makePost('main')], cursor: 'next-page' }
);
}
if (url.pathname.endsWith('getProfiles')) {
return Response.json({ profiles: [authorFeed.feed[0].post.author] });
}
throw new Error(`Unexpected fetch: ${url}`);
});
const feedUrl = `https://fxbsky.app/profile/author.test/${format}?count=2`;
const withoutReplies = await app.request(feedUrl);
expect(withoutReplies.status).toBe(200);
const defaultXml = await withoutReplies.text();
expect(defaultXml).toContain('/post/main');
expect(defaultXml).toContain('/post/next');
expect(defaultXml).not.toContain('/post/reply');
const withReplies = await app.request(`${feedUrl}&with_replies=1`);
expect(withReplies.status).toBe(200);
const repliesXml = await withReplies.text();
expect(repliesXml).toContain('/post/reply');
expect(repliesXml).toContain('/post/main');
expect(repliesXml).not.toContain('/post/next');
}
);
test('media feed skips a full page of replies, including handle-based parent URIs', async () => {
const makePost = (rkey: string, reply: boolean) => {
const entry = structuredClone(authorFeed.feed[0]);
entry.post.uri = `at://did:plc:test111/app.bsky.feed.post/${rkey}`;
entry.post.record.text = rkey;
if (reply) {
Object.assign(entry.post.record, {
reply: { parent: { uri: 'at://author.test/app.bsky.feed.post/parent' } }
});
}
return entry;
};
vi.spyOn(globalThis, 'fetch').mockImplementation(async input => {
const url = new URL(typeof input === 'string' ? input : input.url);
if (url.pathname.endsWith('getAuthorFeed')) {
return Response.json(
url.searchParams.has('cursor')
? { feed: [makePost('main', false)] }
: { feed: [makePost('reply', true)], cursor: 'next-page' }
);
}
throw new Error(`Unexpected fetch: ${url}`);
});
const feedUrl = 'https://fxbsky.app/profile/author.test/media.xml?count=1';
const withoutReplies = await app.request(feedUrl);
expect(withoutReplies.status).toBe(200);
const defaultXml = await withoutReplies.text();
expect(defaultXml).toContain('/post/main');
expect(defaultXml).not.toContain('/post/reply');
const withReplies = await app.request(`${feedUrl}&with_replies=1`);
expect(withReplies.status).toBe(200);
const repliesXml = await withReplies.text();
expect(repliesXml).toContain('/post/reply');
expect(repliesXml).not.toContain('/post/main');
});
+73
View File
@@ -0,0 +1,73 @@
import { afterEach, expect, test, vi } from 'vitest';
import { app } from '../src/worker';
import threadMultiImage from './fixtures/bluesky/thread-multi-image.json';
afterEach(() => vi.restoreAllMocks());
function mockMultiImageThread() {
vi.spyOn(globalThis, 'fetch').mockImplementation(async input => {
const url = typeof input === 'string' ? input : input.url;
if (url.includes('app.bsky.feed.getPostThread')) return Response.json(threadMultiImage);
if (url.includes('app.bsky.actor.getProfiles')) return Response.json({ profiles: [] });
if (url.includes('mosaic.fxbsky.app')) return Response.json({ ok: true });
throw new Error(`Unexpected fetch: ${url}`);
});
}
test.each([
['photo/1', 'https://cdn.bsky.app/full1'],
['photo/2', 'https://cdn.bsky.app/full2'],
['photo/2/en', 'https://cdn.bsky.app/full2']
])('Bluesky /%s embeds only the selected image', async (path, expected) => {
mockMultiImageThread();
const response = await app.request(`https://fxbsky.app/profile/pics.test/post/rkeypics/${path}`, {
headers: { 'User-Agent': 'Twitterbot' }
});
expect(response.status).toBe(200);
const html = await response.text();
expect(html).toContain(`<meta property="og:image" content="${expected}"`);
});
test('Bluesky prefixed localized photo URL embeds the selected image', async () => {
mockMultiImageThread();
const response = await app.request(
'https://fxbsky.app/bsky/profile/pics.test/post/rkeypics/photo/2/en',
{ headers: { 'User-Agent': 'Twitterbot' } }
);
expect(response.status).toBe(200);
const html = await response.text();
expect(html).toContain('<meta property="og:image" content="https://cdn.bsky.app/full2"');
expect(html).not.toContain('<meta property="og:image" content="https://cdn.bsky.app/full1"');
});
test('Bluesky /photo/N rejects an unavailable image', async () => {
mockMultiImageThread();
const response = await app.request(
'https://fxbsky.app/profile/pics.test/post/rkeypics/photo/99',
{
headers: { 'User-Agent': 'Twitterbot' }
}
);
expect(response.status).toBe(404);
});
test('Bluesky photo selection reaches the Discord activity response', async () => {
mockMultiImageThread();
const response = await app.request('https://fxbsky.app/profile/pics.test/post/rkeypics/photo/2', {
headers: { 'User-Agent': 'Discordbot/2.0' }
});
expect(response.status).toBe(200);
const html = await response.text();
const activityHref = html.match(
/<link href='([^']+)' rel='alternate' type='application\/activity\+json'/
)?.[1];
expect(activityHref).toBeDefined();
const snowcode = activityHref?.split('/').at(-1);
const activity = await app.request(`https://fxbsky.app/api/v1/statuses/${snowcode}`, {
headers: { 'User-Agent': 'Discordbot/2.0' }
});
expect(activity.status).toBe(200);
const body = await activity.text();
expect(body).toContain('https://cdn.bsky.app/full2');
expect(body).not.toContain('https://cdn.bsky.app/full1');
});
+25 -1
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest';
import { proxyTwitterPostPhotoUrl } from '../src/helpers/pbsProxy';
import {
formatTelegramTwitterPostPhotoUrl,
proxyTwitterPostPhotoUrl
} from '../src/helpers/pbsProxy';
describe('proxyTwitterPostPhotoUrl', () => {
const mediaUrl = 'https://pbs.twimg.com/media/abc.jpg?name=orig';
@@ -24,3 +27,24 @@ describe('proxyTwitterPostPhotoUrl', () => {
expect(proxyTwitterPostPhotoUrl(mediaUrl, false)).toBe(mediaUrl);
});
});
describe('formatTelegramTwitterPostPhotoUrl', () => {
const mediaUrl = 'https://pbs.twimg.com/media/abc.jpg?name=orig&format=jpg';
const profileUrl = 'https://pbs.twimg.com/profile_images/1/normal.jpg';
it('requests a Telegram-friendly size for X post photos', () => {
expect(formatTelegramTwitterPostPhotoUrl(mediaUrl, true)).toBe(
'https://pbs.twimg.com/media/abc.jpg?name=large&format=jpg'
);
});
it('leaves profile images and other hosts unchanged', () => {
expect(formatTelegramTwitterPostPhotoUrl(profileUrl, true)).toBe(profileUrl);
const blueskyUrl = 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:abc@jpeg';
expect(formatTelegramTwitterPostPhotoUrl(blueskyUrl, true)).toBe(blueskyUrl);
});
it('leaves non-Telegram requests unchanged', () => {
expect(formatTelegramTwitterPostPhotoUrl(mediaUrl, false)).toBe(mediaUrl);
});
});
+2
View File
@@ -57,6 +57,8 @@ test('Telegram Instant View remains enabled for multi-photo mosaic posts', async
expect(isInstantViewHtml(html)).toBe(true);
expect(html.match(/<img /g)?.length).toBeGreaterThanOrEqual(3);
expect(html.match(/name=large/g)?.length).toBeGreaterThanOrEqual(3);
expect(html).not.toContain('name=orig');
});
test('i. prefix still forces Instant View for a single video', async () => {