feat: Directus SDK client with typed helpers

This commit is contained in:
achmad
2026-05-28 22:26:13 +07:00
parent 7ffc6cbad6
commit 1ab153c94f
2 changed files with 222 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockRequest = vi.fn()
const mockWith = vi.fn()
vi.mock('@directus/sdk', () => ({
createDirectus: vi.fn(() => ({ with: mockWith })),
rest: vi.fn(() => 'rest-plugin'),
staticToken: vi.fn(() => 'token-plugin'),
readItems: vi.fn((collection: string) => ({ _op: 'readItems', collection })),
readSingleton: vi.fn((collection: string) => ({ _op: 'readSingleton', collection })),
readItem: vi.fn((collection: string, id: string) => ({ _op: 'readItem', collection, id })),
}))
mockWith.mockReturnValue({ with: mockWith, request: mockRequest })
beforeEach(() => {
vi.resetModules()
mockRequest.mockReset()
process.env.DIRECTUS_URL = 'https://cms.achmad.dev'
process.env.DIRECTUS_TOKEN = 'test-token'
})
describe('getAllCategories', () => {
it('returns array of categories from Directus', async () => {
mockRequest.mockResolvedValue([
{ id: '1', name: 'Anime', slug: 'anime', description: null },
{ id: '2', name: 'VTubers', slug: 'vtubers', description: null },
])
const { getAllCategories } = await import('@/lib/directus')
const result = await getAllCategories()
expect(result).toHaveLength(2)
expect(result[0].slug).toBe('anime')
})
})
describe('getArticleBySlug', () => {
it('returns null when no article matches', async () => {
mockRequest.mockResolvedValue([])
const { getArticleBySlug } = await import('@/lib/directus')
const result = await getArticleBySlug('nonexistent')
expect(result).toBeNull()
})
it('returns the first matching article', async () => {
mockRequest.mockResolvedValue([
{
id: '1', title: 'Frieren S2', slug: 'frieren-s2', status: 'published',
content: '<p>Body</p>', excerpt: 'Short', featured_image: null,
published_at: '2026-05-28T00:00:00Z', is_featured: false,
seo_title: null, seo_description: null,
category: { id: '1', name: 'Anime', slug: 'anime', description: null },
tags: [],
},
])
const { getArticleBySlug } = await import('@/lib/directus')
const result = await getArticleBySlug('frieren-s2')
expect(result?.title).toBe('Frieren S2')
})
})
describe('getAssetUrl', () => {
it('constructs a Directus asset URL from a file UUID', async () => {
const { getAssetUrl } = await import('@/lib/directus')
const url = getAssetUrl('abc-123-uuid')
expect(url).toBe('https://cms.achmad.dev/assets/abc-123-uuid')
})
it('appends width and quality query params', async () => {
const { getAssetUrl } = await import('@/lib/directus')
const url = getAssetUrl('abc-123-uuid', { width: 800, quality: 80 })
expect(url).toContain('width=800')
expect(url).toContain('quality=80')
})
})
+147
View File
@@ -0,0 +1,147 @@
import {
createDirectus,
rest,
staticToken,
readItems,
readSingleton,
readItem,
} from '@directus/sdk'
import type { Article, Category, SiteSettings } from './types'
const directus = createDirectus(process.env.DIRECTUS_URL!)
.with(staticToken(process.env.DIRECTUS_TOKEN!))
.with(rest())
export async function getAllCategories(): Promise<Category[]> {
return directus.request(
readItems('categories', {
fields: ['id', 'name', 'slug', 'description'],
sort: ['name'],
})
) as Promise<Category[]>
}
export async function getCategoryBySlug(slug: string): Promise<Category | null> {
const results = await directus.request(
readItems('categories', {
fields: ['id', 'name', 'slug', 'description'],
filter: { slug: { _eq: slug } },
limit: 1,
})
) as Category[]
return results[0] ?? null
}
export async function getArticles(options: {
limit?: number
offset?: number
categorySlug?: string
} = {}): Promise<Article[]> {
const { limit = 12, offset = 0, categorySlug } = options
const filter: Record<string, unknown> = { status: { _eq: 'published' } }
if (categorySlug) {
filter['category'] = { slug: { _eq: categorySlug } }
}
return directus.request(
readItems('articles', {
fields: [
'id', 'title', 'slug', 'status', 'excerpt', 'featured_image',
'published_at', 'is_featured',
'category.id', 'category.name', 'category.slug',
],
filter,
sort: ['-published_at'],
limit,
offset,
})
) as Promise<Article[]>
}
export async function getArticleBySlug(slug: string): Promise<Article | null> {
const results = await directus.request(
readItems('articles', {
fields: [
'id', 'title', 'slug', 'status', 'content', 'excerpt',
'featured_image', 'published_at', 'is_featured',
'seo_title', 'seo_description',
'category.id', 'category.name', 'category.slug',
'tags.tags_id.id', 'tags.tags_id.name', 'tags.tags_id.slug',
],
filter: { slug: { _eq: slug }, status: { _eq: 'published' } },
limit: 1,
})
) as Article[]
return results[0] ?? null
}
export async function getArticlePathById(id: string): Promise<{
slug: string
category: { slug: string }
} | null> {
try {
return await directus.request(
readItem('articles', id, {
fields: ['slug', 'category.slug'],
})
) as { slug: string; category: { slug: string } }
} catch {
return null
}
}
export async function getRelatedArticles(
categorySlug: string,
excludeSlug: string,
): Promise<Article[]> {
return directus.request(
readItems('articles', {
fields: [
'id', 'title', 'slug', 'excerpt', 'featured_image',
'published_at', 'category.id', 'category.name', 'category.slug',
],
filter: {
status: { _eq: 'published' },
category: { slug: { _eq: categorySlug } },
slug: { _neq: excludeSlug },
},
sort: ['-published_at'],
limit: 4,
})
) as Promise<Article[]>
}
export async function getSiteSettings(): Promise<SiteSettings> {
return directus.request(
readSingleton('site_settings', {
fields: [
'id', 'site_name',
'hero_article.id', 'hero_article.title', 'hero_article.slug',
'hero_article.excerpt', 'hero_article.featured_image',
'hero_article.category.slug', 'hero_article.category.name',
'nav_categories.id', 'nav_categories.name', 'nav_categories.slug',
],
})
) as Promise<SiteSettings>
}
export async function searchArticles(query: string): Promise<Article[]> {
return directus.request(
readItems('articles', {
fields: ['id', 'title', 'slug', 'category.slug', 'category.name'],
search: query,
filter: { status: { _eq: 'published' } },
limit: 8,
})
) as Promise<Article[]>
}
export function getAssetUrl(
fileId: string,
params?: { width?: number; height?: number; quality?: number },
): string {
const url = new URL(`/assets/${fileId}`, process.env.DIRECTUS_URL!)
if (params?.width) url.searchParams.set('width', String(params.width))
if (params?.height) url.searchParams.set('height', String(params.height))
if (params?.quality) url.searchParams.set('quality', String(params.quality))
return url.toString()
}