diff --git a/app/components/DemoStage.vue b/app/components/DemoStage.vue index e0acb53c..c8ab0943 100644 --- a/app/components/DemoStage.vue +++ b/app/components/DemoStage.vue @@ -56,6 +56,7 @@ const liveUrl = computed(() => live && !demo.video ? demo.liveUrl : null) :src="liveUrl" sandbox="allow-scripts" :title="`Live output of /${demo.name}`" + loading="lazy" class="demo-stage__frame" /> diff --git a/app/components/WeeklyEmailPreview.vue b/app/components/WeeklyEmailPreview.vue index e2073b1a..57f4c636 100644 --- a/app/components/WeeklyEmailPreview.vue +++ b/app/components/WeeklyEmailPreview.vue @@ -8,8 +8,11 @@ import type { DigestDemoResponse } from '#layers/identity/server/api/digest/demo // A failed demo costs the section its picture, never its copy or its CTA: // `data` stays null on error and `hasDemo` gates the whole frame. -const { data: demo } = await useFetch('/api/digest/demo', { +// Only the card and its row count reach the payload. The answer also carries +// the whole email as `html`, which this preview never renders. +const { data: demo } = await useFetch('/api/digest/demo', { key: 'home-digest-demo-v1', + transform: ({ card, rowCount }: DigestDemoResponse) => ({ card, rowCount }), }) const hasDemo = computed(() => !!demo.value?.card && (demo.value?.rowCount ?? 0) > 0) diff --git a/app/pages/index.vue b/app/pages/index.vue index 9bd7276c..bd9434c2 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -91,9 +91,10 @@ const [ query: { limit: 24 }, // The board ships only its head. The rest of the named Skills, with their // posts and star series, serve `/skills/trending` and would ride in this - // page's payload unread. + // page's payload unread. The texture reads only Skill names, so the + // repository cards stay out too: they were 47 kB of the payload. transform: (feed: TrendingFeedResponse) => ({ - items: feed.items, + textureNames: feed.items.flatMap(repo => repo.skills.map(skill => `${repo.owner}/${repo.repo}/${skill.name}`)), board: feedBoardRows(feed).slice(0, HOME_BOARD_ROWS), }), })), @@ -110,8 +111,6 @@ const { data: demosData } = useLazyFetch<{ items: HomeDemoItem[] }>('/api/skill- const recentUpdates = computed(() => updatesData.value?.items ?? []) const recentPublishes = computed(() => publishesData.value?.items ?? []) -const trendingRepos = computed(() => trendingData.value?.items ?? []) - /** Owners whose avatar failed, which means the GitHub account is gone; see `/skills/trending`. */ const missingAvatars = ref(new Set()) function onAvatarError(owner: string) { @@ -132,9 +131,7 @@ const showTrending = computed(() => (trendingData.value?.board.length ?? 0) >= H const weekBoardPath = trendingRangeMeta('week').path /** Real Skill names from this week's trending repositories, for the hero texture. */ -const heroTextureNames = computed(() => - trendingRepos.value.flatMap(repo => repo.skills.map(skill => `${repo.owner}/${repo.repo}/${skill.name}`)), -) +const heroTextureNames = computed(() => trendingData.value?.textureNames ?? []) /** * Track order, measured 2026-09-04 rather than assumed. diff --git a/shared/image-proxy.ts b/shared/image-proxy.ts index 81a6e396..fe44a1f0 100644 --- a/shared/image-proxy.ts +++ b/shared/image-proxy.ts @@ -88,7 +88,20 @@ export function avatarProxyUrl(src: string | null | undefined): string | undefin const target = parseImageTarget(src) if (target._tag === 'Err' || !isAvatarUrl(target.url)) return undefined - return `${IMAGE_PROXY_PREFIX}/avatar?url=${encodeURIComponent(target.url.href)}` + return `${IMAGE_PROXY_PREFIX}/avatar?url=${encodeURIComponent(avatarAtThumbnailSize(target.url).href)}` +} + +/** + * Bluesky's `avatar` path serves the upload at full size, often 1000 pixels + * square. Pages draw avatars at 20 to 40 pixels, so the trending board shipped + * about 760 kB of Bluesky avatars where the `avatar_thumbnail` path sends 39 kB. + */ +function avatarAtThumbnailSize(url: URL): URL { + if (url.hostname !== 'cdn.bsky.app' || !url.pathname.startsWith('/img/avatar/')) + return url + const thumbnail = new URL(url) + thumbnail.pathname = url.pathname.replace('/img/avatar/', '/img/avatar_thumbnail/') + return thumbnail } /** The proxy address for a GitHub account avatar at a pixel size. */ diff --git a/test/unit/image-proxy.test.ts b/test/unit/image-proxy.test.ts index 3dd3b2d7..3431e63c 100644 --- a/test/unit/image-proxy.test.ts +++ b/test/unit/image-proxy.test.ts @@ -69,7 +69,7 @@ describe('avatar proxy addresses', () => { ['a GitHub avatar', 'https://avatars.githubusercontent.com/u/1?v=4'], ['a GitHub login avatar', 'https://github.com/harlan-zw.png'], ['an X avatar', 'https://pbs.twimg.com/profile_images/1/a_normal.jpg'], - ['a Bluesky avatar', 'https://cdn.bsky.app/img/avatar/plain/did:plc:abc/bafk@jpeg'], + ['a Bluesky avatar thumbnail', 'https://cdn.bsky.app/img/avatar_thumbnail/plain/did:plc:abc/bafk@jpeg'], ])('proxies %s', (_label, src) => { const path = avatarProxyUrl(src)! expect(path.startsWith('/_img/avatar?')).toBe(true) @@ -85,6 +85,14 @@ describe('avatar proxy addresses', () => { expect(parseImageProxyRequest('avatar', { url: src })).toEqual({ _tag: 'NotFound', reason: 'not-avatar' }) }) + it('proxies a full-size Bluesky avatar as its thumbnail', () => { + const path = avatarProxyUrl('https://cdn.bsky.app/img/avatar/plain/did:plc:abc/bafk@jpeg')! + expect(parseImageProxyRequest('avatar', queryOf(path))).toEqual({ + _tag: 'Ok', + request: avatar('https://cdn.bsky.app/img/avatar_thumbnail/plain/did:plc:abc/bafk@jpeg'), + }) + }) + it('builds a GitHub login avatar address the route accepts', () => { const parsed = parseImageProxyRequest('avatar', queryOf(githubAvatarProxyUrl('antfu', 64))) expect(parsed).toEqual({ _tag: 'Ok', request: avatar('https://github.com/antfu.png?size=64') })