feat: Directus SDK client with typed helpers
This commit is contained in:
@@ -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
@@ -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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user