Skip to content

Commit 28439aa

Browse files
committed
Move social share avatar to public folder
1 parent 817922b commit 28439aa

3 files changed

Lines changed: 64 additions & 18 deletions

File tree

4.08 KB
Loading

‎src/pages/api/social-card/__tests__/index.spec.ts‎

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest'
22
import { GET } from '@pages/api/social-card'
3-
4-
vi.mock('@assets/images/avatars/kevin-brown.webp', () => ({
5-
default: {
6-
src: '/_astro/kevin-brown.test.webp',
7-
fsPath: '/virtual/assets/kevin-brown.webp',
8-
},
3+
import { tmpdir } from 'node:os'
4+
import { join } from 'node:path'
5+
6+
const accessMock = vi.hoisted(() => vi.fn())
7+
const mkdirMock = vi.hoisted(() => vi.fn())
8+
const writeFileMock = vi.hoisted(() => vi.fn())
9+
const fetchMock = vi.hoisted(() => vi.fn())
10+
11+
vi.mock('node:fs/promises', () => ({
12+
access: accessMock,
13+
mkdir: mkdirMock,
14+
writeFile: writeFileMock,
915
}))
1016

1117
type CollectionFixture = Array<{
@@ -73,9 +79,23 @@ const seedCollections = () => {
7379
}
7480

7581
describe('Social Card API - GET /api/social-card', () => {
82+
const expectedAvatarPath = join(tmpdir(), 'webstackbuilders-social-cards', 'kevin-brown.webp')
83+
7684
beforeEach(() => {
7785
generateOpenGraphImageMock.mockReset()
7886
generateOpenGraphImageMock.mockResolvedValue(Buffer.from('mock-image'))
87+
accessMock.mockReset()
88+
mkdirMock.mockReset()
89+
writeFileMock.mockReset()
90+
fetchMock.mockReset()
91+
accessMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
92+
mkdirMock.mockResolvedValue(undefined)
93+
writeFileMock.mockResolvedValue(undefined)
94+
fetchMock.mockResolvedValue({
95+
ok: true,
96+
arrayBuffer: vi.fn(async () => Uint8Array.from([1, 2, 3]).buffer),
97+
})
98+
vi.stubGlobal('fetch', fetchMock)
7999
seedCollections()
80100
})
81101

@@ -133,10 +153,22 @@ describe('Social Card API - GET /api/social-card', () => {
133153
expect(generateOpenGraphImageMock).toHaveBeenCalledWith(
134154
expect.objectContaining({
135155
logo: expect.objectContaining({
136-
path: '/virtual/assets/kevin-brown.webp',
156+
path: expectedAvatarPath,
137157
}),
138158
})
139159
)
160+
161+
expect(fetchMock).toHaveBeenCalledWith(new URL('/assets/images/kevin-brown.webp', 'http://localhost/api/social-card?slug=home'))
162+
expect(writeFileMock).toHaveBeenCalledWith(expectedAvatarPath, expect.any(Buffer))
163+
})
164+
165+
it('reuses the cached avatar file when it already exists locally', async () => {
166+
accessMock.mockResolvedValueOnce(undefined)
167+
168+
await buildRequest('http://localhost/api/social-card?slug=home')
169+
170+
expect(fetchMock).not.toHaveBeenCalled()
171+
expect(writeFileMock).not.toHaveBeenCalled()
140172
})
141173

142174
it('returns an error response when generation fails', async () => {

‎src/pages/api/social-card/index.ts‎

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
11
import type { APIRoute } from 'astro'
22
import { getCollection } from 'astro:content'
33
import { generateOpenGraphImage } from 'astro-og-canvas'
4-
import kevinBrownAvatar from '@assets/images/avatars/kevin-brown.webp'
4+
import { access, mkdir, writeFile } from 'node:fs/promises'
5+
import { tmpdir } from 'node:os'
6+
import { join } from 'node:path'
57
import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_utils/errors'
68
import { createApiFunctionContext } from '@pages/api/_utils/requestContext'
79

810
export const prerender = false
911

1012
const ROUTE = '/api/social-card'
13+
const PUBLIC_AVATAR_PATH = '/assets/images/kevin-brown.webp'
14+
const CACHED_AVATAR_DIRECTORY = join(tmpdir(), 'webstackbuilders-social-cards')
15+
const CACHED_AVATAR_PATH = join(CACHED_AVATAR_DIRECTORY, 'kevin-brown.webp')
1116
const DEFAULT_TITLE = 'Platform Engineering by Kevin Brown'
1217
const DEFAULT_DESCRIPTION =
1318
'Platform engineer helping teams harden delivery, modernize cloud platforms, and improve developer experience.'
1419

15-
type ImageMetadataWithFsPath = typeof kevinBrownAvatar & {
16-
fsPath?: string
17-
}
18-
1920
type CollectionKey = 'articles' | 'caseStudies' | 'services' | 'downloads'
2021
type PaletteKey = 'articles' | 'case-studies' | 'services' | 'downloads' | 'default'
2122

@@ -60,14 +61,27 @@ const gradientPalette: Record<PaletteKey, [number, number, number][]> = {
6061
],
6162
}
6263

63-
const getAvatarFilePath = (): string => {
64-
const avatarFilePath = (kevinBrownAvatar as ImageMetadataWithFsPath).fsPath
64+
const getAvatarFilePath = async (requestUrl: string): Promise<string> => {
65+
try {
66+
await access(CACHED_AVATAR_PATH)
67+
return CACHED_AVATAR_PATH
68+
} catch {
69+
// Cache miss; fetch from the public asset URL and persist locally for astro-og-canvas.
70+
}
6571

66-
if (!avatarFilePath) {
67-
throw new Error('Kevin Brown avatar asset is missing a local file path')
72+
const avatarUrl = new URL(PUBLIC_AVATAR_PATH, requestUrl)
73+
const response = await fetch(avatarUrl)
74+
75+
if (!response.ok) {
76+
throw new Error(`Unable to fetch Kevin Brown avatar from ${avatarUrl.toString()}`)
6877
}
6978

70-
return avatarFilePath
79+
const avatarBuffer = Buffer.from(await response.arrayBuffer())
80+
81+
await mkdir(CACHED_AVATAR_DIRECTORY, { recursive: true })
82+
await writeFile(CACHED_AVATAR_PATH, avatarBuffer)
83+
84+
return CACHED_AVATAR_PATH
7185
}
7286

7387
/** Normalize slug parameters to a consistent format */
@@ -133,7 +147,7 @@ export const GET: APIRoute = async ({ request, clientAddress, cookies }) => {
133147
const slug = normalizeSlug(url.searchParams.get('slug'))
134148
const titleOverride = url.searchParams.get('title')
135149
const descriptionOverride = url.searchParams.get('description')
136-
const avatarPath = getAvatarFilePath()
150+
const avatarPath = await getAvatarFilePath(request.url)
137151

138152
const contentIndex = await buildContentIndex()
139153
const matchedEntry = contentIndex[slug]

0 commit comments

Comments
 (0)